# 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.