From 19f28f4da8060643c9575226f93a166f79629aee Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Wed, 29 Jul 2026 12:50:37 +0300 Subject: [PATCH] Propose black and white points from the scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Levels shipped at 0–255 unless someone moved the sliders, and Bicycle Race showed what that costs. Its ink is grey, not black — a scanned engraving, ink at 2–95, paper at 163–255 — and with alpha = 255 − luminance that greyness becomes transparency. No pixel in the exported bundle was even fully opaque, and the downscale to the song's width blended every stroke edge further. Nothing downstream can rescue it. So detection proposes levels too, like it proposes cuts and skew. Notation is two-tone, which makes Otsu's split the measurement wanted; the points sit halfway from it to each end of the range, so the ramp between them survives as antialiasing rather than going jagged. A page already scanned bilevel has no interior split — Otsu degenerates to 0 — and is left alone. Per page, with the median becoming the song's, so a near-blank page cannot set them. --- docs/guide.md | 9 +++++---- docs/spec.md | 8 ++++++++ noteman_slicer/detect.py | 27 ++++++++++++++++++++++++++- noteman_slicer/project.py | 14 +++++++++++++- tests/test_detect.py | 16 +++++++++++++++- 5 files changed, 67 insertions(+), 7 deletions(-) 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