Add detection: skew, system anchors, ink extents, cuts, staff height

Systems anchor on the vertical bracket and expand over nearby ink, per
ADR 0006. Two corrections the corpus forced:

- Expansion measures distance from the bracket, never from the growing
  extent. A chaining expansion hops between the closely-stacked lines of
  a title block and walks the whole way up the page — on Engel p1 it
  swallowed the title into system 1 and the copyright footer into
  system 3.
- Reach is 1.5 staff heights, which takes in the lyrics below the last
  staff and the tempo mark and INTRO box above the first, while leaving
  the title block and footer out.

Verified against the corpus. Ketun joululaulu: 2 systems per page except
p7 (3) and p12 (1), staff 48px throughout, skew -2.7 to +1.2 per page,
and p2 system 1 spans 179-1071 where its bracket is 177-994 — the
difference being the bottom voice's lyric line. Engel: 2-3 systems per
page, staff 70-72px. Elaman nalka: 3 systems per page, 0 skew, staff
118px at 600 DPI.

The overlay dump is what made both bugs visible, and stays as the tool
for diagnosing a page that comes out wrong.

Closes #4, #5, #6, #7, #8, #9
This commit is contained in:
Esa Kataja
2026-07-28 22:38:50 +03:00
parent e2f3e8fbdc
commit 6ff956a622
4 changed files with 400 additions and 1 deletions
+221
View File
@@ -0,0 +1,221 @@
"""Detection: skew, systems, cuts, staff height.
Everything here is a *suggestion* the user confirms or edits (ADR 0004).
Nothing downstream may assume a result is right.
Systems are anchored on the vertical bracket that spans their staves, not on
gaps in the row-darkness profile: a row profile cannot tell an inter-staff gap
from an inter-system gap, and gets the count wrong on every page of a
multi-voice choral score (ADR 0006). The row profile is still needed, to expand
each anchor to its true ink extent — a bracket stops at the last staff line,
but the slice must include the lyrics printed below it.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import cv2
import numpy as np
SKEW_LIMIT_DEG = 5.0
SKEW_COARSE_STEP = 1.0
SKEW_FINE_STEP = 0.1
_SKEW_WORK_SCALE = 0.25
_INK = 128 # below this is ink, above is paper
_ANCHOR_KERNEL = 0.03 # vertical open kernel, as a fraction of page height
_ANCHOR_MIN = 0.04 # a bracket is at least this tall, as a fraction of page
_PROFILE_FLOOR = 0.02 # ink-run threshold, as a fraction of the profile peak
_EXPAND_REACH = 1.5 # how far past the bracket a system's ink reaches, in staff heights
@dataclass
class System:
"""One line of music: the ink extent that becomes a slice."""
top: int
bottom: int
staff_height: float | None = None
@property
def height(self) -> int:
return self.bottom - self.top
@dataclass
class PageDetection:
skew: float
systems: list[System] = field(default_factory=list)
cuts: list[int] = field(default_factory=list)
@property
def bracketless(self) -> bool:
"""True when no bracket was found and the row profile was used alone."""
return not self.systems or all(s.staff_height is None for s in self.systems)
def row_darkness(gray: np.ndarray) -> np.ndarray:
return (255 - gray.astype(np.float32)).sum(axis=1)
def deskew_angle(gray: np.ndarray) -> float:
"""Angle maximising row-darkness variance — staff lines are the signal.
Coarse then fine, on a downscaled copy: 31 warps instead of 101.
"""
work = cv2.resize(gray, None, fx=_SKEW_WORK_SCALE, fy=_SKEW_WORK_SCALE,
interpolation=cv2.INTER_AREA)
def score(angle: float) -> float:
return float(row_darkness(_rotate(work, angle, cv2.INTER_LINEAR)).var())
coarse = np.arange(-SKEW_LIMIT_DEG, SKEW_LIMIT_DEG + 1e-9, SKEW_COARSE_STEP)
best = max(coarse, key=score)
fine = np.arange(best - SKEW_COARSE_STEP, best + SKEW_COARSE_STEP + 1e-9, SKEW_FINE_STEP)
fine = fine[np.abs(fine) <= SKEW_LIMIT_DEG]
return round(float(max(fine, key=score)), 2)
def _rotate(gray: np.ndarray, angle: float, flags: int = cv2.INTER_CUBIC) -> np.ndarray:
if angle == 0.0:
return gray
h, w = gray.shape
m = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0)
return cv2.warpAffine(gray, m, (w, h), flags=flags, borderValue=255)
def deskew(gray: np.ndarray, angle: float) -> np.ndarray:
return _rotate(gray, angle)
def system_anchors(gray: np.ndarray) -> list[tuple[int, int]]:
"""y-extents of the vertical brackets, one per system."""
h = gray.shape[0]
binary = (gray < _INK).astype(np.uint8)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(3, int(h * _ANCHOR_KERNEL))))
strokes = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)
count, _, stats, _ = cv2.connectedComponentsWithStats(strokes, 8)
tall = [
(stats[i, cv2.CC_STAT_TOP], stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT])
for i in range(1, count)
if stats[i, cv2.CC_STAT_HEIGHT] > h * _ANCHOR_MIN
]
# Tallest first, keeping only strokes that don't overlap one already kept:
# a system's barlines all overlap its bracket, so each system yields one.
anchors: list[tuple[int, int]] = []
for top, bottom in sorted(tall, key=lambda s: s[1] - s[0], reverse=True):
if any(not (bottom < a[0] or top > a[1]) for a in anchors):
continue
anchors.append((top, bottom))
return sorted(anchors)
def ink_runs(gray: np.ndarray) -> list[tuple[int, int]]:
"""Rows containing ink, despeckled — specks are the known failure mode."""
profile = row_darkness(cv2.medianBlur(gray, 3))
if profile.max() <= 0:
return []
inked = profile > profile.max() * _PROFILE_FLOOR
runs: list[tuple[int, int]] = []
start: int | None = None
for i, on in enumerate(inked):
if on and start is None:
start = i
elif not on and start is not None:
runs.append((start, i))
start = None
if start is not None:
runs.append((start, len(inked)))
return runs
def staff_height(gray: np.ndarray, top: int, bottom: int) -> float | None:
"""Distance between a staff's outer lines, from staff-line spacing."""
profile = row_darkness(gray[top:bottom])
if profile.size == 0 or profile.max() <= 0:
return None
peaks = np.where(profile > profile.max() * 0.55)[0]
if peaks.size < 2:
return None
centres = []
run = [peaks[0]]
for prev, cur in zip(peaks, peaks[1:]):
if cur - prev > 3:
centres.append(float(np.mean(run)))
run = []
run.append(cur)
centres.append(float(np.mean(run)))
if len(centres) < 2:
return None
gaps = np.diff(centres)
# Keep intra-staff gaps; the big ones are the spaces between staves.
intra = gaps[gaps < np.median(gaps) * 2]
if intra.size == 0:
return None
return float(np.median(intra) * 4) # 5 lines, 4 spaces
def _expand(
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.
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.
Distance is measured from the *bracket*, never from the 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.
"""
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
def detect_page(gray: np.ndarray, skew: float | None = None) -> PageDetection:
"""Full proposal for one page raster. `gray` is the *unrotated* page."""
angle = deskew_angle(gray) if skew is None else skew
straight = deskew(gray, angle)
runs = ink_runs(straight)
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))
else:
# No bracket: a single-staff melody or lead sheet, where every ink run
# genuinely is its own system.
systems = [System(top=t, bottom=b) for t, b in runs]
cuts = [
(systems[i].bottom + systems[i + 1].top) // 2 for i in range(len(systems) - 1)
]
return PageDetection(skew=angle, systems=systems, cuts=cuts)