diff --git a/docs/guide.md b/docs/guide.md index 0a04e4c..435787c 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -44,10 +44,11 @@ off. one back. 4. **Set the content rectangle.** Drag the blue edges so they hold the music and nothing else. This is the horizontal crop for every slice on the page. -5. **Set black and white points.** Pull the *White point* down until the paper - goes pure white and its texture disappears; pull *Black point* up until the - notes are solid black rather than grey mush. Scans need this; clean digital - PDFs usually don't. +5. **Check black and white points.** These arrive proposed from the scan, like + the cuts do. If the paper still shows texture, pull *White point* down; if the + notes look grey rather than solid, pull *Black point* up. Getting this wrong + is the one mistake you cannot see until the bundle is on the tablet — grey ink + becomes half-transparent ink, and nothing downstream can rescue it. Page Up / Page Down move between pages. Levels carry over from the previous page, so a consistent scan only needs setting once. diff --git a/docs/spec.md b/docs/spec.md index 49e05a3..65b4531 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -236,6 +236,14 @@ point just under the paper's luminance and the paper vanishes completely; set th black point at the ink's darkest and notes go solid. It is also the single biggest lever on output size. +**Detection proposes both**, like it proposes cuts and skew, because the default +0–255 is the one setting whose harm is invisible until the bundle is on a tablet. +Notation is two-tone, so Otsu's split between ink and paper is the measurement; +the points sit halfway from it to each end of the range, leaving the ramp between +them as the antialiasing. A page already scanned bilevel has no interior split — +Otsu degenerates to 0 there — and is left at 0–255. The proposal is per page and +the median becomes the song's, so a near-blank page cannot set it. + Adaptive methods (CLAHE, adaptive thresholding) are the trap — tuned for text, they eat the thin stuff on notation: hairpin tips, slur ends, ledger lines, tapered beams. A global LUT whose effect you can see beats a local algorithm you diff --git a/noteman_slicer/detect.py b/noteman_slicer/detect.py index 01f9ccb..bb57b6e 100644 --- a/noteman_slicer/detect.py +++ b/noteman_slicer/detect.py @@ -65,6 +65,7 @@ class PageDetection: systems: list[System] = field(default_factory=list) cuts: list[int] = field(default_factory=list) content: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0) + levels: tuple[int, int] = (0, 255) @property def bracketless(self) -> bool: @@ -292,6 +293,26 @@ def staff_height(gray: np.ndarray, top: int, bottom: int) -> float | None: return float(np.median(intra) * 4) # 5 lines, 4 spaces +def ink_levels(gray: np.ndarray) -> tuple[int, int]: + """Black and white points that put the ink on black and the paper on white. + + Left at 0–255 a slice ships whatever grey the scanner produced, and the + downscale to the song's width then blends every stroke edge further, so a + fine engraving arrives on the tablet as a wash. Notation is two-tone by + nature — ink and paper, nothing in between — so Otsu's split is exactly the + measurement wanted, and the points sit halfway to each end of the range from + it. Halfway rather than at the split itself: the ramp between them is the + antialiasing, and collapsing it would leave the notes jagged. + """ + split = float(cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[0]) + if split < 1: + # A page already scanned bilevel has no interior split to find, and + # Otsu degenerates to 0. There is nothing between ink and paper to + # stretch, so leave the sliders where they are. + return 0, 255 + return int(split / 2), int(split + (255 - split) / 2) + + def _gap(run: tuple[int, int], anchor: Anchor) -> int: """Vertical distance between an ink run and a bracket; 0 if they overlap.""" start, end = run @@ -387,5 +408,9 @@ def detect_page(gray: np.ndarray, skew: float | None = None) -> PageDetection: # would risk clipping a tempo mark or a section label above the first staff. left, right = content_columns(straight, anchors) return PageDetection( - skew=angle, systems=systems, cuts=cuts, content=(left, 0.0, right, 1.0) + skew=angle, + systems=systems, + cuts=cuts, + content=(left, 0.0, right, 1.0), + levels=ink_levels(straight), ) diff --git a/noteman_slicer/project.py b/noteman_slicer/project.py index 4ef00cb..cd3d804 100644 --- a/noteman_slicer/project.py +++ b/noteman_slicer/project.py @@ -15,6 +15,7 @@ import hashlib import json from dataclasses import dataclass, field from pathlib import Path +from statistics import median from .detect import PageDetection @@ -296,7 +297,18 @@ class Project: content_rect=detection.content, ) ) - return cls(source=source, source_hash=hash_file(source), pages=pages) + # Levels per song, not per page: a scanner's contrast does not change + # between sheets, and one pair of sliders for the whole song is what a + # user actually wants to nudge. The median keeps a near-blank page — + # where the ink/paper split is guesswork — from setting them. + proposals = [d.levels for d in detections] or [(0, 255)] + levels = ( + int(median(b for b, _ in proposals)), + int(median(w for _, w in proposals)), + ) + return cls( + source=source, source_hash=hash_file(source), pages=pages, levels=levels + ) def save(self, path: Path | None = None) -> Path: """Atomic write, so a crash mid-save cannot destroy the previous state.""" diff --git a/tests/test_detect.py b/tests/test_detect.py index 58f06fd..8afc0cc 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -14,7 +14,12 @@ import numpy as np sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from noteman_slicer.detect import deskew, deskew_angle, detect_page # noqa: E402 +from noteman_slicer.detect import ( # noqa: E402 + deskew, + deskew_angle, + detect_page, + ink_levels, +) W, H = 1000, 1400 STAFF_GAP = 15 # → staff height 60, so expansion reaches 90px past a bracket @@ -71,6 +76,15 @@ def main() -> int: found = deskew_angle(deskew(page, angle)) assert abs(found + angle) <= 0.15, f"skew {angle}: got {found}" + # Levels are proposed too. A grey scan left at 0–255 ships its wash to the + # tablet, and the downscale to the song's width only blends it further. + grey = np.full((H, W), 210, np.uint8) # paper, not white + grey[200:400, 100:900] = 70 # ink, not black + black, white = ink_levels(grey) + assert black < 70 < white < 210, (black, white) + # A page already bilevel has nothing between ink and paper to stretch. + assert ink_levels(_page()) == (0, 255) + # 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