Propose black and white points from the scan

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.
This commit is contained in:
Esa Kataja
2026-07-29 12:50:37 +03:00
parent 38cb6ce09a
commit 19f28f4da8
5 changed files with 67 additions and 7 deletions
+5 -4
View File
@@ -44,10 +44,11 @@ off.
one back. one back.
4. **Set the content rectangle.** Drag the blue edges so they hold the music and 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. 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 5. **Check black and white points.** These arrive proposed from the scan, like
goes pure white and its texture disappears; pull *Black point* up until the the cuts do. If the paper still shows texture, pull *White point* down; if the
notes are solid black rather than grey mush. Scans need this; clean digital notes look grey rather than solid, pull *Black point* up. Getting this wrong
PDFs usually don't. 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 Up / Page Down move between pages. Levels carry over from the previous
page, so a consistent scan only needs setting once. page, so a consistent scan only needs setting once.
+8
View File
@@ -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 black point at the ink's darkest and notes go solid. It is also the single
biggest lever on output size. biggest lever on output size.
**Detection proposes both**, like it proposes cuts and skew, because the default
0255 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 0255. 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, Adaptive methods (CLAHE, adaptive thresholding) are the trap — tuned for text,
they eat the thin stuff on notation: hairpin tips, slur ends, ledger lines, 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 tapered beams. A global LUT whose effect you can see beats a local algorithm you
+26 -1
View File
@@ -65,6 +65,7 @@ class PageDetection:
systems: list[System] = field(default_factory=list) systems: list[System] = field(default_factory=list)
cuts: list[int] = 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) content: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0)
levels: tuple[int, int] = (0, 255)
@property @property
def bracketless(self) -> bool: 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 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 0255 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: def _gap(run: tuple[int, int], anchor: Anchor) -> int:
"""Vertical distance between an ink run and a bracket; 0 if they overlap.""" """Vertical distance between an ink run and a bracket; 0 if they overlap."""
start, end = run 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. # would risk clipping a tempo mark or a section label above the first staff.
left, right = content_columns(straight, anchors) left, right = content_columns(straight, anchors)
return PageDetection( 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),
) )
+13 -1
View File
@@ -15,6 +15,7 @@ import hashlib
import json import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from statistics import median
from .detect import PageDetection from .detect import PageDetection
@@ -296,7 +297,18 @@ class Project:
content_rect=detection.content, 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: def save(self, path: Path | None = None) -> Path:
"""Atomic write, so a crash mid-save cannot destroy the previous state.""" """Atomic write, so a crash mid-save cannot destroy the previous state."""
+15 -1
View File
@@ -14,7 +14,12 @@ import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) 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 W, H = 1000, 1400
STAFF_GAP = 15 # → staff height 60, so expansion reaches 90px past a bracket 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)) found = deskew_angle(deskew(page, angle))
assert abs(found + angle) <= 0.15, f"skew {angle}: got {found}" assert abs(found + angle) <= 0.15, f"skew {angle}: got {found}"
# Levels are proposed too. A grey scan left at 0255 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 # 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 # 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 # one system — Olukainen juomukainen, where five pages of six came out as a