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:
@@ -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 177–994 while its true ink extent is 179–1071 — 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.
|
||||
Reference in New Issue
Block a user