Replace the handoff notes with a durable spec and ADRs

The handoff was written as a message to relay information; several
decisions lived only there. Split into permanent homes:

- docs/spec.md — scope, geometry model, pipeline order, detection,
  levels, editor, project file, markers, bundle format, noteman's
  changes, reference values
- ADR 0002 — raster only in release 1; SVG slices measured at 40x WebP
  naive, 2.6x with a bounding-box cull, deferred on risk not size
- ADR 0003 — lossless WebP with levels and 16-level alpha; every lossy
  option and alternative format measured larger for line art
- ADR 0004 — detection proposes, the human disposes; no unattended mode
- ADR 0005 — PyMuPDF for all PDF access, accepting AGPL
- ADR 0006 — systems are found by vertical brackets, not row-darkness
  gaps, which miscount every page of a 6-voice score

Also from testing against the hardest score in the repertoire: scanned
PDFs carry their scan as an embedded image and must be extracted at
native resolution rather than re-rendered at 600 DPI, and per-page
deskew is mandatory (skew varies -2.6 to +1.2 within one PDF).
This commit is contained in:
Esa Kataja
2026-07-28 22:05:09 +03:00
parent efc920fdc5
commit 46ae7e813a
8 changed files with 674 additions and 7 deletions
@@ -0,0 +1,67 @@
# Systems are found by vertical brackets, not by row-darkness gaps
System detection anchors on the **vertical bracket / barline** that spans a
system's staves, and uses the row-darkness profile only to expand each anchor to
its ink extent. The obvious approach — find gaps in the row-darkness profile and
cut in the middle of them — does not work on multi-voice choral scores, which is
most of the corpus.
## Why the obvious approach fails
A row-darkness profile cannot distinguish an **inter-staff** gap from an
**inter-system** gap. In a 6-voice closed score, one system is six staves joined
by a bracket, and the gaps between those six staves look exactly like the gap
between two systems — only smaller, and not reliably so.
Measured on *Ketun joululaulu*, a 12-page 6-voice arrangement and the hardest
score in the repertoire:
- On page 2's first system, staff gaps run ~47px against a ~211px system gap. A
merge threshold tuned there works.
- On the same page's second system the lyrics fill the inter-staff gaps, so the
ratios invert and the same threshold merges the wrong things.
Result across all 12 pages, row-profile-only versus bracket-anchored:
| | bracket-anchored | row-profile only |
|---|---|---|
| systems per page | 2, 2, 2, 2, 2, 2, 3, 2, 2, 2, 2, 1 | 10, 6, 7, 7, 7, 8, 8, 5, 4, 5, 8, 4 |
The bracket-anchored counts match the score. The row-profile counts are wrong on
every page, and wrong by a different amount each time — so no threshold fixes
them.
## The algorithm
1. **Deskew per page.** Projection-profile variance sweep over ±5°. Measured skew
on this song ranges 2.6° to +1.2° *between pages of the same PDF*, so per-page
is not optional.
2. **Find anchors.** Binarise, then morphological open with a tall thin kernel
(height ≈ 3% of the page) so only long vertical strokes survive. Take
connected components taller than 4% of the page; walk them tallest-first,
keeping each one whose y-extent doesn't overlap an already-kept anchor. Each
surviving stroke is one system.
3. **Expand to ink.** Compute the row-darkness profile on a despeckled copy, take
its ink runs, and assign each run to the nearest anchor by centre distance. A
system's extent is the union of its runs.
4. **Place cuts** at the midpoint between consecutive systems' ink extents.
Step 3 is what makes this work rather than the bracket alone: a bracket stops at
the last staff line, but the slice must include the **lyrics below it**. On page
2, system 1's bracket spans 177994 while its true ink extent is 1791071 — the
77px difference is the bottom voice's lyric line, which the bracket misses
entirely and the row profile finds.
## Consequences
- Detection needs both signals. Neither the column pass nor the row pass is
sufficient alone, so `detect.py` computes both.
- **Scores without brackets** — single-staff melodies, lead sheets — have no
anchors, and fall back to row-profile runs. That fallback is the *only* correct
behaviour there, since every ink run genuinely is its own system.
- Bar numbers printed above a system (this score uses 11, 16, …) sit in their own
ink run and get absorbed into the nearest system by step 3. That is right: they
belong to the system they label.
- A page number can be absorbed the same way if its darkness clears the profile
threshold, inflating the last system's extent. The content rectangle and the
bottom discard slice both prevent this; don't rely on the threshold.