diff --git a/noteman_slicer/detect.py b/noteman_slicer/detect.py index 7e389e1..01f9ccb 100644 --- a/noteman_slicer/detect.py +++ b/noteman_slicer/detect.py @@ -26,6 +26,7 @@ _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 +_ANCHOR_MAX_RATIO = 2.0 # a stroke this much taller than the typical one is an artefact _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 _STAFF_KERNEL = 0.05 # horizontal open kernel, as a fraction of page width @@ -123,6 +124,17 @@ def system_anchors(gray: np.ndarray) -> list[Anchor]: if stats[i, cv2.CC_STAT_HEIGHT] > h * _ANCHOR_MIN ] + # A scanner leaves a dark line down the sheet edge — the binder shadow, the + # glass, the page next to it — and it runs the whole height of the scan. + # Being the tallest stroke on the page it wins every overlap below and + # swallows every system into one. A page's brackets and barlines are all + # about one system tall, so anything wildly taller than the typical stroke + # is not notation. Relative, not an absolute fraction of the page: a page + # holding one big system is legitimate and must survive. + if len(tall) > 1: + limit = float(np.median([a.bottom - a.top for a in tall])) * _ANCHOR_MAX_RATIO + tall = [a for a in tall if a.bottom - a.top <= limit] or tall + # 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. # The kept stroke is the tallest, which is the bracket rather than a barline. diff --git a/noteman_slicer/render.py b/noteman_slicer/render.py index 7659194..85d75cc 100644 --- a/noteman_slicer/render.py +++ b/noteman_slicer/render.py @@ -26,7 +26,10 @@ from .project import Cut, Project MAX_WIDTH = 1920 ALPHA_LEVELS = 16 # quantising alpha costs nothing visible and ~32% of the bytes -_SPECK_AREA = 300 # ink blobs smaller than this don't anchor a trim +# A row or column carrying less ink than this is a fleck, not content: at least +# this many pixels, and at least this share of the slice's own size. +_SPECK_INK = 8 +_SPECK_SHARE = 0.005 @dataclass @@ -105,26 +108,24 @@ def _ink_bbox(gray: np.ndarray) -> tuple[int, int, int, int] | None: One scan fleck at the far left would otherwise anchor the trim and shift that slice relative to every other one. + + Measured per row and per column rather than per blob. Judging each blob on + its own area throws away a whole line of lyrics — every letter is its own + small component, and no single one is big enough to keep — which is how a + slice loses its bottom voice's words. A row carrying a line of text carries + plenty of ink *in total*, and a fleck's row carries almost none. """ - ink = (gray < 200).astype(np.uint8) - count, _, stats, _ = cv2.connectedComponentsWithStats(ink, 8) - boxes = [ - ( - stats[i, cv2.CC_STAT_LEFT], - stats[i, cv2.CC_STAT_TOP], - stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH], - stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT], - ) - for i in range(1, count) - if stats[i, cv2.CC_STAT_AREA] >= _SPECK_AREA - ] - if not boxes: + ink = gray < 200 + rows, cols = ink.sum(axis=1), ink.sum(axis=0) + kept_rows = np.where(rows >= max(_SPECK_INK, ink.shape[1] * _SPECK_SHARE))[0] + kept_cols = np.where(cols >= max(_SPECK_INK, ink.shape[0] * _SPECK_SHARE))[0] + if not kept_rows.size or not kept_cols.size: return None return ( - min(b[0] for b in boxes), - min(b[1] for b in boxes), - max(b[2] for b in boxes), - max(b[3] for b in boxes), + int(kept_cols[0]), + int(kept_rows[0]), + int(kept_cols[-1]) + 1, + int(kept_rows[-1]) + 1, ) diff --git a/tests/test_detect.py b/tests/test_detect.py index 7d13706..58f06fd 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -71,6 +71,14 @@ def main() -> int: found = deskew_angle(deskew(page, angle)) assert abs(found + angle) <= 0.15, f"skew {angle}: got {found}" + # A scanner's edge line runs the whole height of the sheet. Being taller + # than every bracket it used to win each overlap and swallow the page into + # one system — Olukainen juomukainen, where five pages of six came out as a + # single slice each. + scanned = _page() + scanned[10 : H - 10, W - 8 : W - 4] = 0 + assert len(detect_page(scanned).systems) == 2, "an edge artefact is not a bracket" + # No brackets: every ink run is its own system. bare = np.full((H, W), 255, np.uint8) for y in (200, 500, 800): diff --git a/tests/test_render.py b/tests/test_render.py index 2ba1510..55d4cbe 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -19,6 +19,7 @@ from noteman_slicer.pdf import open_source, page_raster # noqa: E402 from noteman_slicer.project import Cut, Project, default_path # noqa: E402 from noteman_slicer.render import ( # noqa: E402 ALPHA_LEVELS, + _ink_bbox, apply_levels, encode, pad_right, @@ -87,6 +88,19 @@ def main() -> int: assert rgba[:, :, 3].min() == 0, "paper must be fully transparent" assert len(np.unique(rgba[:, :, 3])) <= ALPHA_LEVELS + # Trim keeps a line of lyrics and drops a fleck. Each letter is its own + # small blob, so judging blobs by area threw the whole line away and the + # bottom voice lost its words; a fleck's row carries almost no ink at all. + art = np.full((300, 800), 255, np.uint8) + art[100:150, 50:750] = 0 # a staff + for x in range(60, 700, 30): # lyrics: many small glyphs, one row + art[200:220, x : x + 14] = 0 + art[5:9, 10:14] = 0 # a fleck in the far corner + x0, y0, x1, y1 = _ink_bbox(art) + assert (y0, y1) == (100, 220), f"lyrics kept, fleck dropped: {(y0, y1)}" + assert (x0, x1) == (50, 750), (x0, x1) + assert _ink_bbox(np.full((50, 50), 255, np.uint8)) is None, "blank slice has no box" + # Levels: a white point below the paper value wipes the paper out entirely. faint = np.full((10, 10), 200, np.uint8) assert apply_levels(faint, 0, 180).max() == 255