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).
3.4 KiB
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
- 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.
- 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.
- 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.
- 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.pycomputes 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.