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.
109 lines
3.7 KiB
Python
109 lines
3.7 KiB
Python
"""Runnable check for detection, on a synthetic page.
|
||
|
||
Draws the structure that matters — a bracket per system, staves, lyrics close
|
||
below, and a title and footer far away — so the check is about the algorithm
|
||
rather than about any one scan. Run with `python tests/test_detect.py`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
||
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
|
||
|
||
|
||
def _system(page: np.ndarray, top: int) -> tuple[int, int]:
|
||
"""Two staves joined by a bracket, with a lyric line below. Returns its span."""
|
||
bottom = top + 200
|
||
page[top:bottom, 100:104] = 0 # the bracket
|
||
for staff_top in (top, top + 140):
|
||
for i in range(5):
|
||
y = staff_top + i * STAFF_GAP
|
||
page[y : y + 2, 110:900] = 0
|
||
page[staff_top + 90 : staff_top + 105, 200:800] = 0 # lyrics under the staff
|
||
return top, bottom
|
||
|
||
|
||
def _page() -> np.ndarray:
|
||
page = np.full((H, W), 255, np.uint8)
|
||
page[50:70, 300:700] = 0 # title, far above system 1
|
||
_system(page, 200)
|
||
_system(page, 700)
|
||
page[1350:1365, 100:600] = 0 # footer, far below system 2
|
||
return page
|
||
|
||
|
||
def main() -> int:
|
||
page = _page()
|
||
|
||
det = detect_page(page)
|
||
assert len(det.systems) == 2, f"expected 2 systems, got {len(det.systems)}"
|
||
assert len(det.cuts) == 1, det.cuts
|
||
|
||
first, second = det.systems
|
||
# The bracket spans 200–400; the lyric line under the lower staff reaches
|
||
# ~445 and must be absorbed.
|
||
assert first.top == 200, first.top
|
||
assert 400 < first.bottom < 500, first.bottom
|
||
assert second.top == 700, second.top
|
||
|
||
# The title and footer are far from any bracket and must not be swallowed —
|
||
# the bug that a chaining expansion reintroduces.
|
||
assert first.top > 70, "title block was swallowed"
|
||
assert second.bottom < 1350, "footer was swallowed"
|
||
|
||
# The cut falls between the two systems, in the whitespace.
|
||
assert first.bottom < det.cuts[0] < second.top, det.cuts
|
||
|
||
assert first.staff_height is not None
|
||
assert abs(first.staff_height - STAFF_GAP * 4) < STAFF_GAP, first.staff_height
|
||
|
||
# Skew is recovered to within one fine step.
|
||
for angle in (-1.5, 0.8):
|
||
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
|
||
# single slice each.
|
||
scanned = _page()
|
||
scanned[10 : H - 10, W - 8 : W - 4] = 0
|
||
assert len(detect_page(scanned).systems) == 2, "an edge artefact is not a bracket"
|
||
|
||
# No brackets: every ink run is its own system.
|
||
bare = np.full((H, W), 255, np.uint8)
|
||
for y in (200, 500, 800):
|
||
bare[y : y + 20, 100:900] = 0
|
||
assert len(detect_page(bare).systems) == 3
|
||
assert detect_page(bare).bracketless
|
||
|
||
print("ok")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|