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).
68 lines
3.4 KiB
Markdown
68 lines
3.4 KiB
Markdown
# 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.
|