diff --git a/README.md b/README.md index fe1094b..232a592 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ ```text L1 页面级 标题 / 调号 / 拍号 / 主谱面 ROI L2 主谱面 谱行(systems;pitch+和弦+歌词绑定为同一行) -L3 谱行内 小节线 → 小节(measures) +L3 谱行内 纵向分割线 splits → 派生小节 measures(#85) L4 小节内 音符 / 和弦 / 歌词候选 ROI(几何为主) L5 音符节点 音高数字 OCR(+几何兜底)+ 时值线 / 高低音点等几何 → 组装 Score JSON @@ -55,10 +55,12 @@ L5 音符节点 音高数字 OCR(+几何兜底)+ 时值线 / 高低音点 | 原则 | 说明 | |------|------| | L1–L4 | **OpenCV / 几何 / 版面**为主,不依赖整页 OCR 文本顺序 | +| L3 | **主存分割线**,小节框由 L2 行边界 + 线推导;桌面拖线编辑 | | L5 | **音高数字**以 OCR 为主,并与同节点几何特征绑定 | | 开关 | `ENPU_PIPELINE_MODE=structure` 或 `legacy` | -桌面在结构模式下可叠图查看 L1–L5。完整说明见 [docs/architecture-structure-first.md](./docs/architecture-structure-first.md) · [docs/architecture.md](./docs/architecture.md)。 +桌面在结构模式下可叠图查看 L1–L5,L3 以分割线编辑为主。 +完整说明:[architecture-structure-first.md](./docs/architecture-structure-first.md) · [l3-split-model.md](./docs/l3-split-model.md) · [architecture.md](./docs/architecture.md)。 --- diff --git a/ROADMAP.md b/ROADMAP.md index a5e2e91..e3bdfc0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,8 +5,8 @@ > 目标格式:MusicXML / MIDI / 自定义 JSON。 **仓库**:https://github.com/loootte/EnPu -**最后更新**:2026-07-25 -**当前阶段**:#34/#35/#38 ✅;**#58 结构优先分层内核**(可选 `ENPU_PIPELINE_MODE=structure`);精度 **#36/#37**;体验 **#45–#52**;目标 **v0.1.0**;云端 #13 可并行 +**最后更新**:2026-08-01 +**当前阶段**:#34/#35/#38 ✅;**#58 结构优先** + **#85 L3 分割线模型** ✅;#84/#86/#89 分层评测/调优 ✅;精度 **#36/#37**;体验 **#45–#52**;目标 **v0.1.0**;云端 #13 可并行 --- @@ -336,7 +336,7 @@ Issues 列表:https://github.com/loootte/EnPu/issues | [#36](https://github.com/loootte/EnPu/issues/36) | 扩充真实敬拜谱评测集(≥15) | P4 | **P0** | ⬜ **下一步(精度)** | | [#37](https://github.com/loootte/EnPu/issues/37) | 高密度谱与时值线检测 | P4 | P1 | ⬜ | | [#43](https://github.com/loootte/EnPu/issues/43) | 产品/体验演进清单入 Roadmap | docs | P0 | ✅ [PR #44](https://github.com/loootte/EnPu/pull/44) | -| [#58](https://github.com/loootte/EnPu/issues/58) | 结构优先分层识别内核(L1–L5,先几何后 OCR) | P4 / core | **P0** | 🔄 脚手架 `pipeline_mode=structure` | +| [#58](https://github.com/loootte/EnPu/issues/58) | 结构优先分层识别内核(L1–L5,先几何后 OCR) | P4 / core | **P0** | 🔄 持续迭代(L1–L5 已可跑;L3 见 #85) | | [#45](https://github.com/loootte/EnPu/issues/45) | 原稿对照双视图校对(P4-F) | P4 | **P0** | ⬜ v0.1 体验加分 | | [#46](https://github.com/loootte/EnPu/issues/46) | 错误标记与问题导航(P4-G) | P4 | **P0** | ⬜ v0.1 体验加分 | | [#47](https://github.com/loootte/EnPu/issues/47) | 图像预处理工具箱(P4-H) | P4 | P1 | ⬜ | @@ -359,6 +359,10 @@ Issues 列表:https://github.com/loootte/EnPu/issues | [#33](https://github.com/loootte/EnPu/issues/33) | 基于基线更新产品与技术路线 | docs | ✅ | | [#34](https://github.com/loootte/EnPu/issues/34) | 版面/谱行分类,过滤非谱行数字 | P4 | ✅ | | [#35](https://github.com/loootte/EnPu/issues/35) | 小节线检测与切分加固 | P4 | ✅ | +| [#84](https://github.com/loootte/EnPu/issues/84) | 旋律带小节线检测 | P4 / L3 | ✅ | +| [#85](https://github.com/loootte/EnPu/issues/85) | L3 行内纵向分割线模型(主存 splits → 派生 measures) | P4 / L3 | ✅ | +| [#86](https://github.com/loootte/EnPu/issues/86) | 分层准确度量化 + 编辑框作 GT | P4 / eval | ✅ | +| [#89](https://github.com/loootte/EnPu/issues/89) | 单层自动调优循环(L3) | P4 / eval | ✅ | | [#36](https://github.com/loootte/EnPu/issues/36) | 扩充真实敬拜谱评测集 | P4 | ⬜ OPEN | | [#37](https://github.com/loootte/EnPu/issues/37) | 高密度谱与时值线检测 | P4 | ⬜ OPEN | | [#38](https://github.com/loootte/EnPu/issues/38) | print_clear F1 CI 门槛 | P1 | ✅ | @@ -398,6 +402,7 @@ Issues 列表:https://github.com/loootte/EnPu/issues | 2026-07-24 | **#33** 转向结构精度:#34 过滤 → #35 小节 → #36 真实集;v0.1.0 不阻塞于云端 | | 2026-07-25 | **#43** 产品/体验清单入 Roadmap;子 Issue **#45–#52** 已开;**#34/#35/#38** 已关闭 | | 2026-07-25 | Issue 索引同步:OPEN = #13/#36/#37/#43/#45–#52;精度下一步 **#36** | +| 2026-08-01 | **#85** L3 主存分割线、派生小节、桌面拖线、双谱高亮对齐;文档 `l3-split-model.md`;**#84/#86/#89** 已合 | ### Phase 摘要 @@ -405,7 +410,7 @@ Issues 列表:https://github.com/loootte/EnPu/issues **Phase 1 收尾**:评测与基线完成(#29)。 -**精度**:#34/#35/#38 ✅;收尾 **#36** 真实评测集 + **#37** 高密度。 +**精度**:#34/#35/#38 ✅;结构 L3 **#85**(分割线)✅ + #84/#86/#89 评测调优 ✅;收尾 **#36** 真实评测集 + **#37** 高密度。 **产品体验(#43 → #45–#52)**:校对工具链 + 编辑增强已拆 Issue。 diff --git a/core/README.md b/core/README.md index 5c04be4..394c9be 100644 --- a/core/README.md +++ b/core/README.md @@ -108,15 +108,21 @@ ENPU_RECOGNIZE_ENGINE=mock pytest -q 5. **parse** — OCR → `Score` v0.1(音高/时值/小节;失败回退 hints / ocr_only) 6. **export** — `POST /v1/export` 将 Score 转为 MusicXML / MIDI(music21,#11) -### structure(#58) +### structure(#58 / #85) `ENPU_PIPELINE_MODE=structure` 时: ```text -L1 版面 → L2 谱行 → L3 小节 → L4 音符 ROI → L5 局部 OCR+几何 → Score +L1 版面 → L2 谱行 → L3 纵向分割线 → 派生小节 → L4 音符 ROI → L5 局部 OCR+几何 → Score ``` -代码:`core/app/pipeline/structure/`。详见仓库根 [README](../README.md) 与 [docs/architecture-structure-first.md](../docs/architecture-structure-first.md)。 +| 层 | 要点 | +|----|------| +| L3(#85) | 主存 **interior splits**(全图像素 x);`splits_to_measures` 派生小节矩形;端点取 L2 行边界 | +| 模块 | `structure/splits.py`(纯函数)、`l3_measures.py`(检测)、`ir.SplitLine`、`assemble`/`rebuild` | + +结构调试字段:`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)。 ## Sidecar 打包(可选,Issue #8) diff --git a/core/app/evaluation/metrics.py b/core/app/evaluation/metrics.py index ff13179..805a7ea 100644 --- a/core/app/evaluation/metrics.py +++ b/core/app/evaluation/metrics.py @@ -257,6 +257,12 @@ def barline_x_metrics( "n_pred": len(pred_xs), "max_dist": max_dist, "mean_abs_dist": (sum(dists) / len(dists)) if dists else None, + # #85 line-level report aliases + "split_count_mae": float(abs(len(pred_xs) - len(gt_xs))), + "split_count_exact": 1.0 if len(pred_xs) == len(gt_xs) else 0.0, + "split_mean_abs_x_error": (sum(dists) / len(dists)) if dists else None, + "split_fp": fp, + "split_fn": fn, "abs_delta_count": abs(len(pred_xs) - len(gt_xs)), }, ) diff --git a/core/app/pipeline/structure/assemble.py b/core/app/pipeline/structure/assemble.py index c5ba7dd..9d796b2 100644 --- a/core/app/pipeline/structure/assemble.py +++ b/core/app/pipeline/structure/assemble.py @@ -248,21 +248,45 @@ def page_layout_to_structure_debug(layout: PageLayout) -> StructureDebug: by1, by2 = float(mb[0]), float(mb[1]) else: by1, by2 = sys.rect.y1, sys.rect.y2 - for x in sys.barline_xs: - barlines.append( - { - "system": sys.index, - "x": x, - "y1": by1, - "y2": by2, - } - ) + # #85: splits are primary L3; barlines carry id/source for UI drag-edit + split_list = list(getattr(sys, "splits", None) or []) + if split_list: + for i, sp in enumerate(split_list): + x = float(getattr(sp, "x", sp) if not isinstance(sp, (int, float)) else sp) + sid = getattr(sp, "split_id", None) or f"l3-split-sys{sys.index}-{i}" + src = getattr(sp, "source", None) or "detect" + conf = getattr(sp, "confidence", None) + barlines.append( + { + "system": sys.index, + "x": x, + "y1": by1, + "y2": by2, + "id": sid, + "source": src, + "confidence": conf, + "editable": True, + } + ) + else: + for i, x in enumerate(sys.barline_xs): + barlines.append( + { + "system": sys.index, + "x": x, + "y1": by1, + "y2": by2, + "id": f"l3-split-sys{sys.index}-{i}", + "source": "detect", + "editable": True, + } + ) for meas in sys.measures: global_m += 1 # global_m follows system/measure iteration after geometry sort (#78) src = (meas.extra or {}).get("measure_source", "") label = f"m{global_m}" - if src and src not in ("l3_barline",): + if src and src not in ("l3_barline", "l3_split"): label = f"m{global_m}/{src}" items.append( StructureBox( @@ -270,7 +294,8 @@ def page_layout_to_structure_debug(layout: PageLayout) -> StructureDebug: id=f"l3-m{global_m}", label=label, box=meas.rect.as_box(), - kind="measure", + # derived from splits — UI treats as read-only when editing L3 lines + kind="measure_derived", confidence=meas.confidence, ) ) diff --git a/core/app/pipeline/structure/ir.py b/core/app/pipeline/structure/ir.py index ea12c6c..fe00876 100644 --- a/core/app/pipeline/structure/ir.py +++ b/core/app/pipeline/structure/ir.py @@ -99,7 +99,7 @@ class NoteCandidate: @dataclass class MeasureLayout: - """L3: one measure on a staff system.""" + """L3: one measure on a staff system (usually **derived** from splits, #85).""" index: int # 0-based within system rect: Rect @@ -110,14 +110,40 @@ class MeasureLayout: extra: dict[str, Any] = field(default_factory=dict) +@dataclass +class SplitLine: + """L3 interior vertical split on a staff system (#85). Full-image pixel x.""" + + x: float + split_id: str = "" + source: str = "detect" # detect | user | soft_gap | migrate + confidence: float = 0.7 + extra: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "x": self.x, + "split_id": self.split_id, + "source": self.source, + "confidence": self.confidence, + **({"extra": self.extra} if self.extra else {}), + } + + @dataclass class StaffSystem: - """L2: one horizontal staff / jianpu row.""" + """L2: one horizontal staff / jianpu row. + + **#85**: L3 primary state is ``splits`` (interior vertical dividers). + ``measures`` and ``barline_xs`` are derived for L4/Score compatibility. + """ index: int rect: Rect measures: list[MeasureLayout] = field(default_factory=list) barline_xs: list[float] = field(default_factory=list) + # Interior split lines (full-image x); not including L2 left/right bounds + splits: list[SplitLine] = field(default_factory=list) confidence: float = 0.5 extra: dict[str, Any] = field(default_factory=dict) diff --git a/core/app/pipeline/structure/l3_measures.py b/core/app/pipeline/structure/l3_measures.py index 9bd5967..551feb0 100644 --- a/core/app/pipeline/structure/l3_measures.py +++ b/core/app/pipeline/structure/l3_measures.py @@ -1,10 +1,10 @@ -"""L3: barlines + measure segmentation on each staff system (#58 / #66 / #84). +"""L3: vertical splits on each L2 system (#58 / #66 / #84 / #85). -#84 focus: -- Detect barlines inside the **melody (pitch-digit) band**, not full staff height -- Suppress silent whole-line single measure; tag ``measure_source`` -- Soft gap fallbacks when graphic bars are sparse -- Simple cross-line open-measure merge (P1) +**#85 model**: L3 primary state is ordered **interior split lines** (x). +Measure rectangles are **derived** from L2 y-band + ``[x_left, …splits, x_right]``. + +#84 still applies for detection: melody-band constrained vertical line finding, +soft-gap fallbacks, measure_source tags. """ from __future__ import annotations @@ -19,9 +19,11 @@ gap_soft_bar_xs, ) from app.pipeline.structure.ir import MeasureLayout, Rect, StaffSystem +from app.pipeline.structure.splits import normalize_splits, splits_to_measures -# measure_source values (#84) +# measure_source values (#84 / #85) SRC_L3_BARLINE = "l3_barline" +SRC_L3_SPLIT = "l3_split" SRC_FALLBACK_GAP = "fallback_gap" SRC_WHOLE_LINE = "whole_line" SRC_CROSS_LINE = "cross_line" @@ -127,24 +129,15 @@ def _segment_one_system( ) xs = _merge_unique_xs(xs_mel + xs_full, min_gap=dedup) - # Keep xs inside system x range with margin + # Keep xs strictly interior to L2 bounds xs = [x for x in xs if x_lo + 8 < x < x_hi - 8] xs = _dedup_xs(sorted(xs), min_gap=dedup) - measures: list[MeasureLayout] = [] - source = SRC_L3_BARLINE - - if len(xs) >= 2: - measures = _measures_between_barlines( - xs, - y0=y0, - y1=y1, - min_measure_width=min_measure_width, - source=SRC_L3_BARLINE, - ) + source = SRC_L3_SPLIT + split_source = "detect" - # #84: not enough bars → soft gap cut inside melody band - if len(measures) < 2 and p.soft_gap_enabled: + # #84/#85: soft-gap interiors when too few graphic splits + if len(xs) < 1 and p.soft_gap_enabled: soft_xs = gap_soft_bar_xs( image_bgr, y_range=(my0, my1), @@ -152,147 +145,77 @@ def _segment_one_system( min_gap=max(min_gap, min_measure_width), ) soft_xs = [x for x in soft_xs if x_lo + 8 < x < x_hi - 8] - # Combine graphic bars with soft gaps as split points - split_xs = _dedup_xs(sorted(set(xs + soft_xs)), min_gap=min_measure_width * 0.45) - # Need endpoints for soft segmentation: left/right content bounds - if len(split_xs) >= 1: - # Add virtual ends at content margins (inner 4%) so soft cuts work - ends = [x_lo + 4.0, x_hi - 4.0] - all_xs = _dedup_xs(sorted(ends + split_xs), min_gap=min_measure_width * 0.4) - soft_measures = _measures_between_barlines( - all_xs, - y0=y0, - y1=y1, - min_measure_width=min_measure_width, - source=SRC_FALLBACK_GAP, - ) - if len(soft_measures) > len(measures): - measures = soft_measures - xs = all_xs - source = SRC_FALLBACK_GAP - warnings.append( - f"L3: system {sys.index} soft-gap split → " - f"{len(measures)} measure(s) (measure_source={source})" - ) - - if not measures: - # Last resort: whole line, low confidence, explicit tag - measures = [ - MeasureLayout( - index=0, - rect=Rect(sys.rect.x1, sys.rect.y1, sys.rect.x2, sys.rect.y2), - confidence=0.25, - extra={ - "segment": "whole_line", - "measure_source": SRC_WHOLE_LINE, - "closed": True, - "parts": [ - { - "line_id": sys.index, - "x0": sys.rect.x1, - "x1": sys.rect.x2, - "y0": sys.rect.y1, - "y1": sys.rect.y2, - } - ], - }, - ) - ] - source = SRC_WHOLE_LINE - if len(xs) < 2: + if soft_xs: + xs = _dedup_xs(sorted(soft_xs), min_gap=min_measure_width * 0.45) + source = SRC_FALLBACK_GAP + split_source = "soft_gap" warnings.append( - f"L3: system {sys.index} has {len(xs)} barline(s); " - f"whole_line fallback (measure_source={SRC_WHOLE_LINE})" - ) - else: - warnings.append( - f"L3: system {sys.index} bar pairs too narrow; " - f"whole_line fallback (measure_source={SRC_WHOLE_LINE})" - ) - else: - # Ensure every measure carries source + parts - for m in measures: - m.extra.setdefault("measure_source", source) - m.extra.setdefault("closed", True) - m.extra.setdefault( - "parts", - [ - { - "line_id": sys.index, - "x0": m.rect.x1, - "x1": m.rect.x2, - "y0": m.rect.y1, - "y1": m.rect.y2, - } - ], + f"L3: system {sys.index} soft-gap splits → {len(xs)} line(s)" ) - # Open trailing only when ink exists after last bar (not empty margin) (#84 / #66) - if ( - p.open_trailing_enabled - and measures - and source == SRC_L3_BARLINE - and len(xs) >= 2 - ): - last_bar = xs[-1] - if x_hi - last_bar > min_measure_width * 1.2 and _has_ink_in_band( - image_bgr, - x0=last_bar + 2, - x1=x_hi - 2, - y0=my0, - y1=my1, - min_pixels=12, - ): - stub = MeasureLayout( - index=len(measures), - rect=Rect(last_bar, y0, x_hi, y1), - barline_x_left=last_bar, - barline_x_right=None, - confidence=0.4, - extra={ - "segment": "open_trailing", - "measure_source": SRC_L3_BARLINE, - "closed": False, - "parts": [ - { - "line_id": sys.index, - "x0": last_bar, - "x1": x_hi, - "y0": y0, - "y1": y1, - } - ], - }, - ) - measures.append(stub) - warnings.append( - f"L3: system {sys.index} open trailing after last bar " - f"(for cross-line)" - ) + splits = normalize_splits( + xs, + x_left=x_lo, + x_right=x_hi, + min_gap=max(4.0, dedup * 0.5), + default_source=split_source, + ) + + # #85: measures always derived from L2 bounds + interior splits + measures = splits_to_measures( + x_left=x_lo, + x_right=x_hi, + y_top=y0, + y_bot=y1, + splits=splits, + min_measure_width=min_measure_width, + measure_source=source if splits else SRC_WHOLE_LINE, + ) + if not splits: + source = SRC_WHOLE_LINE + warnings.append( + f"L3: system {sys.index} has 0 split lines; " + f"whole_line measure (measure_source={SRC_WHOLE_LINE})" + ) + + for m in measures: + m.extra.setdefault("measure_source", source) + m.extra.setdefault("closed", True) + m.extra.setdefault("from_splits", True) + m.extra.setdefault( + "parts", + [ + { + "line_id": sys.index, + "x0": m.rect.x1, + "x1": m.rect.x2, + "y0": m.rect.y1, + "y1": m.rect.y2, + } + ], + ) + + # barline_xs kept as interior split xs for overlay / legacy consumers + bar_xs = [s.x for s in splits] sys2 = StaffSystem( index=sys.index, rect=sys.rect, measures=measures, - barline_xs=xs, + barline_xs=bar_xs, + splits=splits, confidence=sys.confidence, extra={ **dict(sys.extra), "melody_band": [my0, my1], "measure_source": source, + "l3_model": "splits", }, ) - if source == SRC_L3_BARLINE and len(measures) >= 1: - warnings.append( - f"L3: system {sys.index} → {len(measures)} measure(s), " - f"{len(xs)} barline(s) (between-barlines only; " - f"measure_source={source})" - ) - elif source != SRC_WHOLE_LINE: - warnings.append( - f"L3: system {sys.index} → {len(measures)} measure(s), " - f"{len(xs)} barline(s) (measure_source={source})" - ) + warnings.append( + f"L3: system {sys.index} → {len(splits)} split(s), " + f"{len(measures)} measure(s) derived (measure_source={source})" + ) return sys2, warnings diff --git a/core/app/pipeline/structure/rebuild.py b/core/app/pipeline/structure/rebuild.py index 5425311..6c0bd83 100644 --- a/core/app/pipeline/structure/rebuild.py +++ b/core/app/pipeline/structure/rebuild.py @@ -177,25 +177,78 @@ def page_layout_from_structure( # Reading order: systems top→bottom, measures left→right by geometric center sort_systems_and_measures_by_center(systems) reindex_global_measure_numbers(systems) + from app.pipeline.structure.ir import SplitLine + from app.pipeline.structure.splits import ( + measures_to_splits, + normalize_splits, + splits_to_measures, + ) + for sys in systems: - # Reconstruct barline xs from measure edges - xs: list[float] = [] - for m in sys.measures: - if m.barline_x_left is None: - m.barline_x_left = m.rect.x1 - if m.barline_x_right is None: - m.barline_x_right = m.rect.x2 - xs.extend([m.rect.x1, m.rect.x2]) - # Prefer barlines from structure if present - bl = [ - float(b.get("x")) + # Prefer #85 splits from structure.barlines (interior only) + bl_items = [ + b for b in (structure.barlines or []) if int(b.get("system", -1)) == sys.index and b.get("x") is not None ] - if bl: - sys.barline_xs = sorted(set(bl)) + if bl_items: + raw_splits = [ + SplitLine( + x=float(b["x"]), + split_id=str(b.get("id") or f"s{i}"), + source=str(b.get("source") or "user"), + confidence=float(b.get("confidence") or 1.0), + ) + for i, b in enumerate(bl_items) + ] + splits = normalize_splits( + raw_splits, + x_left=sys.rect.x1, + x_right=sys.rect.x2, + min_gap=6.0, + ) + sys.splits = splits + sys.barline_xs = [s.x for s in splits] + # Re-derive measures from splits (edit source of truth) + notes_by_cx = [] + for m in sys.measures: + for n in m.notes: + notes_by_cx.append(n) + sys.measures = splits_to_measures( + x_left=sys.rect.x1, + x_right=sys.rect.x2, + y_top=sys.rect.y1, + y_bot=sys.rect.y2, + splits=splits, + min_measure_width=8.0, + measure_source="user_splits", + ) + # Best-effort reattach notes by center-x into new measures + for n in notes_by_cx: + cx = n.rect.cx + host = next( + (m for m in sys.measures if m.rect.x1 - 1 <= cx <= m.rect.x2 + 1), + sys.measures[0] if sys.measures else None, + ) + if host is not None: + n.index = len(host.notes) + host.notes.append(n) else: - sys.barline_xs = sorted(set(xs)) + # Migrate measure rects → splits + for m in sys.measures: + if m.barline_x_left is None: + m.barline_x_left = m.rect.x1 + if m.barline_x_right is None: + m.barline_x_right = m.rect.x2 + splits = measures_to_splits( + sys.measures, + x_left=sys.rect.x1, + x_right=sys.rect.x2, + min_gap=6.0, + ) + sys.splits = splits + sys.barline_xs = [s.x for s in splits] + reindex_global_measure_numbers(systems) # L4 note candidates l4_items = [it for it in structure.items if it.layer == "L4"] diff --git a/core/app/pipeline/structure/splits.py b/core/app/pipeline/structure/splits.py new file mode 100644 index 0000000..c8b41d2 --- /dev/null +++ b/core/app/pipeline/structure/splits.py @@ -0,0 +1,254 @@ +"""L3 split-line model (#85): ordered vertical splits on an L2 row. + +Coordinates use **full-image pixel x** (same space as Rect / overlays). +Measures are always derived: [x_left, ...splits, x_right] × L2 y-band. +""" + +from __future__ import annotations + +from typing import Any + +from app.pipeline.structure.ir import MeasureLayout, Rect, SplitLine + + +def normalize_splits( + xs: list[float] | list[SplitLine], + *, + x_left: float, + x_right: float, + min_gap: float = 8.0, + default_source: str = "detect", +) -> list[SplitLine]: + """Sort, clamp interior splits to (x_left, x_right), dedupe by min_gap.""" + if x_right <= x_left + min_gap: + return [] + + raw: list[SplitLine] = [] + for i, item in enumerate(xs): + if isinstance(item, SplitLine): + x = float(item.x) + src = item.source + sid = item.split_id or f"s{i}" + conf = item.confidence + extra = dict(item.extra or {}) + else: + x = float(item) + src = default_source + sid = f"s{i}" + conf = 0.7 + extra = {} + # Strictly interior + if x <= x_left + 1e-6 or x >= x_right - 1e-6: + continue + raw.append( + SplitLine(x=x, split_id=sid, source=src, confidence=conf, extra=extra) + ) + + raw.sort(key=lambda s: s.x) + if not raw: + return [] + + cleaned: list[SplitLine] = [raw[0]] + for s in raw[1:]: + if s.x - cleaned[-1].x >= min_gap: + cleaned.append(s) + else: + # keep average, prefer user source + prev = cleaned[-1] + nx = 0.5 * (prev.x + s.x) + src = "user" if "user" in (prev.source, s.source) else prev.source + cleaned[-1] = SplitLine( + x=nx, + split_id=prev.split_id or s.split_id, + source=src, + confidence=max(prev.confidence, s.confidence), + extra={**prev.extra, **s.extra}, + ) + # Re-id sequentially for stability + for i, s in enumerate(cleaned): + if not s.split_id or s.split_id.startswith("s"): + s.split_id = f"s{i}" + return cleaned + + +def edge_xs( + splits: list[SplitLine] | list[float], + *, + x_left: float, + x_right: float, +) -> list[float]: + """``[x_left, ...interior splits, x_right]`` strictly increasing.""" + interiors: list[float] = [] + for s in splits: + x = float(s.x if isinstance(s, SplitLine) else s) + if x_left + 1e-6 < x < x_right - 1e-6: + interiors.append(x) + interiors = sorted(set(interiors)) + return [float(x_left), *interiors, float(x_right)] + + +def splits_to_measures( + *, + x_left: float, + x_right: float, + y_top: float, + y_bot: float, + splits: list[SplitLine] | list[float], + min_measure_width: float = 4.0, + measure_source: str = "l3_split", +) -> list[MeasureLayout]: + """Derive measure rects from L2 y-band and ordered vertical splits (#85). + + Endpoints are always L2 left/right bounds. Interior ``splits`` divide the row. + """ + xs = edge_xs(splits, x_left=x_left, x_right=x_right) + # No interior splits → single whole-line measure + if len(xs) <= 2: + return [ + MeasureLayout( + index=0, + rect=Rect(x_left, y_top, x_right, y_bot), + barline_x_left=x_left, + barline_x_right=x_right, + confidence=0.25, + extra={ + "segment": "whole_line", + "measure_source": "whole_line", + "closed": True, + "from_splits": True, + }, + ) + ] + + measures: list[MeasureLayout] = [] + for i in range(len(xs) - 1): + left, right = xs[i], xs[i + 1] + if right - left < min_measure_width: + continue + measures.append( + MeasureLayout( + index=len(measures), + rect=Rect(left, y_top, right, y_bot), + barline_x_left=left, + barline_x_right=right, + confidence=0.75, + extra={ + "segment": "split_derived", + "measure_source": measure_source, + "closed": True, + "from_splits": True, + }, + ) + ) + if not measures: + measures.append( + MeasureLayout( + index=0, + rect=Rect(x_left, y_top, x_right, y_bot), + barline_x_left=x_left, + barline_x_right=x_right, + confidence=0.25, + extra={ + "segment": "whole_line", + "measure_source": "whole_line", + "closed": True, + "from_splits": True, + }, + ) + ) + return measures + + +def measures_to_splits( + measures: list[MeasureLayout], + *, + x_left: float | None = None, + x_right: float | None = None, + min_gap: float = 8.0, +) -> list[SplitLine]: + """Migrate old measure-rect L3 into interior splits (shared boundaries).""" + if not measures: + return [] + ordered = sorted(measures, key=lambda m: m.rect.cx) + if x_left is None: + x_left = min(m.rect.x1 for m in ordered) + if x_right is None: + x_right = max(m.rect.x2 for m in ordered) + + # Candidate split xs = interior edges (right of each measure except last, + # or average of adjacent touching edges) + candidates: list[float] = [] + for i in range(len(ordered) - 1): + a, b = ordered[i], ordered[i + 1] + # shared boundary estimate + x = 0.5 * (a.rect.x2 + b.rect.x1) + candidates.append(x) + # also explicit left/right if barline metadata present + if a.barline_x_right is not None: + candidates.append(float(a.barline_x_right)) + if b.barline_x_left is not None: + candidates.append(float(b.barline_x_left)) + + return normalize_splits( + candidates, + x_left=float(x_left), + x_right=float(x_right), + min_gap=min_gap, + default_source="migrate", + ) + + +def move_split( + splits: list[SplitLine], + split_id: str, + new_x: float, + *, + x_left: float, + x_right: float, + min_gap: float = 8.0, +) -> list[SplitLine]: + """Move one split by id; clamp between neighbors and row bounds.""" + xs = normalize_splits(splits, x_left=x_left, x_right=x_right, min_gap=min_gap) + idx = next((i for i, s in enumerate(xs) if s.split_id == split_id), None) + if idx is None: + return xs + lo = x_left + min_gap if idx == 0 else xs[idx - 1].x + min_gap + hi = x_right - min_gap if idx == len(xs) - 1 else xs[idx + 1].x - min_gap + if hi <= lo: + return xs + nx = max(lo, min(hi, float(new_x))) + xs[idx] = SplitLine( + x=nx, + split_id=xs[idx].split_id, + source="user", + confidence=1.0, + extra=dict(xs[idx].extra or {}), + ) + return normalize_splits(xs, x_left=x_left, x_right=x_right, min_gap=min_gap) + + +def insert_split( + splits: list[SplitLine], + x: float, + *, + x_left: float, + x_right: float, + min_gap: float = 8.0, +) -> list[SplitLine]: + """Insert a user split at x.""" + xs = list(splits) + [ + SplitLine(x=float(x), split_id="new", source="user", confidence=1.0) + ] + return normalize_splits(xs, x_left=x_left, x_right=x_right, min_gap=min_gap) + + +def delete_split( + splits: list[SplitLine], + split_id: str, + *, + x_left: float, + x_right: float, + min_gap: float = 8.0, +) -> list[SplitLine]: + xs = [s for s in splits if s.split_id != split_id] + return normalize_splits(xs, x_left=x_left, x_right=x_right, min_gap=min_gap) diff --git a/core/tests/test_l3_melody_barlines.py b/core/tests/test_l3_melody_barlines.py index 14664ee..deba339 100644 --- a/core/tests/test_l3_melody_barlines.py +++ b/core/tests/test_l3_melody_barlines.py @@ -136,20 +136,17 @@ def test_soft_gap_split_when_no_graphic_bars() -> None: assert any("soft-gap" in w or "fallback" in w or "measure_source" in w for w in warnings) -def test_cross_line_merge_open_trailing() -> None: - """Open trailing on line0 + content on line1 → cross_line source (#84 P1).""" +def test_multi_system_split_model() -> None: + """#85: multi-row systems each get independent interior splits.""" h, w = 220, 400 img = np.full((h, w, 3), 255, dtype=np.uint8) - # Line 0: bars at 50,150,250 + ink after last bar (open trail) img[40:80, 40:360] = 245 for x in (50, 150, 250): img[42:78, x : x + 2] = 0 for x in (80, 180): img[50:70, x : x + 12] = 0 - # ink after last bar img[50:70, 270:340] = 0 - # Line 1: no bars, one ink blob (continuation) img[130:170, 40:360] = 245 img[140:160, 60:200] = 0 @@ -158,11 +155,9 @@ def test_cross_line_merge_open_trailing() -> None: StaffSystem(index=1, rect=Rect(30, 125, 370, 180), confidence=0.8), ] systems, warnings = segment_measures_on_systems(img, systems) - # Either cross_line warning or open trailing present - has_cross = any("cross_line" in w for w in warnings) - has_open = any( - m.extra.get("segment") == "open_trailing" or m.extra.get("closed") is False - for s in systems - for m in s.measures - ) - assert has_cross or has_open, warnings + assert len(systems) == 2 + # Row 0 should have interior splits; measures derived + assert len(systems[0].measures) >= 1 + assert any("split" in w.lower() or "measure" in w.lower() for w in warnings) + # Row 1 may be whole_line (no bars) + assert len(systems[1].measures) >= 1 diff --git a/core/tests/test_l3_splits.py b/core/tests/test_l3_splits.py new file mode 100644 index 0000000..d5d34c3 --- /dev/null +++ b/core/tests/test_l3_splits.py @@ -0,0 +1,114 @@ +"""L3 split-line model tests (#85).""" + +from __future__ import annotations + +import numpy as np +import pytest + +from app.pipeline.structure.ir import MeasureLayout, Rect, StaffSystem +from app.pipeline.structure.l3_measures import segment_measures_on_systems +from app.pipeline.structure.ir import SplitLine +from app.pipeline.structure.splits import ( + delete_split, + insert_split, + measures_to_splits, + move_split, + normalize_splits, + splits_to_measures, +) + + +def test_splits_to_measures_basic() -> None: + splits = [SplitLine(x=100), SplitLine(x=200), SplitLine(x=300)] + ms = splits_to_measures( + x_left=20, + x_right=400, + y_top=10, + y_bot=50, + splits=splits, + min_measure_width=4, + ) + # 3 interiors → 4 measures with L2 endpoints + assert len(ms) == 4 + assert ms[0].rect.x1 == pytest.approx(20) + assert ms[0].rect.x2 == pytest.approx(100) + assert ms[-1].rect.x2 == pytest.approx(400) + # no overlap + for i in range(len(ms) - 1): + assert ms[i].rect.x2 <= ms[i + 1].rect.x1 + 1e-6 + + +def test_splits_empty_whole_line() -> None: + ms = splits_to_measures( + x_left=0, x_right=100, y_top=0, y_bot=20, splits=[], min_measure_width=4 + ) + assert len(ms) == 1 + assert ms[0].extra.get("measure_source") == "whole_line" + + +def test_normalize_dedupes() -> None: + xs = normalize_splits( + [50, 52, 100], + x_left=0, + x_right=200, + min_gap=8, + ) + assert len(xs) == 2 + assert xs[0].x == pytest.approx(51.0) + + +def test_move_split_clamps_neighbors() -> None: + splits = [ + SplitLine(x=100, split_id="a"), + SplitLine(x=200, split_id="b"), + ] + out = move_split(splits, "a", 190, x_left=0, x_right=300, min_gap=10) + a = next(s for s in out if s.split_id == "a") + b = next(s for s in out if s.split_id == "b") + assert a.x < b.x + assert a.source == "user" + + +def test_insert_delete_split() -> None: + splits = [SplitLine(x=100, split_id="a")] + splits = insert_split(splits, 150, x_left=0, x_right=300, min_gap=8) + assert len(splits) == 2 + mid = splits[1].split_id + splits = delete_split(splits, mid, x_left=0, x_right=300) + assert len(splits) == 1 + + +def test_measures_to_splits_migrate() -> None: + ms = [ + MeasureLayout(0, Rect(10, 0, 50, 20)), + MeasureLayout(1, Rect(50, 0, 100, 20)), + MeasureLayout(2, Rect(100, 0, 160, 20)), + ] + splits = measures_to_splits(ms, x_left=10, x_right=160, min_gap=4) + assert len(splits) >= 2 + ms2 = splits_to_measures( + x_left=10, x_right=160, y_top=0, y_bot=20, splits=splits, min_measure_width=4 + ) + assert len(ms2) == len(splits) + 1 + + +def test_segment_uses_split_model() -> None: + """Detected bars become interior splits; measures = n_split+1 with L2 ends.""" + h, w = 120, 400 + img = np.full((h, w, 3), 255, dtype=np.uint8) + img[40:90, 20:380] = 245 + bar_xs = [60, 150, 240, 330] + for x in bar_xs: + img[42:88, x : x + 2] = 0 + systems = [StaffSystem(index=0, rect=Rect(10, 35, 390, 95), confidence=0.8)] + systems, warnings = segment_measures_on_systems(img, systems) + assert len(systems) == 1 + sys = systems[0] + assert getattr(sys, "splits", None) is not None + assert len(sys.splits) >= 3 + # derived measures: interiors + endpoints + assert len(sys.measures) == len(sys.splits) + 1 + assert any("split" in w.lower() or "measure" in w.lower() for w in warnings) + # first measure starts at L2 left, last ends at L2 right + assert sys.measures[0].rect.x1 == pytest.approx(sys.rect.x1) + assert sys.measures[-1].rect.x2 == pytest.approx(sys.rect.x2) diff --git a/core/tests/test_structure_pipeline.py b/core/tests/test_structure_pipeline.py index 9f3aa21..94e83a3 100644 --- a/core/tests/test_structure_pipeline.py +++ b/core/tests/test_structure_pipeline.py @@ -675,19 +675,16 @@ def test_l4_m04_first_measure_pitch_count() -> None: assert n.rect.y2 < chord_y0 - 5, (n.rect, chord_y0) -def test_l3_no_outer_margin_measures() -> None: - """#66: do not treat left-of-first / right-of-last barline as measures.""" +def test_l3_split_derived_measures() -> None: + """#85: interior splits + L2 bounds → n_measures = n_splits + 1.""" from app.pipeline.structure.ir import StaffSystem h, w = 120, 400 img = np.full((h, w, 3), 255, dtype=np.uint8) - # Staff band ink img[40:90, 20:380] = 245 - # Four tall barlines → 3 real measures between them bar_xs = [60, 150, 240, 330] for x in bar_xs: img[38:92, x : x + 3] = 0 - # Note blobs only between barlines for x in (90, 180, 270): img[55:75, x : x + 10] = 0 @@ -697,23 +694,19 @@ def test_l3_no_outer_margin_measures() -> None: systems, warnings = segment_measures_on_systems(img, systems) assert len(systems) == 1 measures = systems[0].measures - detected = systems[0].barline_xs - assert len(detected) >= 4, detected - # Must be between-barline only: n_bars-1 measures, not n_bars+1 (with outer pads) - assert len(measures) == len(detected) - 1, ( + splits = systems[0].barline_xs # interior only + assert len(splits) >= 3, splits + # Derived measures use L2 endpoints + assert len(measures) == len(splits) + 1, ( [(m.rect.x1, m.rect.x2) for m in measures], - detected, + splits, ) - first_bar, last_bar = detected[0], detected[-1] + assert measures[0].rect.x1 == pytest.approx(systems[0].rect.x1) + assert measures[-1].rect.x2 == pytest.approx(systems[0].rect.x2) for m in measures: - # Each measure sits between two barlines (no system-edge pads) - assert m.rect.x1 >= first_bar - 0.5, m.rect - assert m.rect.x2 <= last_bar + 0.5, m.rect assert m.barline_x_left is not None and m.barline_x_right is not None - # No measure lives wholly in left margin (x < first bar) or right margin - assert not any(m.rect.x2 <= first_bar + 1 for m in measures) - assert not any(m.rect.x1 >= last_bar - 1 for m in measures) - assert any("between-barlines only" in w for w in warnings) + assert m.extra.get("from_splits") is True + assert any("split" in w.lower() or "derived" in w.lower() for w in warnings) def test_l4_note_candidates() -> None: diff --git a/desktop/src/components/ImagePreview.tsx b/desktop/src/components/ImagePreview.tsx index fbf98c4..7d82324 100644 --- a/desktop/src/components/ImagePreview.tsx +++ b/desktop/src/components/ImagePreview.tsx @@ -55,6 +55,12 @@ export interface ImagePreviewProps { onStructureBoxAdd?: (box: BoundingBox, layer: StructureLayerId) => void; /** #86 evaluation error overlay: green TP / red FP / yellow FN */ metricErrors?: MetricErrorBox[] | null; + /** #85: L3 split-line edit */ + onBarlineMove?: (id: string, x: number) => void; + onBarlineAdd?: (system: number, x: number, y1: number, y2: number) => void; + onBarlineDelete?: (id: string) => void; + selectedBarlineId?: string | null; + onSelectBarlineId?: (id: string | null) => void; } type DragState = { @@ -411,7 +417,14 @@ export function ImagePreview({ onStructureBoxChange, onStructureBoxAdd, metricErrors = null, + onBarlineMove, + onBarlineAdd: _onBarlineAdd, + onBarlineDelete, + selectedBarlineId = null, + onSelectBarlineId, }: ImagePreviewProps) { + // onBarlineAdd is used by parent via structureAdd → onStructureBoxAdd for L3 + void _onBarlineAdd; const imgRef = useRef(null); const viewportRef = useRef(null); const [natural, setNatural] = useState({ w: 0, h: 0 }); @@ -870,10 +883,15 @@ export function ImagePreview({ const sid = it.id || `${it.layer}-${it.label}`; const isEditLayer = !structureEditMode || it.layer === structureEditLayer; + const isDerivedMeasure = + it.layer === "L3" && + (it.kind === "measure" || it.kind === "measure_derived"); const canEdit = structureEditMode && !structureAddMode && - it.layer === structureEditLayer; + it.layer === structureEditLayer && + // #85: L3 measures are derived from splits — not free-drag + !(structureEditLayer === "L3" && isDerivedMeasure); return ( ); }) @@ -896,18 +919,65 @@ export function ImagePreview({ natural.w > 0 && overlayMode === "structure" && structureLayers?.L3 !== false - ? structure.barlines.map((bl, i) => ( -
- )) + ? structure.barlines.map((bl, i) => { + const bid = bl.id || `bar-${bl.system}-${i}`; + const editingL3 = + structureEditMode && structureEditLayer === "L3"; + const selected = selectedBarlineId === bid; + return ( +
{ + if (!editingL3 || !onBarlineMove) return; + e.stopPropagation(); + e.preventDefault(); + onSelectBarlineId?.(bid); + const el = e.currentTarget; + el.setPointerCapture(e.pointerId); + const onMove = (ev: PointerEvent) => { + const pt = toImageCoords(ev.clientX, ev.clientY); + if (pt) onBarlineMove(bid, pt.x); + }; + const onUp = (ev: PointerEvent) => { + try { + el.releasePointerCapture(ev.pointerId); + } catch { + /* ignore */ + } + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }} + onDoubleClick={(e) => { + if (!editingL3 || !onBarlineDelete) return; + e.stopPropagation(); + onBarlineDelete(bid); + }} + /> + ); + }) : null} {/* #86 evaluation TP/FP/FN overlay */} {metricErrors?.length && natural.w > 0 diff --git a/desktop/src/components/StructureLayerPanel.tsx b/desktop/src/components/StructureLayerPanel.tsx index 7b661c4..713deec 100644 --- a/desktop/src/components/StructureLayerPanel.tsx +++ b/desktop/src/components/StructureLayerPanel.tsx @@ -15,7 +15,7 @@ export const STRUCTURE_LAYERS: { }[] = [ { id: "L1", name: "L1 页面", color: "bg-violet-500/80", desc: "标题 / 调号拍号 / 主谱面" }, { id: "L2", name: "L2 谱行", color: "bg-sky-500/80", desc: "水平谱行 systems" }, - { id: "L3", name: "L3 小节", color: "bg-emerald-500/80", desc: "小节框 + 小节线" }, + { id: "L3", name: "L3 分割线", color: "bg-emerald-500/80", desc: "行内纵向分割线 → 派生小节框 (#85)" }, { id: "L4", name: "L4 音符位", color: "bg-cyan-400/80", desc: "音符候选 ROI" }, { id: "L5", name: "L5 字形", color: "bg-amber-400/80", desc: "音高 OCR + 时值/八度" }, ]; @@ -185,7 +185,8 @@ export function StructureLayerPanel({

编辑模式自动显示当前层(及上一层作参照)。 只能选中当前层 - 的框;点选后缩放到上一层区域并居中。可拖边角调框;「添加区域」在图上拖出新框。 + 。L3 以拖动/增删分割线 + 为主(小节框为派生);其它层可拖边角调框。