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
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.