diff --git a/noteman_slicer/detect.py b/noteman_slicer/detect.py index f59128b..23d1102 100644 --- a/noteman_slicer/detect.py +++ b/noteman_slicer/detect.py @@ -161,35 +161,70 @@ def staff_height(gray: np.ndarray, top: int, bottom: int) -> float | None: return float(np.median(intra) * 4) # 5 lines, 4 spaces -def _expand( +def _gap(run: tuple[int, int], span: tuple[int, int]) -> int: + """Vertical distance between an ink run and a bracket span; 0 if they overlap.""" + start, end = run + top, bottom = span + if end > top and start < bottom: + return 0 + return top - end if end <= top else start - bottom + + +def _assign( runs: list[tuple[int, int]], - top: int, - bottom: int, - max_gap: float, - others: list[tuple[int, int]], -) -> tuple[int, int]: - """Grow a bracket span over the ink close to it. + anchors: list[tuple[int, int]], + reaches: list[float], +) -> list[tuple[int, int]]: + """Give every ink run to one system, and return each system's extent. - Lyrics printed below the last staff, and the tempo mark or section label - printed above the first, sit within about a staff height of the bracket and - get absorbed. A title block or a copyright footer is far further away and - does not. + A run between two systems is resolved by **precedence, not proximity**: the + system above wins if the run is within its reach. Text printed under a staff + belongs to that staff, and engravers space lyrics generously — on *Feliz + Navidad* a lyric line sits 43px under its own system's bracket but only 10px + above the next one's, so nearest-bracket gives it to the wrong system. - Distance is measured from the *bracket*, never from the growing extent: a + Distance is measured from the *bracket*, never from a growing extent — a title block's credit lines are stacked closely enough that a chaining expansion hops from one to the next and walks the whole way up the page. + + One pass over all systems, rather than each bracket expanding on its own, so + that a run has exactly one owner and extents cannot overlap. + + Known limit: when a lyric line is printed tight enough under its system that + no blank row separates it from the *next* system's staves, the two fuse into + a single ink run and no row profile can split them — the lyric is then given + to the system below and the cut lands high. Dragging the cut is the fix; + separating them needs a signal this pass doesn't have. """ - lo, hi = top, bottom - for start, end in runs: - if any(end > o[0] and start < o[1] for o in others): - continue # belongs to a different system - if end > top and start < bottom: # overlaps the bracket itself - lo, hi = min(lo, start), max(hi, end) - elif end <= top and top - end <= max_gap: - lo = min(lo, start) - elif start >= bottom and start - bottom <= max_gap: - hi = max(hi, end) - return lo, hi + bounds = [list(a) for a in anchors] + + def claim(index: int, run: tuple[int, int]) -> None: + bounds[index][0] = min(bounds[index][0], run[0]) + bounds[index][1] = max(bounds[index][1], run[1]) + + for run in runs: + gaps = [_gap(run, a) for a in anchors] + + # Ink overlapping a bracket belongs to it — to the one it overlaps most, + # whatever else is in reach. + inside = [ + (min(run[1], anchors[i][1]) - max(run[0], anchors[i][0]), i) + for i, g in enumerate(gaps) + if g == 0 + ] + if inside: + claim(max(inside)[1], run) + continue + + within = [i for i, g in enumerate(gaps) if g <= reaches[i]] + if not within: + continue # a title block or a footer: too far from any system + + # Otherwise the system above wins, and only failing that the one below. + above = [i for i in within if anchors[i][1] <= run[0]] + claim(above[-1] if above else within[0], run) + + return [(lo, hi) for lo, hi in bounds] def detect_page(gray: np.ndarray, skew: float | None = None) -> PageDetection: @@ -201,15 +236,14 @@ def detect_page(gray: np.ndarray, skew: float | None = None) -> PageDetection: anchors = system_anchors(straight) if anchors: - systems = [] - for i, (top, bottom) in enumerate(anchors): - # Staff height is measured on the bracket span, before expansion, - # so a swallowed title block can't distort it. - height = staff_height(straight, top, bottom) - others = [a for j, a in enumerate(anchors) if j != i] - reach = (height or gray.shape[0] * 0.02) * _EXPAND_REACH - lo, hi = _expand(runs, top, bottom, reach, others) - systems.append(System(top=lo, bottom=hi, staff_height=height)) + # Staff height is measured on the bracket span, before expansion, so a + # swallowed title block can't distort it. + heights = [staff_height(straight, top, bottom) for top, bottom in anchors] + reaches = [(h or gray.shape[0] * 0.02) * _EXPAND_REACH for h in heights] + systems = [ + System(top=lo, bottom=hi, staff_height=h) + for (lo, hi), h in zip(_assign(runs, anchors, reaches), heights) + ] else: # No bracket: a single-staff melody or lead sheet, where every ink run # genuinely is its own system.