Give each ink run a single owner, by precedence not proximity

Feliz Navidad exposed two assignment faults. Each bracket was expanding
independently, so a lyric line between two systems could be claimed by
both, producing overlapping extents. And nearest-bracket is the wrong
rule: engravers space lyrics generously, so a line sits 43px under its
own system's bracket but only 10px above the next one's.

Now one pass assigns every run exactly once: ink overlapping a bracket
belongs to it, and otherwise the system above wins over the system
below. Text under a staff belongs to that staff.

Known limit, documented in _assign: where a lyric is printed tight
enough that no blank row separates it from the next system's staves,
the two fuse into one ink run and no row profile can split them. The
lyric goes to the system below and the cut lands ~90px high. Dragging
it is the fix.

Feliz Navidad now reads 5 systems on every page, staff 70px, with no
overlapping extents; Ketun joululaulu, Engel and Elaman nalka are
unchanged.
This commit is contained in:
Esa Kataja
2026-07-28 22:46:27 +03:00
parent 6ff956a622
commit 61dac44037
+66 -32
View File
@@ -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 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]], runs: list[tuple[int, int]],
top: int, anchors: list[tuple[int, int]],
bottom: int, reaches: list[float],
max_gap: float, ) -> list[tuple[int, int]]:
others: list[tuple[int, int]], """Give every ink run to one system, and return each system's extent.
) -> tuple[int, int]:
"""Grow a bracket span over the ink close to it.
Lyrics printed below the last staff, and the tempo mark or section label A run between two systems is resolved by **precedence, not proximity**: the
printed above the first, sit within about a staff height of the bracket and system above wins if the run is within its reach. Text printed under a staff
get absorbed. A title block or a copyright footer is far further away and belongs to that staff, and engravers space lyrics generously — on *Feliz
does not. 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 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. 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 bounds = [list(a) for a in anchors]
for start, end in runs:
if any(end > o[0] and start < o[1] for o in others): def claim(index: int, run: tuple[int, int]) -> None:
continue # belongs to a different system bounds[index][0] = min(bounds[index][0], run[0])
if end > top and start < bottom: # overlaps the bracket itself bounds[index][1] = max(bounds[index][1], run[1])
lo, hi = min(lo, start), max(hi, end)
elif end <= top and top - end <= max_gap: for run in runs:
lo = min(lo, start) gaps = [_gap(run, a) for a in anchors]
elif start >= bottom and start - bottom <= max_gap:
hi = max(hi, end) # Ink overlapping a bracket belongs to it — to the one it overlaps most,
return lo, hi # 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: 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) anchors = system_anchors(straight)
if anchors: if anchors:
systems = [] # Staff height is measured on the bracket span, before expansion, so a
for i, (top, bottom) in enumerate(anchors): # swallowed title block can't distort it.
# Staff height is measured on the bracket span, before expansion, heights = [staff_height(straight, top, bottom) for top, bottom in anchors]
# so a swallowed title block can't distort it. reaches = [(h or gray.shape[0] * 0.02) * _EXPAND_REACH for h in heights]
height = staff_height(straight, top, bottom) systems = [
others = [a for j, a in enumerate(anchors) if j != i] System(top=lo, bottom=hi, staff_height=h)
reach = (height or gray.shape[0] * 0.02) * _EXPAND_REACH for (lo, hi), h in zip(_assign(runs, anchors, reaches), heights)
lo, hi = _expand(runs, top, bottom, reach, others) ]
systems.append(System(top=lo, bottom=hi, staff_height=height))
else: else:
# No bracket: a single-staff melody or lead sheet, where every ink run # No bracket: a single-staff melody or lead sheet, where every ink run
# genuinely is its own system. # genuinely is its own system.