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:
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user