"""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)