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
+26 -1
View File
@@ -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 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:
"""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),
)
+13 -1
View File
@@ -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."""