diff --git a/core/README.md b/core/README.md index 394c9be..f4c7a74 100644 --- a/core/README.md +++ b/core/README.md @@ -124,6 +124,18 @@ L1 版面 → L2 谱行 → L3 纵向分割线 → 派生小节 → L4 音符 RO 结构调试字段:`structure.barlines[]`(可编辑分割线)、L3 items `kind=measure_derived`。 文档:[l3-split-model.md](../docs/l3-split-model.md) · [architecture-structure-first.md](../docs/architecture-structure-first.md) · 根 [README](../README.md)。 +### L1–L3 布局训练数据(#93) + +从桌面 **`.enpu.json`** 工程导出标准 layout 样本: + +```powershell +# 仓库根 +$env:PYTHONPATH = ".\core" +python scripts/export_layout_gt.py --project path\to\song.enpu.json --out samples\layout\L00x +``` + +模块:`app/layout_gt/`(export + validate)。规范:[docs/train/l1-l3-data-spec.md](../docs/train/l1-l3-data-spec.md)。 + ## Sidecar 打包(可选,Issue #8) ```powershell diff --git a/core/app/layout_gt/__init__.py b/core/app/layout_gt/__init__.py new file mode 100644 index 0000000..facd296 --- /dev/null +++ b/core/app/layout_gt/__init__.py @@ -0,0 +1,21 @@ +"""L1–L3 layout ground-truth export & validation (#93 / #92). + +See ``docs/train/l1-l3-data-spec.md`` for the training sample schema. +""" + +from app.layout_gt.export import ( + LAYOUT_SCHEMA_VERSION, + export_project_to_sample_dir, + layout_sample_from_project, + layout_sample_from_structure, +) +from app.layout_gt.validate import ValidationResult, validate_layout_sample + +__all__ = [ + "LAYOUT_SCHEMA_VERSION", + "export_project_to_sample_dir", + "layout_sample_from_project", + "layout_sample_from_structure", + "validate_layout_sample", + "ValidationResult", +] diff --git a/core/app/layout_gt/export.py b/core/app/layout_gt/export.py new file mode 100644 index 0000000..0618b95 --- /dev/null +++ b/core/app/layout_gt/export.py @@ -0,0 +1,566 @@ +"""Export EnPu project / structure → L1–L3 layout training sample (#93). + +Primary input is a desktop ``.enpu.json`` project (``project_version`` 0.2):: + + { + "kind": "enpu-project", + "project_version": "0.2", + "title": "...", + "score": { ... Score v0.1 ... }, + "source_image": "M04_manual.png", + "source_image_data_url": "data:image/png;base64,...", # optional + "structure": { + "pipeline": "structure", + "summary": { "width", "height", "n_systems", ... }, + "items": [ { "layer": "L1"|"L2"|..., "box", "kind", "id", ... } ], + "barlines": [ { "system", "x", "y1", "y2", "id"?, "source"? } ] + }, + ... + } + +Layout GT **does not** embed Score note sequences; only optional page meta +(title/key/time) and L1–L3 geometry. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import re +from pathlib import Path +from typing import Any + +from app.layout_gt.validate import validate_layout_sample + +LAYOUT_SCHEMA_VERSION = "0.1" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _as_dict(obj: Any) -> dict[str, Any]: + if obj is None: + return {} + if isinstance(obj, dict): + return obj + if hasattr(obj, "model_dump"): + return obj.model_dump(mode="json") + if hasattr(obj, "dict"): + return obj.dict() + raise TypeError(f"expected mapping, got {type(obj)!r}") + + +def _box(raw: Any) -> dict[str, float] | None: + if raw is None: + return None + if isinstance(raw, dict): + try: + x1, y1 = float(raw["x1"]), float(raw["y1"]) + x2, y2 = float(raw["x2"]), float(raw["y2"]) + except (KeyError, TypeError, ValueError): + return None + else: + try: + x1, y1, x2, y2 = ( + float(raw.x1), + float(raw.y1), + float(raw.x2), + float(raw.y2), + ) + except (AttributeError, TypeError, ValueError): + return None + if x2 < x1: + x1, x2 = x2, x1 + if y2 < y1: + y1, y2 = y2, y1 + return {"x1": x1, "y1": y1, "x2": x2, "y2": y2} + + +def _item_layer(it: dict[str, Any]) -> str: + return str(it.get("layer") or "").upper() + + +def _l1_role(it: dict[str, Any]) -> str: + kind = str(it.get("kind") or "").lower() + label = str(it.get("label") or "").lower() + for role in ("title", "key_time", "score", "other"): + if kind == role or role in label: + return role + if "key" in kind or "time" in kind or "meta" in kind: + return "key_time" + return kind or "other" + + +def _decode_data_url(data_url: str) -> tuple[bytes, str]: + """Return (bytes, ext) from a data:image/...;base64,... URL.""" + m = re.match( + r"^data:image/(png|jpeg|jpg|webp|gif);base64,(.+)$", + data_url.strip(), + flags=re.IGNORECASE | re.DOTALL, + ) + if not m: + raise ValueError("unsupported or invalid source_image_data_url") + fmt = m.group(1).lower() + ext = "jpg" if fmt in ("jpeg", "jpg") else fmt + raw = base64.b64decode(m.group(2)) + return raw, ext + + +def _image_size_from_png(data: bytes) -> tuple[int, int] | None: + if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n": + return None + # IHDR: width/height big-endian at bytes 16..24 + w = int.from_bytes(data[16:20], "big") + h = int.from_bytes(data[20:24], "big") + return w, h + + +def _sha256_hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _interior_splits_from_edges( + xs: list[float], + *, + x_left: float | None = None, + x_right: float | None = None, + n_measures: int | None = None, + edge_eps: float = 2.0, +) -> list[float]: + """Convert barline x list to #85 **interior** splits. + + Real ``.enpu.json`` files may store either: + + - **#85 interiors**: ``n_splits = n_measures - 1`` + - **#66 edges**: ``n_barlines = n_measures + 1`` (includes outer measure bounds) + - raw detector xs that may hug L2 left/right + + We drop endpoints near L2 bounds and, when ``n_measures`` is known and + ``len(xs) == n_measures + 1``, drop the first/last edge. + """ + xs = sorted(float(x) for x in xs) + if not xs: + return [] + + if ( + n_measures is not None + and n_measures >= 1 + and len(xs) == n_measures + 1 + ): + # Full edge chain → interiors + return xs[1:-1] if len(xs) >= 2 else [] + + out = list(xs) + if x_left is not None: + out = [x for x in out if x > x_left + edge_eps] + if x_right is not None: + out = [x for x in out if x < x_right - edge_eps] + return out + + +def _measures_to_interior_xs( + measures: list[dict[str, float]], + *, + min_gap: float = 4.0, +) -> list[float]: + """Shared vertical boundaries between left-sorted measure boxes.""" + if len(measures) < 2: + return [] + ms = sorted(measures, key=lambda b: (b["x1"] + b["x2"]) / 2.0) + xs: list[float] = [] + for i in range(len(ms) - 1): + # Shared boundary ≈ mid of right edge of i and left edge of i+1 + x = 0.5 * (ms[i]["x2"] + ms[i + 1]["x1"]) + if not xs or x - xs[-1] >= min_gap: + xs.append(x) + return xs + + +# --------------------------------------------------------------------------- +# Core conversion +# --------------------------------------------------------------------------- + + +def layout_sample_from_structure( + structure: dict[str, Any] | Any, + *, + image: dict[str, Any] | None = None, + meta: dict[str, Any] | None = None, + sample_id: str | None = None, + include_derived_measures: bool = True, + source: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a layout sample dict from RecognizeResponse.structure / project.structure.""" + st = _as_dict(structure) + items = list(st.get("items") or []) + barlines = list(st.get("barlines") or []) + summary = st.get("summary") or {} + + # --- image --- + img = dict(image or {}) + if "width" not in img and summary.get("width") is not None: + img["width"] = int(summary["width"]) + if "height" not in img and summary.get("height") is not None: + img["height"] = int(summary["height"]) + + # --- L1 --- + l1_regions: list[dict[str, Any]] = [] + l1_map: dict[str, dict[str, float]] = {} + for it in items: + if _item_layer(it) != "L1": + continue + box = _box(it.get("box")) + if not box: + continue + role = _l1_role(it) + entry = { + "role": role, + "box": box, + "id": it.get("id") or f"l1-{role}", + "confidence": it.get("confidence"), + } + l1_regions.append(entry) + # first wins for known roles + if role in ("title", "key_time", "score") and role not in l1_map: + l1_map[role] = box + + l1: dict[str, Any] = {"regions": l1_regions} + if "score" in l1_map: + l1["score_region"] = l1_map["score"] + if "title" in l1_map: + l1["title"] = l1_map["title"] + if "key_time" in l1_map: + l1["key_time"] = l1_map["key_time"] + + # --- L2 --- + l2_items = [it for it in items if _item_layer(it) == "L2"] + l2_items = sorted( + l2_items, + key=lambda it: ( + float((_box(it.get("box")) or {}).get("y1", 0)), + float((_box(it.get("box")) or {}).get("x1", 0)), + ), + ) + systems: list[dict[str, Any]] = [] + for order, it in enumerate(l2_items): + box = _box(it.get("box")) + if not box: + continue + # Prefer id index when present (l2-sys0) + sid = str(it.get("id") or f"l2-sys{order}") + m = re.search(r"(\d+)$", sid) + sys_index = int(m.group(1)) if m else order + systems.append( + { + "id": sid, + "index": sys_index, + "bbox": box, + "kind": str(it.get("kind") or "system"), + "label": it.get("label"), + "confidence": it.get("confidence"), + } + ) + # Re-index 0..n-1 in reading order for stable export + systems.sort(key=lambda s: (s["bbox"]["y1"], s["bbox"]["x1"])) + for i, s in enumerate(systems): + s["index"] = i + + # --- L3 measures per system (optional derived) --- + l3_items = [it for it in items if _item_layer(it) == "L3"] + measures_by_sys: dict[int, list[dict[str, Any]]] = {s["index"]: [] for s in systems} + + def _assign_system(box: dict[str, float]) -> int | None: + if not systems: + return None + cy = 0.5 * (box["y1"] + box["y2"]) + cx = 0.5 * (box["x1"] + box["x2"]) + best_i = None + best_pen = 1e18 + for s in systems: + b = s["bbox"] + # vertical containment preferred + if b["y1"] - 2 <= cy <= b["y2"] + 2: + pen = 0.0 + else: + pen = min(abs(cy - b["y1"]), abs(cy - b["y2"])) + 1000.0 + # horizontal soft + if cx < b["x1"] or cx > b["x2"]: + pen += min(abs(cx - b["x1"]), abs(cx - b["x2"])) + if pen < best_pen: + best_pen = pen + best_i = s["index"] + return best_i + + for it in l3_items: + box = _box(it.get("box")) + if not box: + continue + si = _assign_system(box) + if si is None: + continue + measures_by_sys.setdefault(si, []).append( + { + "id": it.get("id"), + "label": it.get("label"), + "kind": it.get("kind") or "measure_derived", + "box": box, + "confidence": it.get("confidence"), + } + ) + for si in measures_by_sys: + measures_by_sys[si].sort( + key=lambda m: (m["box"]["x1"] + m["box"]["x2"]) / 2.0 + ) + + # --- L3 splits from barlines (primary) --- + # Map original structure system index → export index + # Barlines use structure system index; L2 may have been re-sorted. + # Prefer matching by order: if structure L2 ids are l2-sys{k}, barline.system == k. + raw_by_sys: dict[int, list[dict[str, Any]]] = {} + for b in barlines: + if not isinstance(b, dict) or b.get("x") is None: + continue + try: + si = int(b.get("system", -1)) + except (TypeError, ValueError): + continue + raw_by_sys.setdefault(si, []).append(b) + + # If barline system ids don't match re-indexed systems, try identity + rows: list[dict[str, Any]] = [] + for s in systems: + si = s["index"] + # barlines may still use original system numbers matching l2-sys{n} + # Use original id number if present + orig_m = re.search(r"sys(\d+)", str(s.get("id") or "")) + candidates: list[dict[str, Any]] = [] + if orig_m: + candidates = list(raw_by_sys.get(int(orig_m.group(1)), [])) + if not candidates: + candidates = list(raw_by_sys.get(si, [])) + + raw_xs = [float(b["x"]) for b in candidates] + n_meas = len(measures_by_sys.get(si) or []) + interiors = _interior_splits_from_edges( + raw_xs, + x_left=s["bbox"]["x1"], + x_right=s["bbox"]["x2"], + n_measures=n_meas if n_meas > 0 else None, + ) + # Fallback: derive from measure boxes + if not interiors and n_meas >= 2: + interiors = _measures_to_interior_xs( + [m["box"] for m in measures_by_sys[si]] + ) + + # Attach metadata from nearest raw barline when possible + splits: list[dict[str, Any]] = [] + for i, x in enumerate(interiors): + src = "migrate" + sid = f"s{si}-{i}" + conf = None + y1 = s["bbox"]["y1"] + y2 = s["bbox"]["y2"] + best = None + best_d = 1e18 + for b in candidates: + d = abs(float(b["x"]) - x) + if d < best_d: + best_d = d + best = b + if best is not None and best_d <= 4.0: + src = str(best.get("source") or "detect") + if best.get("id"): + sid = str(best["id"]) + if best.get("confidence") is not None: + conf = best.get("confidence") + if best.get("y1") is not None: + y1 = float(best["y1"]) + if best.get("y2") is not None: + y2 = float(best["y2"]) + sp: dict[str, Any] = { + "id": sid, + "x": float(x), + "y1": y1, + "y2": y2, + "source": src, + } + if conf is not None: + sp["confidence"] = conf + splits.append(sp) + + row: dict[str, Any] = { + "system_id": s["id"], + "system_index": si, + "splits": splits, + } + if include_derived_measures and measures_by_sys.get(si): + row["measures"] = [ + { + "id": m.get("id"), + "label": m.get("label"), + "box": m["box"], + } + for m in measures_by_sys[si] + ] + rows.append(row) + + sample: dict[str, Any] = { + "layout_schema_version": LAYOUT_SCHEMA_VERSION, + "kind": "enpu-layout-gt", + "image": img, + "l1": l1, + "l2": {"systems": systems}, + "l3": {"rows": rows}, + } + if sample_id: + sample["id"] = sample_id + if meta: + sample["meta"] = meta + if source: + sample["source"] = source + return sample + + +def layout_sample_from_project( + project: dict[str, Any] | str | Path, + *, + sample_id: str | None = None, + image_relpath: str = "image.png", + include_derived_measures: bool = True, +) -> dict[str, Any]: + """Load ``.enpu.json`` (path or dict) → layout sample (without writing files).""" + if isinstance(project, (str, Path)): + path = Path(project) + project = json.loads(path.read_text(encoding="utf-8")) + default_id = path.stem.replace(".enpu", "") + source_path = str(path) + else: + default_id = str(project.get("title") or "sample") + source_path = None + + if not isinstance(project, dict): + raise TypeError("project must be a dict or path") + + structure = project.get("structure") + if not structure: + raise ValueError( + "project has no structure field; re-open in desktop structure mode " + "and save after recognition" + ) + + score = project.get("score") or {} + meta = { + "title": project.get("title") or score.get("title"), + "key": score.get("key"), + "time_signature": score.get("time_signature"), + "source_image_name": project.get("source_image"), + "project_version": project.get("project_version"), + "engine": (project.get("meta") or {}).get("engine") + or (score.get("meta") or {}).get("engine"), + } + # drop empty meta keys + meta = {k: v for k, v in meta.items() if v is not None and v != ""} + + img: dict[str, Any] = {"path": image_relpath} + # size from structure summary preferred + summary = (structure or {}).get("summary") or {} + if summary.get("width") is not None: + img["width"] = int(summary["width"]) + if summary.get("height") is not None: + img["height"] = int(summary["height"]) + + source = { + "type": "enpu_project", + "kind": project.get("kind"), + "project_version": project.get("project_version"), + "path": source_path, + } + + return layout_sample_from_structure( + structure, + image=img, + meta=meta, + sample_id=sample_id or default_id, + include_derived_measures=include_derived_measures, + source=source, + ) + + +def export_project_to_sample_dir( + project: dict[str, Any] | str | Path, + out_dir: str | Path, + *, + sample_id: str | None = None, + copy_image: bool = True, + validate: bool = True, + include_derived_measures: bool = True, +) -> dict[str, Any]: + """Write ``layout.json`` (+ ``image.*``) under ``out_dir``. + + Returns the layout sample dict. Raises ``ValueError`` if validation fails + when ``validate=True``. + """ + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + project_path: Path | None = None + if isinstance(project, (str, Path)): + project_path = Path(project) + project_data = json.loads(project_path.read_text(encoding="utf-8")) + else: + project_data = project + + # Determine image bytes + image_bytes: bytes | None = None + image_ext = "png" + data_url = project_data.get("source_image_data_url") + if copy_image and isinstance(data_url, str) and data_url.startswith("data:image"): + image_bytes, image_ext = _decode_data_url(data_url) + elif copy_image and project_path is not None: + # sibling image next to project + name = project_data.get("source_image") + if name: + cand = project_path.parent / name + if cand.is_file(): + image_bytes = cand.read_bytes() + image_ext = cand.suffix.lstrip(".") or "png" + + image_name = f"image.{image_ext}" + if image_bytes is not None: + (out_dir / image_name).write_bytes(image_bytes) + + sample = layout_sample_from_project( + project_data, + sample_id=sample_id, + image_relpath=image_name if image_bytes is not None else ( + str(project_data.get("source_image") or "image.png") + ), + include_derived_measures=include_derived_measures, + ) + if project_path is not None: + sample.setdefault("source", {})["path"] = str(project_path) + + if image_bytes is not None: + sample.setdefault("image", {})["sha256"] = _sha256_hex(image_bytes) + size = _image_size_from_png(image_bytes) + if size: + sample["image"]["width"], sample["image"]["height"] = size + + if validate: + result = validate_layout_sample(sample) + if not result.ok: + raise ValueError( + "layout sample validation failed:\n" + + "\n".join(f" - {e}" for e in result.errors) + ) + sample.setdefault("export", {})["validation_warnings"] = list(result.warnings) + + layout_path = out_dir / "layout.json" + layout_path.write_text( + json.dumps(sample, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return sample diff --git a/core/app/layout_gt/validate.py b/core/app/layout_gt/validate.py new file mode 100644 index 0000000..51c5fd4 --- /dev/null +++ b/core/app/layout_gt/validate.py @@ -0,0 +1,233 @@ +"""Validate L1–L3 layout training samples (#93).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class ValidationResult: + ok: bool + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + def raise_if_error(self) -> None: + if not self.ok: + raise ValueError( + "layout validation failed:\n" + + "\n".join(f" - {e}" for e in self.errors) + ) + + +def _box_ok(box: Any, *, name: str, errors: list[str]) -> bool: + if not isinstance(box, dict): + errors.append(f"{name}: box must be object") + return False + for k in ("x1", "y1", "x2", "y2"): + if k not in box: + errors.append(f"{name}: missing box.{k}") + return False + try: + float(box[k]) + except (TypeError, ValueError): + errors.append(f"{name}: box.{k} not numeric") + return False + if float(box["x2"]) < float(box["x1"]): + errors.append(f"{name}: x2 < x1") + return False + if float(box["y2"]) < float(box["y1"]): + errors.append(f"{name}: y2 < y1") + return False + return True + + +def validate_layout_sample( + sample: dict[str, Any], + *, + min_split_gap: float = 1.0, + require_score_region: bool = True, + require_systems: bool = True, +) -> ValidationResult: + """Check schema + geometric consistency of a layout GT sample. + + Rules (hard errors unless noted): + + - ``layout_schema_version`` present + - image width/height > 0 when present + - L1 score_region recommended (error if ``require_score_region``) + - L2 systems with valid bboxes + - L3 rows: splits strictly increasing, strictly interior to L2 x-range + - if measures present: ``n_measures == n_splits + 1`` (warn if only measures) + """ + errors: list[str] = [] + warnings: list[str] = [] + + if not isinstance(sample, dict): + return ValidationResult(ok=False, errors=["sample must be a JSON object"]) + + ver = sample.get("layout_schema_version") + if not ver: + errors.append("missing layout_schema_version") + + image = sample.get("image") or {} + w = image.get("width") + h = image.get("height") + if w is not None: + try: + if int(w) <= 0: + errors.append("image.width must be > 0") + except (TypeError, ValueError): + errors.append("image.width not int") + else: + warnings.append("image.width missing") + if h is not None: + try: + if int(h) <= 0: + errors.append("image.height must be > 0") + except (TypeError, ValueError): + errors.append("image.height not int") + else: + warnings.append("image.height missing") + + # ----- L1 ----- + l1 = sample.get("l1") or {} + score = l1.get("score_region") + if score is None: + # try regions + for r in l1.get("regions") or []: + if str(r.get("role") or "").lower() == "score" and r.get("box"): + score = r["box"] + break + if score is None: + msg = "L1 score_region missing" + if require_score_region: + errors.append(msg) + else: + warnings.append(msg) + else: + _box_ok(score, name="l1.score_region", errors=errors) + + for key in ("title", "key_time"): + if key in l1 and l1[key] is not None: + _box_ok(l1[key], name=f"l1.{key}", errors=errors) + + # ----- L2 ----- + l2 = sample.get("l2") or {} + systems = list(l2.get("systems") or []) + if not systems: + msg = "L2 systems empty" + if require_systems: + errors.append(msg) + else: + warnings.append(msg) + + sys_by_id: dict[str, dict[str, Any]] = {} + sys_by_index: dict[int, dict[str, Any]] = {} + prev_y = -1e18 + for i, s in enumerate(systems): + name = f"l2.systems[{i}]" + if not isinstance(s, dict): + errors.append(f"{name}: not object") + continue + bbox = s.get("bbox") or s.get("box") + if not _box_ok(bbox, name=f"{name}.bbox", errors=errors): + continue + sid = str(s.get("id") or f"sys{i}") + sys_by_id[sid] = s + try: + idx = int(s.get("index", i)) + except (TypeError, ValueError): + idx = i + sys_by_index[idx] = s + y1 = float(bbox["y1"]) + if y1 + 1e-3 < prev_y: + warnings.append(f"{name}: systems not sorted by y (ok if multi-column)") + prev_y = y1 + + # ----- L3 ----- + l3 = sample.get("l3") or {} + rows = list(l3.get("rows") or []) + if systems and not rows: + warnings.append("L3 rows empty (no splits annotated)") + + for i, row in enumerate(rows): + name = f"l3.rows[{i}]" + if not isinstance(row, dict): + errors.append(f"{name}: not object") + continue + sid = row.get("system_id") + sidx = row.get("system_index") + sys = None + if sid is not None and str(sid) in sys_by_id: + sys = sys_by_id[str(sid)] + elif sidx is not None: + try: + sys = sys_by_index.get(int(sidx)) + except (TypeError, ValueError): + sys = None + if sys is None and systems: + # fallback by row order + if i < len(systems): + sys = systems[i] + warnings.append(f"{name}: system_id/index not matched; used systems[{i}]") + else: + errors.append(f"{name}: cannot resolve parent L2 system") + continue + bbox = (sys or {}).get("bbox") or (sys or {}).get("box") or {} + x_left = float(bbox.get("x1", 0)) if bbox else None + x_right = float(bbox.get("x2", 0)) if bbox else None + + splits = list(row.get("splits") or []) + xs: list[float] = [] + for j, sp in enumerate(splits): + sn = f"{name}.splits[{j}]" + if isinstance(sp, (int, float)): + x = float(sp) + elif isinstance(sp, dict) and sp.get("x") is not None: + try: + x = float(sp["x"]) + except (TypeError, ValueError): + errors.append(f"{sn}: x not numeric") + continue + else: + errors.append(f"{sn}: need x") + continue + xs.append(x) + if x_left is not None and x_right is not None: + if x <= x_left + 1e-6 or x >= x_right - 1e-6: + errors.append( + f"{sn}: x={x} not strictly interior to L2 " + f"({x_left}, {x_right})" + ) + + for a, b in zip(xs, xs[1:]): + if b - a < min_split_gap: + errors.append( + f"{name}: splits not strictly increasing with gap>={min_split_gap} " + f"({a} → {b})" + ) + if b < a - 1e-9: + errors.append(f"{name}: splits not sorted ({a} > {b})") + + measures = list(row.get("measures") or []) + if measures: + n_m = len(measures) + n_s = len(xs) + if n_m != n_s + 1: + errors.append( + f"{name}: n_measures ({n_m}) must equal n_splits+1 ({n_s + 1}) " + f"when measures are stored" + ) + # soft: measures left→right + mids = [] + for j, m in enumerate(measures): + box = m.get("box") if isinstance(m, dict) else None + if box and _box_ok(box, name=f"{name}.measures[{j}]", errors=errors): + mids.append(0.5 * (float(box["x1"]) + float(box["x2"]))) + for a, b in zip(mids, mids[1:]): + if b < a - 1e-3: + warnings.append(f"{name}: measures not left-to-right ordered") + + ok = len(errors) == 0 + return ValidationResult(ok=ok, errors=errors, warnings=warnings) diff --git a/core/tests/test_layout_gt.py b/core/tests/test_layout_gt.py new file mode 100644 index 0000000..1308cf7 --- /dev/null +++ b/core/tests/test_layout_gt.py @@ -0,0 +1,163 @@ +"""L1–L3 layout GT export & validation (#93).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from app.layout_gt.export import ( + _interior_splits_from_edges, + layout_sample_from_project, + layout_sample_from_structure, +) +from app.layout_gt.validate import validate_layout_sample + + +def _minimal_structure() -> dict: + return { + "pipeline": "structure", + "summary": {"width": 400, "height": 300, "n_systems": 1, "n_measures": 3}, + "items": [ + { + "layer": "L1", + "id": "l1-title", + "label": "title", + "kind": "title", + "box": {"x1": 50, "y1": 10, "x2": 350, "y2": 40}, + }, + { + "layer": "L1", + "id": "l1-score", + "label": "score", + "kind": "score", + "box": {"x1": 0, "y1": 50, "x2": 400, "y2": 280}, + }, + { + "layer": "L2", + "id": "l2-sys0", + "label": "谱行 1", + "kind": "system", + "box": {"x1": 20, "y1": 80, "x2": 380, "y2": 140}, + }, + { + "layer": "L3", + "id": "l3-m1", + "label": "m1", + "kind": "measure", + "box": {"x1": 40, "y1": 80, "x2": 140, "y2": 140}, + }, + { + "layer": "L3", + "id": "l3-m2", + "label": "m2", + "kind": "measure", + "box": {"x1": 140, "y1": 80, "x2": 240, "y2": 140}, + }, + { + "layer": "L3", + "id": "l3-m3", + "label": "m3", + "kind": "measure", + "box": {"x1": 240, "y1": 80, "x2": 360, "y2": 140}, + }, + # L4 ignored + { + "layer": "L4", + "id": "l4-m1-pitch0", + "kind": "note_roi", + "box": {"x1": 50, "y1": 90, "x2": 70, "y2": 120}, + }, + ], + # #66-style edges: 4 xs for 3 measures + "barlines": [ + {"system": 0, "x": 40, "y1": 80, "y2": 140}, + {"system": 0, "x": 140, "y1": 80, "y2": 140}, + {"system": 0, "x": 240, "y1": 80, "y2": 140}, + {"system": 0, "x": 360, "y1": 80, "y2": 140}, + ], + } + + +def test_interior_from_edges_chain() -> None: + xs = _interior_splits_from_edges( + [40, 140, 240, 360], n_measures=3, x_left=20, x_right=380 + ) + assert xs == pytest.approx([140, 240]) + + +def test_structure_to_sample_strips_l4_and_edge_barlines() -> None: + sample = layout_sample_from_structure( + _minimal_structure(), + image={"path": "image.png", "width": 400, "height": 300}, + sample_id="toy", + ) + assert sample["layout_schema_version"] == "0.1" + assert sample["l1"]["score_region"]["x2"] == 400 + assert len(sample["l2"]["systems"]) == 1 + row = sample["l3"]["rows"][0] + assert len(row["splits"]) == 2 + assert [s["x"] for s in row["splits"]] == pytest.approx([140.0, 240.0]) + assert len(row["measures"]) == 3 + r = validate_layout_sample(sample) + assert r.ok, r.errors + + +def test_project_wrapper() -> None: + project = { + "project_version": "0.2", + "kind": "enpu-project", + "title": "toy", + "score": { + "schema_version": "0.1", + "title": "toy", + "key": "A", + "time_signature": "4/4", + "parts": [], + }, + "source_image": "toy.png", + "structure": _minimal_structure(), + "meta": {"engine": "structure+mock", "pipeline_mode": "structure"}, + } + sample = layout_sample_from_project(project, sample_id="toy") + assert sample["meta"]["key"] == "A" + assert sample["source"]["type"] == "enpu_project" + assert validate_layout_sample(sample).ok + + +def test_validate_rejects_split_outside_l2() -> None: + sample = layout_sample_from_structure( + _minimal_structure(), + image={"width": 400, "height": 300}, + ) + sample["l3"]["rows"][0]["splits"].append({"id": "bad", "x": 10}) # left of L2 + r = validate_layout_sample(sample) + assert not r.ok + assert any("interior" in e for e in r.errors) + + +def test_validate_rejects_measure_split_count_mismatch() -> None: + sample = layout_sample_from_structure( + _minimal_structure(), + image={"width": 400, "height": 300}, + ) + # drop one split but keep 3 measures + sample["l3"]["rows"][0]["splits"] = sample["l3"]["rows"][0]["splits"][:1] + r = validate_layout_sample(sample) + assert not r.ok + assert any("n_measures" in e for e in r.errors) + + +def test_repo_sample_if_present() -> None: + """Optional: samples/layout/*/layout.json committed for #93.""" + root = Path(__file__).resolve().parents[2] / "samples" / "layout" + if not root.is_dir(): + pytest.skip("no samples/layout") + layouts = list(root.glob("*/layout.json")) + if not layouts: + pytest.skip("no layout.json under samples/layout") + for p in layouts: + data = json.loads(p.read_text(encoding="utf-8")) + r = validate_layout_sample(data) + assert r.ok, f"{p}: {r.errors}" diff --git a/docs/train/l1-l3-data-spec.md b/docs/train/l1-l3-data-spec.md new file mode 100644 index 0000000..fc4620e --- /dev/null +++ b/docs/train/l1-l3-data-spec.md @@ -0,0 +1,256 @@ +# L1–L3 布局训练数据规范(layout data-spec) + +> 状态:v0.1(#93 / 父任务 #92) +> 坐标:**全图像素**,与桌面叠图 / `structure` / IR 一致(原点左上,x 向右,y 向下)。 +> 与 **Score v0.1**(`docs/jianpu-schema.md`)**分离**:本规范只描述页面几何布局,不描述音高/时值语义。 + +--- + +## 1. 目标与边界 + +| 是 | 否 | +|----|----| +| L1 页面区(title / key_time / score_region) | Score 音符序列、歌词语义 | +| L2 谱行框 systems | L4 音符 ROI、L5 音高 OCR | +| L3 行内纵向 **splits**(主存)与可选派生 measures | 端到端音高模型标签 | + +训练样本 = **图像 + layout JSON**。Score 仅可作可选页级 meta(title/key/time)。 + +--- + +## 2. 现有恩谱工程格式(输入真源) + +桌面保存的 **`.enpu.json`**(`project_version: "0.2"`,`kind: "enpu-project"`)是人工校正后的可版本化样本。真实示例字段: + +```text +{ + project_version: "0.2", + kind: "enpu-project", + title, score, # Score v0.1(语义,非 layout GT) + source_image, # 原图文件名 + source_image_data_url, # 可选 data:image/png;base64,... + structure: { # 布局真源(导出用) + pipeline: "structure", + summary: { width, height, n_systems, n_measures, ... }, + items: [ { layer, id, label, kind, box, confidence? } ], # L1–L5 + barlines: [ { system, x, y1, y2, id?, source? } ] # L3 竖线 + }, + boxes?, regions?, # OCR 遗留,layout 导出忽略 + meta: { engine, pipeline_mode, enpu_desktop }, + created_at, updated_at +} +``` + +### 2.1 `structure.items` 分层 + +| layer | kind(常见) | 含义 | +|-------|--------------|------| +| L1 | `title` / `key_time` / `score` | 页面区域 | +| L2 | `system` | 一条逻辑谱行(pitch+和弦+歌词绑定后的行框) | +| L3 | `measure` 或 `measure_derived` | 小节矩形(**派生**;旧工程多为 `measure`) | +| L4 / L5 | `note_roi` / `glyph` 等 | **本 data-spec 不导出** | + +`box`:`{ x1, y1, x2, y2 }`,全图像素。部分工程在 box 上带多余 `score: null`,导出时忽略。 + +### 2.2 `structure.barlines` + +| 字段 | 必选 | 说明 | +|------|------|------| +| `system` | 是 | 谱行索引(与 L2 `l2-sys{N}` 一致) | +| `x` | 是 | 竖线 x(全图) | +| `y1`, `y2` | 建议 | 叠图用;缺省取 L2 y | +| `id`, `source`, `editable`, `confidence` | 否 | #85 新字段;旧工程可无 | + +**重要(旧工程兼容):** + +- #66 时期常见:`n_barlines ≈ n_measures + 1`(**含小节外沿** 的 edge 链)。 +- #85 规范:主存仅为 **interior splits**,`n_splits = n_measures - 1`,端点取 L2 `x1/x2`。 +- 导出器会将 edge 链 **去掉首尾** 转为 interior splits(见 `app.layout_gt.export`)。 + +--- + +## 3. 训练样本目录约定 + +```text +samples/layout// + layout.json # 本规范 JSON + image.png # 或 .jpg;与 layout.image.path 相对本目录 +``` + +也可用清单文件聚合多个样本(训练 Framework #95 再定);单样本最低要求是 **一对** `layout.json` + 图像。 + +私有/商业谱建议放在 **不入库** 目录,例如 `samples/private/layout/`(见 `.gitignore` 约定)。 + +--- + +## 4. `layout.json` 字段表 + +### 4.1 根对象 + +| 字段 | 类型 | 必选 | 说明 | +|------|------|------|------| +| `layout_schema_version` | string | 是 | 当前 `"0.1"`(**不是** `score.schema_version`) | +| `kind` | string | 建议 | `"enpu-layout-gt"` | +| `id` | string | 建议 | 样本 ID | +| `image` | object | 是 | 见下 | +| `meta` | object | 否 | 页级元数据(非几何) | +| `l1` | object | 是 | L1 | +| `l2` | object | 是 | L2 | +| `l3` | object | 是 | L3 | +| `source` | object | 否 | 导出来源(工程路径等) | + +### 4.2 `image` + +| 字段 | 类型 | 必选 | 说明 | +|------|------|------|------| +| `path` | string | 是* | 相对样本目录的图像路径 | +| `width` | int | 强烈建议 | 像素宽 | +| `height` | int | 强烈建议 | 像素高 | +| `sha256` | string | 建议 | 图像内容哈希 | + +\*若仅用 hash 存储图库可另议;MVP 用 path。 + +### 4.3 `meta`(可选) + +| 字段 | 说明 | +|------|------| +| `title` / `key` / `time_signature` | 来自 Score 或工程标题 | +| `source_image_name` | 工程内原文件名 | +| `engine` | 识别引擎标记 | + +### 4.4 L1 + +| 字段 | 类型 | 必选 | 说明 | +|------|------|------|------| +| `score_region` | BBox | **是** | 主谱面 ROI | +| `title` | BBox | 否 | 标题区 | +| `key_time` | BBox | 否 | 调号/拍号区 | +| `regions` | list | 否 | 全量 L1(含 role) | + +`BBox = { x1, y1, x2, y2 }`,要求 `x2≥x1`, `y2≥y1`。 + +### 4.5 L2 + +```text +l2.systems[]: { + id: string, # 如 l2-sys0 + index: int, # 阅读序 0..n-1 + bbox: BBox, # 谱行框 + kind?: "system", + label?: string, + confidence?: number +} +``` + +### 4.6 L3(主存 splits) + +```text +l3.rows[]: { + system_id: string, # 对应 l2.systems[].id + system_index: int, + splits: [{ # 有序 interior 分割线 + id: string, + x: number, # 全图像素;严格在 L2.x1 < x < L2.x2 + y1?: number, + y2?: number, + source?: "user"|"detect"|"migrate"|"soft_gap", + confidence?: number + }], + measures?: [{ # 可选派生,存盘则必须 n = n_splits+1 + id?, label?, + box: BBox + }] +} +``` + +**派生规则(与 #85 一致):** + +```text +edges = [L2.x1, sorted(splits.x), L2.x2] +measure_i = [edges[i], edges[i+1]] × [L2.y1, L2.y2] +n_measures = n_splits + 1 # 无内线 → 整行 1 节 +``` + +校验器:若写出 `measures`,则强制 `len(measures) == len(splits) + 1`。 + +--- + +## 5. 工程 / structure → 训练样本映射 + +| 工程 / structure | layout 样本 | +|------------------|-------------| +| `structure.summary.width/height` | `image.width/height` | +| `source_image_data_url` / 旁路图 | `image.png` + `image.path` | +| L1 items (`kind` title/key_time/score) | `l1.title` / `key_time` / `score_region` + `regions` | +| L2 items `kind=system` | `l2.systems[]` | +| `structure.barlines[]` | → 按 system 分组 → **interior** `l3.rows[].splits` | +| L3 items measure 框 | 可选 `l3.rows[].measures`;若 barlines 缺失可由邻接框推 splits | +| `score.title/key/time_signature` | `meta.*`(非几何) | +| L4 / L5 / `boxes` / `regions` OCR | **不映射** | + +实现:`core/app/layout_gt/` · CLI:`scripts/export_layout_gt.py`。 + +--- + +## 6. 负例 / 忽略约定 + +| 区域 | 是否标注 | +|------|----------| +| 页眉装饰、页码、与谱面无关的文字 | 不进入 L2 systems;可落在 score_region 外 | +| 纯歌词行(未绑入 melody system) | 默认 **不** 作为 L2;若 UI 已绑入 system 框则随 L2 保留 | +| 和弦带 / 歌词带 | 已包含在 L2 行框内(#61 绑定),不单独 L2 | +| 空白页边 | 不标 L3 splits | + +--- + +## 7. 与 Score v0.1 的边界 + +| | Score v0.1 | layout GT 0.1 | +|--|------------|---------------| +| 版本字段 | `schema_version` | `layout_schema_version` | +| 内容 | 调号、拍号、小节音符 | 框与分割线几何 | +| 小节 | `parts[].measures[]` 语义列表 | 由 L2+splits **派生** 的几何框 | +| 用途 | 播放/导出/编辑 | 监督 L1–L3 检测模型 | + +禁止把 Score 的 measure 序号当作唯一 L3 几何 GT(应用 splits / 框)。 + +--- + +## 8. 导出与校验 + +```powershell +# 从桌面工程导出样本目录 +python scripts/export_layout_gt.py ` + --project "C:\Users\...\坐在宝座上圣洁羔羊A调.enpu.json" ` + --out samples/layout/L001_zuozai_baozuo + +# 仅校验 +python scripts/export_layout_gt.py --validate-only samples/layout/L001_zuozai_baozuo/layout.json +``` + +校验硬规则摘要: + +1. `layout_schema_version` 存在 +2. L1 `score_region` 合法 BBox +3. L2 systems 合法 BBox +4. 每个 split.x 严格落在对应 L2 `(x1, x2)` 内且严格递增 +5. 若有 measures:`n_measures == n_splits + 1` + +--- + +## 9. 版本 + +| 版本 | 说明 | +|------|------| +| `0.1` | 初版:L1 regions + L2 systems + L3 interior splits;可选 derived measures | + +破坏性变更必须 bump `layout_schema_version` 并更新本文件与 `core/app/layout_gt`。 + +--- + +## 10. 相关 + +- 父任务 [#92](https://github.com/loootte/EnPu/issues/92) · 本任务 [#93](https://github.com/loootte/EnPu/issues/93) +- [#85](https://github.com/loootte/EnPu/issues/85) L3 分割线模型 · [l3-split-model.md](../l3-split-model.md) +- [architecture-structure-first.md](../architecture-structure-first.md) +- 桌面工程 I/O:`desktop/src/lib/projectIo.ts`(`project_version` 0.2) diff --git a/samples/layout/L001_zuozai_baozuo/image.png b/samples/layout/L001_zuozai_baozuo/image.png new file mode 100644 index 0000000..b7dc536 Binary files /dev/null and b/samples/layout/L001_zuozai_baozuo/image.png differ diff --git a/samples/layout/L001_zuozai_baozuo/layout.json b/samples/layout/L001_zuozai_baozuo/layout.json new file mode 100644 index 0000000..5e40eca --- /dev/null +++ b/samples/layout/L001_zuozai_baozuo/layout.json @@ -0,0 +1,583 @@ +{ + "layout_schema_version": "0.1", + "kind": "enpu-layout-gt", + "image": { + "path": "image.png", + "width": 1654, + "height": 2339, + "sha256": "d522708de9af7a95e1c8d57a22a9d7b79666e29f244e254289e28eccdc108a14" + }, + "l1": { + "regions": [ + { + "role": "title", + "box": { + "x1": 412.16011552550015, + "y1": 148.8226515808753, + "x2": 1136.493894993895, + "y2": 244.865493059572 + }, + "id": "l1-title", + "confidence": 0.7 + }, + { + "role": "key_time", + "box": { + "x1": 0.0, + "y1": 222.93955441488336, + "x2": 646.7564102564102, + "y2": 260.0 + }, + "id": "l1-key_time", + "confidence": 0.55 + }, + { + "role": "score", + "box": { + "x1": 0.0, + "y1": 286.0, + "x2": 1654.0, + "y2": 2198.0 + }, + "id": "l1-score", + "confidence": 0.8 + } + ], + "score_region": { + "x1": 0.0, + "y1": 286.0, + "x2": 1654.0, + "y2": 2198.0 + }, + "title": { + "x1": 412.16011552550015, + "y1": 148.8226515808753, + "x2": 1136.493894993895, + "y2": 244.865493059572 + }, + "key_time": { + "x1": 0.0, + "y1": 222.93955441488336, + "x2": 646.7564102564102, + "y2": 260.0 + } + }, + "l2": { + "systems": [ + { + "id": "l2-sys0", + "index": 0, + "bbox": { + "x1": 0.0, + "y1": 265.0, + "x2": 1654.0, + "y2": 492.0 + }, + "kind": "system", + "label": "谱行 1", + "confidence": 0.86 + }, + { + "id": "l2-sys1", + "index": 1, + "bbox": { + "x1": 0.0, + "y1": 542.0, + "x2": 1654.0, + "y2": 769.0 + }, + "kind": "system", + "label": "谱行 2", + "confidence": 0.86 + }, + { + "id": "l2-sys2", + "index": 2, + "bbox": { + "x1": 0.0, + "y1": 819.0, + "x2": 1654.0, + "y2": 1046.0 + }, + "kind": "system", + "label": "谱行 3", + "confidence": 0.86 + }, + { + "id": "l2-sys3", + "index": 3, + "bbox": { + "x1": 0.0, + "y1": 1087.0, + "x2": 1654.0, + "y2": 1393.5 + }, + "kind": "system", + "label": "谱行 4", + "confidence": 0.86 + }, + { + "id": "l2-sys4", + "index": 4, + "bbox": { + "x1": 0.0, + "y1": 1393.5, + "x2": 1654.0, + "y2": 1672.0 + }, + "kind": "system", + "label": "谱行 5", + "confidence": 0.86 + }, + { + "id": "l2-sys5", + "index": 5, + "bbox": { + "x1": 0.0, + "y1": 1672.0, + "x2": 1654.0, + "y2": 1878.0 + }, + "kind": "system", + "label": "谱行 6", + "confidence": 0.86 + } + ] + }, + "l3": { + "rows": [ + { + "system_id": "l2-sys0", + "system_index": 0, + "splits": [ + { + "id": "s0-0", + "x": 450.5, + "y1": 265.0, + "y2": 492.0, + "source": "detect" + }, + { + "id": "s0-1", + "x": 813.3125, + "y1": 265.0, + "y2": 492.0, + "source": "detect" + }, + { + "id": "s0-2", + "x": 1175.5, + "y1": 265.0, + "y2": 492.0, + "source": "detect" + } + ], + "measures": [ + { + "id": "l3-m1", + "label": "m1", + "box": { + "x1": 88.5, + "y1": 265.0, + "x2": 450.5, + "y2": 492.0 + } + }, + { + "id": "l3-m2", + "label": "m2", + "box": { + "x1": 450.5, + "y1": 265.0, + "x2": 813.3125, + "y2": 492.0 + } + }, + { + "id": "l3-m3", + "label": "m3", + "box": { + "x1": 813.3125, + "y1": 265.0, + "x2": 1175.5, + "y2": 492.0 + } + }, + { + "id": "l3-m4", + "label": "m4", + "box": { + "x1": 1175.5, + "y1": 265.0, + "x2": 1537.75, + "y2": 492.0 + } + } + ] + }, + { + "system_id": "l2-sys1", + "system_index": 1, + "splits": [ + { + "id": "s1-0", + "x": 450.5, + "y1": 542.0, + "y2": 769.0, + "source": "detect" + }, + { + "id": "s1-1", + "x": 813.3125, + "y1": 542.0, + "y2": 769.0, + "source": "detect" + }, + { + "id": "s1-2", + "x": 1175.5, + "y1": 542.0, + "y2": 769.0, + "source": "detect" + } + ], + "measures": [ + { + "id": "l3-m5", + "label": "m5", + "box": { + "x1": 88.5, + "y1": 542.0, + "x2": 450.5, + "y2": 769.0 + } + }, + { + "id": "l3-m6", + "label": "m6", + "box": { + "x1": 450.5, + "y1": 542.0, + "x2": 813.3125, + "y2": 769.0 + } + }, + { + "id": "l3-m7", + "label": "m7", + "box": { + "x1": 813.3125, + "y1": 542.0, + "x2": 1175.5, + "y2": 769.0 + } + }, + { + "id": "l3-m8", + "label": "m8", + "box": { + "x1": 1175.5, + "y1": 542.0, + "x2": 1537.75, + "y2": 769.0 + } + } + ] + }, + { + "system_id": "l2-sys2", + "system_index": 2, + "splits": [ + { + "id": "s2-0", + "x": 450.5, + "y1": 819.0, + "y2": 1046.0, + "source": "detect" + }, + { + "id": "s2-1", + "x": 813.375, + "y1": 819.0, + "y2": 1046.0, + "source": "detect" + }, + { + "id": "s2-2", + "x": 1175.5, + "y1": 819.0, + "y2": 1046.0, + "source": "detect" + } + ], + "measures": [ + { + "id": "l3-m9", + "label": "m9", + "box": { + "x1": 88.5, + "y1": 819.0, + "x2": 450.5, + "y2": 1046.0 + } + }, + { + "id": "l3-m10", + "label": "m10", + "box": { + "x1": 450.5, + "y1": 819.0, + "x2": 813.375, + "y2": 1046.0 + } + }, + { + "id": "l3-m11", + "label": "m11", + "box": { + "x1": 813.375, + "y1": 819.0, + "x2": 1175.5, + "y2": 1046.0 + } + }, + { + "id": "l3-m12", + "label": "m12", + "box": { + "x1": 1175.5, + "y1": 819.0, + "x2": 1537.75, + "y2": 1046.0 + } + } + ] + }, + { + "system_id": "l2-sys3", + "system_index": 3, + "splits": [ + { + "id": "s3-0", + "x": 450.5, + "y1": 1087.0, + "y2": 1393.5, + "source": "detect" + }, + { + "id": "s3-1", + "x": 813.3125, + "y1": 1087.0, + "y2": 1393.5, + "source": "detect" + }, + { + "id": "s3-2", + "x": 1175.5, + "y1": 1087.0, + "y2": 1393.5, + "source": "detect" + } + ], + "measures": [ + { + "id": "l3-m13", + "label": "m13", + "box": { + "x1": 88.5, + "y1": 1087.0, + "x2": 450.5, + "y2": 1393.5 + } + }, + { + "id": "l3-m14", + "label": "m14", + "box": { + "x1": 450.5, + "y1": 1087.0, + "x2": 813.3125, + "y2": 1393.5 + } + }, + { + "id": "l3-m15", + "label": "m15", + "box": { + "x1": 813.3125, + "y1": 1087.0, + "x2": 1175.5, + "y2": 1393.5 + } + }, + { + "id": "l3-m16", + "label": "m16", + "box": { + "x1": 1175.5, + "y1": 1087.0, + "x2": 1537.75, + "y2": 1393.5 + } + } + ] + }, + { + "system_id": "l2-sys4", + "system_index": 4, + "splits": [ + { + "id": "s4-0", + "x": 450.5, + "y1": 1393.5, + "y2": 1672.0, + "source": "detect" + }, + { + "id": "s4-1", + "x": 813.3125, + "y1": 1393.5, + "y2": 1672.0, + "source": "detect" + }, + { + "id": "s4-2", + "x": 1175.5, + "y1": 1393.5, + "y2": 1672.0, + "source": "detect" + } + ], + "measures": [ + { + "id": "l3-m17", + "label": "m17", + "box": { + "x1": 88.5, + "y1": 1393.5, + "x2": 450.5, + "y2": 1672.0 + } + }, + { + "id": "l3-m18", + "label": "m18", + "box": { + "x1": 450.5, + "y1": 1393.5, + "x2": 813.3125, + "y2": 1672.0 + } + }, + { + "id": "l3-m19", + "label": "m19", + "box": { + "x1": 813.3125, + "y1": 1393.5, + "x2": 1175.5, + "y2": 1672.0 + } + }, + { + "id": "l3-m20", + "label": "m20", + "box": { + "x1": 1175.5, + "y1": 1393.5, + "x2": 1537.75, + "y2": 1672.0 + } + } + ] + }, + { + "system_id": "l2-sys5", + "system_index": 5, + "splits": [ + { + "id": "s5-0", + "x": 450.5, + "y1": 1672.0, + "y2": 1878.0, + "source": "detect" + }, + { + "id": "s5-1", + "x": 813.375, + "y1": 1672.0, + "y2": 1878.0, + "source": "detect" + }, + { + "id": "s5-2", + "x": 1175.5, + "y1": 1672.0, + "y2": 1878.0, + "source": "detect" + } + ], + "measures": [ + { + "id": "l3-m21", + "label": "m21", + "box": { + "x1": 88.5, + "y1": 1672.0, + "x2": 450.5, + "y2": 1878.0 + } + }, + { + "id": "l3-m22", + "label": "m22", + "box": { + "x1": 450.5, + "y1": 1672.0, + "x2": 813.375, + "y2": 1878.0 + } + }, + { + "id": "l3-m23", + "label": "m23", + "box": { + "x1": 813.375, + "y1": 1672.0, + "x2": 1175.5, + "y2": 1878.0 + } + }, + { + "id": "l3-m24", + "label": "m24", + "box": { + "x1": 1175.5, + "y1": 1672.0, + "x2": 1537.75, + "y2": 1878.0 + } + } + ] + } + ] + }, + "id": "L001_zuozai_baozuo", + "meta": { + "title": "坐在宝座上圣洁羔羊A调", + "key": "C", + "time_signature": "4/4", + "source_image_name": "M04_manual.png", + "project_version": "0.2", + "engine": "structure+mock" + }, + "source": { + "type": "enpu_project", + "kind": "enpu-project", + "project_version": "0.2", + "note": "Exported from desktop .enpu.json (title=坐在宝座上圣洁羔羊A调, image=M04_manual.png)" + }, + "export": { + "validation_warnings": [] + } +} diff --git a/samples/layout/README.md b/samples/layout/README.md new file mode 100644 index 0000000..ba84a48 --- /dev/null +++ b/samples/layout/README.md @@ -0,0 +1,33 @@ +# Layout training samples(L1–L3) + +标准见 [docs/train/l1-l3-data-spec.md](../../docs/train/l1-l3-data-spec.md)。 + +## 目录 + +每个样本一个子目录: + +```text +samples/layout// + layout.json + image.png +``` + +## 从恩谱工程导出 + +```powershell +# 仓库根目录 +$env:PYTHONPATH = ".\core" +python scripts/export_layout_gt.py ` + --project "C:\path\to\song.enpu.json" ` + --out samples/layout/L00x_name +``` + +工程格式为桌面 **`.enpu.json`**(`project_version` 0.2,含 `structure` + 可选嵌入图)。 + +## 本仓库样例 + +| ID | 来源 | 说明 | +|----|------|------| +| `L001_zuozai_baozuo` | 真实工程《坐在宝座上圣洁羔羊A调》 | M04 页;L2=6 行,每行 4 节 → 3 interior splits | + +私有/未授权谱请放 `samples/private/layout/`(勿提交)。 diff --git a/scripts/export_layout_gt.py b/scripts/export_layout_gt.py new file mode 100644 index 0000000..8b9047b --- /dev/null +++ b/scripts/export_layout_gt.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Export EnPu .enpu.json project → L1–L3 layout training sample (#93). + +Examples:: + + # From a real desktop project (embeds image from source_image_data_url) + python scripts/export_layout_gt.py ^ + --project "C:\\Users\\...\\song.enpu.json" ^ + --out samples/layout/L001_zuozai + + # Validate an existing layout.json only + python scripts/export_layout_gt.py --validate-only samples/layout/L001/layout.json +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CORE = ROOT / "core" +if str(CORE) not in sys.path: + sys.path.insert(0, str(CORE)) + +from app.layout_gt.export import export_project_to_sample_dir # noqa: E402 +from app.layout_gt.validate import validate_layout_sample # noqa: E402 + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser( + description="Export / validate L1–L3 layout GT from EnPu projects (#93)" + ) + ap.add_argument( + "--project", + "-p", + type=Path, + help="Path to .enpu.json project", + ) + ap.add_argument( + "--out", + "-o", + type=Path, + help="Output sample directory (writes layout.json + image.*)", + ) + ap.add_argument( + "--sample-id", + type=str, + default=None, + help="Optional sample id field", + ) + ap.add_argument( + "--no-image", + action="store_true", + help="Do not extract embedded image", + ) + ap.add_argument( + "--no-measures", + action="store_true", + help="Omit derived measures from layout.json (splits only)", + ) + ap.add_argument( + "--validate-only", + type=Path, + default=None, + help="Only validate an existing layout.json", + ) + ap.add_argument( + "--skip-validate", + action="store_true", + help="Write even if validation fails (not recommended)", + ) + args = ap.parse_args(argv) + + if args.validate_only: + data = json.loads(args.validate_only.read_text(encoding="utf-8")) + r = validate_layout_sample(data) + print(f"ok={r.ok}") + for e in r.errors: + print(f"ERROR: {e}") + for w in r.warnings: + print(f"WARN: {w}") + return 0 if r.ok else 2 + + if not args.project or not args.out: + ap.error("--project and --out are required (or use --validate-only)") + + if not args.project.is_file(): + print(f"project not found: {args.project}", file=sys.stderr) + return 1 + + try: + sample = export_project_to_sample_dir( + args.project, + args.out, + sample_id=args.sample_id, + copy_image=not args.no_image, + validate=not args.skip_validate, + include_derived_measures=not args.no_measures, + ) + except ValueError as e: + print(str(e), file=sys.stderr) + return 2 + + n_sys = len((sample.get("l2") or {}).get("systems") or []) + rows = (sample.get("l3") or {}).get("rows") or [] + n_splits = sum(len(r.get("splits") or []) for r in rows) + print(f"wrote {args.out / 'layout.json'}") + print( + f" id={sample.get('id')!r} systems={n_sys} " + f"split_lines={n_splits} image={sample.get('image')}" + ) + warns = (sample.get("export") or {}).get("validation_warnings") or [] + for w in warns: + print(f" WARN: {w}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())