Files
noteman-slicer/tests/test_detect.py
T
Esa Kataja 6ff956a622 Add detection: skew, system anchors, ink extents, cuts, staff height
Systems anchor on the vertical bracket and expand over nearby ink, per
ADR 0006. Two corrections the corpus forced:

- Expansion measures distance from the bracket, never from the growing
  extent. A chaining expansion hops between the closely-stacked lines of
  a title block and walks the whole way up the page — on Engel p1 it
  swallowed the title into system 1 and the copyright footer into
  system 3.
- Reach is 1.5 staff heights, which takes in the lyrics below the last
  staff and the tempo mark and INTRO box above the first, while leaving
  the title block and footer out.

Verified against the corpus. Ketun joululaulu: 2 systems per page except
p7 (3) and p12 (1), staff 48px throughout, skew -2.7 to +1.2 per page,
and p2 system 1 spans 179-1071 where its bracket is 177-994 — the
difference being the bottom voice's lyric line. Engel: 2-3 systems per
page, staff 70-72px. Elaman nalka: 3 systems per page, 0 skew, staff
118px at 600 DPI.

The overlay dump is what made both bugs visible, and stays as the tool
for diagnosing a page that comes out wrong.

Closes #4, #5, #6, #7, #8, #9
2026-07-28 22:38:50 +03:00

87 lines
2.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 deskew, deskew_angle, detect_page # noqa: E402
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 200400; 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}"
# 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())