From 46ae7e813ac83a4558e0e26a675a281c4d5f4970 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 28 Jul 2026 22:05:09 +0300 Subject: [PATCH 1/6] Replace the handoff notes with a durable spec and ADRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CONTEXT.md | 9 +- README.md | 18 +- .../0002-raster-only-svg-renderer-deferred.md | 61 +++ ...webp-with-levels-and-alpha-quantisation.md | 58 +++ ...4-detection-proposes-the-human-disposes.md | 55 +++ docs/adr/0005-pymupdf-for-all-pdf-access.md | 36 ++ ...tems-are-found-by-brackets-not-row-gaps.md | 67 ++++ docs/spec.md | 377 ++++++++++++++++++ 8 files changed, 674 insertions(+), 7 deletions(-) create mode 100644 docs/adr/0002-raster-only-svg-renderer-deferred.md create mode 100644 docs/adr/0003-lossless-webp-with-levels-and-alpha-quantisation.md create mode 100644 docs/adr/0004-detection-proposes-the-human-disposes.md create mode 100644 docs/adr/0005-pymupdf-for-all-pdf-access.md create mode 100644 docs/adr/0006-systems-are-found-by-brackets-not-row-gaps.md create mode 100644 docs/spec.md diff --git a/CONTEXT.md b/CONTEXT.md index 33a8a9f..a82232c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -32,10 +32,11 @@ pre-sets it on a page's top and bottom slice when they contain no system. _Avoid_: delete, skip, exclude **Slice image**: -The rendered artifact of a slice. From a raster source: lossless WebP, RGB pure -black, `alpha = 255 − luminance`, width capped at 1920px — paper is transparency, -ink is alpha. From a vector source: SVG with text converted to paths. Both are -display-ready as produced; nothing downstream reprocesses them. +The rendered artifact of a slice: lossless WebP, RGB pure black, +`alpha = 255 − luminance`, width capped at 1920px — paper is transparency, ink is +alpha. Display-ready as produced; nothing downstream reprocesses it. An SVG form +for vector sources is designed but deferred, which is why the geometry model is +renderer-agnostic. _Avoid_: PNG, page image, tile **Marker**: diff --git a/README.md b/README.md index 2c4cf55..ab9936b 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,18 @@ needs a system package. | | | |---|---| | [CONTEXT.md](CONTEXT.md) | Glossary. What a slice, cut, discard, bundle and song scale actually mean here. Start here. | -| [slicer-handoff.md](slicer-handoff.md) | The design: pipeline, geometry model, detection, bundle format, and what noteman has to change. | -| [docs/adr/0001](docs/adr/0001-slicer-owns-image-processing-bundle-is-the-only-channel.md) | Why the slicer owns all image processing and the bundle is the only channel. | -| [BACKLOG.md](BACKLOG.md) | Deliberately deferred, with the reasoning that got it deferred. | +| [docs/spec.md](docs/spec.md) | The specification: pipeline, geometry model, detection, editor, bundle format, and what noteman has to change. | + +Deferred work is tracked as issues and milestones on the Gitea repo, not in this +tree. + +Decisions that were expensive to reach, each with the evidence behind it: + +| | | +|---|---| +| [ADR 0001](docs/adr/0001-slicer-owns-image-processing-bundle-is-the-only-channel.md) | The slicer owns all image processing; the bundle is the only channel to noteman. | +| [ADR 0002](docs/adr/0002-raster-only-svg-renderer-deferred.md) | Raster only in release 1 — measured SVG slice sizes and what they showed. | +| [ADR 0003](docs/adr/0003-lossless-webp-with-levels-and-alpha-quantisation.md) | Lossless WebP beats every lossy option and every alternative format here. | +| [ADR 0004](docs/adr/0004-detection-proposes-the-human-disposes.md) | No unattended mode: detection suggests, a human confirms. | +| [ADR 0005](docs/adr/0005-pymupdf-for-all-pdf-access.md) | PyMuPDF for all PDF access, accepting AGPL. | +| [ADR 0006](docs/adr/0006-systems-are-found-by-brackets-not-row-gaps.md) | Systems are found by vertical brackets; row-darkness gaps get it wrong. | diff --git a/docs/adr/0002-raster-only-svg-renderer-deferred.md b/docs/adr/0002-raster-only-svg-renderer-deferred.md new file mode 100644 index 0000000..1400311 --- /dev/null +++ b/docs/adr/0002-raster-only-svg-renderer-deferred.md @@ -0,0 +1,61 @@ +# Raster only in release 1; the SVG renderer is deferred + +Vector PDFs are most of the newer corpus, and keeping them vector all the way to +the viewer was an early goal — sheet music is line art, and SVG stays crisp at any +tablet zoom. We measured it before building it, and decided to **rasterize vector +sources like everything else in release 1** and revisit the SVG renderer once +real songs have been cut. + +## The measurement + +One real vector song, 6 pages, 65 systems, rendered both ways: + +| Approach | Total | vs WebP | +|---|---|---| +| WebP slices (600 DPI → 1920, ink→alpha, lossless) | 1.19 MB | 1× | +| SVG, naive `viewBox` + `clipPath` | 26.0 MB | 40× | +| SVG, `set_cropbox` per band | 26.5 MB | 41× | +| SVG, bounding-box cull + glyph subset | 3.09 MB | 2.6× | + +- **The naive cut is unusable.** A `viewBox` + `clipPath` slice contains the + entire page's geometry and merely hides eleven-twelfths of it. +- **`set_cropbox` does not help.** MuPDF renders full page content regardless of + the crop, so there is no free version of the cull. +- **The cull works.** PyMuPDF emits a `` glyph table (111 KB of a 256 KB + page) referenced by ``, plus body `` + elements. Filter both by y-extent, then keep only the glyphs the survivors + reference. Roughly 50 lines, 15× improvement. + +## Why defer, given the cull works + +**Not size.** At 3.1 MB vs 1.2 MB per song — 225 MB vs 87 MB across a 73-song +corpus — both are nothing on a homelab. The measurement killed the lazy +implementation, not the idea. + +What defers it is risk and missing evidence: + +- The cull is **heuristic parsing**: glyph extents bounded at baseline ±14pt, + path extents read from raw `d` coordinates. It is over-inclusive by design, so + it fails safe — but "fails safe" still means a slice quietly carrying a + neighbour's slur, or a hairline dropped because the y-window was wrong on some + publisher's output. That needs eyeballing per song, a QA loop the raster path + doesn't have. +- Rendering 65 complex SVGs in a scrolling column may be slower than 65 WebPs. + Unmeasured. +- **The deciding question is unanswerable from here**: does 1920px WebP actually + feel insufficient when pinch-zooming on a tablet? Cutting real songs answers + it; more measurement doesn't. + +Vector PDFs are also the *clean* case for the raster path — deskew is a no-op, +detection works best, there are no scan artefacts — so rasterizing them is not a +degraded fallback. + +## Consequences + +- The geometry model stays **renderer-agnostic**, in normalised page coordinates, + so adding the SVG renderer later is an output stage rather than a redesign. +- **Re-export from the project file** regenerates every song's bundle without + repeating human work, so songs cut before the SVG renderer exists are not + stranded. +- noteman needs no SVG support (`image/svg+xml`, `.svg` content type, CSP header + on SVG responses) until the renderer ships. diff --git a/docs/adr/0003-lossless-webp-with-levels-and-alpha-quantisation.md b/docs/adr/0003-lossless-webp-with-levels-and-alpha-quantisation.md new file mode 100644 index 0000000..fca73e9 --- /dev/null +++ b/docs/adr/0003-lossless-webp-with-levels-and-alpha-quantisation.md @@ -0,0 +1,58 @@ +# Lossless WebP, with levels and alpha quantised to 16 levels + +Slice images are encoded as **lossless WebP**, with the levels adjustment applied +and the alpha channel quantised to 16 levels. About 7 KB per slice, ~450 KB for a +65-system song. Every lossy option and every alternative format measured +*larger* for this content, which is the opposite of the usual intuition — hence +this record. + +## The measurement + +20 slices of one real song, levels applied throughout, relative to plain lossless +WebP: + +| | vs baseline | | +|---|---|---| +| **WebP lossless + alpha quantised to 16** | **68%** | chosen | +| AVIF q60 | 90% | lossy, for 10% | +| WebP lossless | 100% | baseline | +| WebP lossy q85 (alpha) | 107% | | +| AVIF q85 | 114% | | +| JXL lossless | 130–133% | | +| WebP lossy q85 (opaque ink-on-white) | 158% | | +| PNG grayscale + alpha | 165% | | +| AVIF lossless | 188% | | + +Separately, before levels: applying levels alone takes 338 KB → 211 KB, a 38% +reduction. + +## Four results that contradict an instinct + +- **Lossy is bigger than lossless here.** Not a quality problem — the measured + difference between q85 and lossless is max 12/255, mean 0.33, i.e. invisible. + Lossy VP8 simply spends more bits on sharp black/white edges than VP8L's + palette and predictor transforms do, and notation is nothing but sharp edges. + The "q85 looks fine" intuition comes from photographs and inverts here. +- **AVIF and JXL both lose**, AVIF lossless by nearly 2×. Their lossless modes + are afterthoughts on photo codecs. WebP's VP8L is close to purpose-built for + flat two-tone line art — sheet music is the content type it is best at. JXL + additionally has no path forward in Chrome. +- **Alpha costs nothing.** Opaque ink-on-white and black-plus-alpha are within + 0.1% at lossless, so paper-tint removal and future non-rectangular slices are + free. +- **Levels is the single biggest lever** — 38%, as a side effect of a control + that exists for quality reasons anyway. Pushing the white point below the + paper's luminance sets vast regions to exactly `alpha = 0`, which costs almost + nothing to encode. + +Alpha quantisation to 16 levels is imperceptible: antialiased edges span 2–3 px +at 1920, and 16 steps across that is below notice. 8 levels starts to gamble on +thin strokes. + +## Rejected as not worth it + +- **Encoder effort tuning** — Pillow's `method=6` buys 3% and a dependency. +- **`alpha_quality=60`** — 24%, for less control than quantisation gives. +- **Grayscale WebP** — no such mode exists. It wouldn't help anyway: the RGB + channels are constant black and compress to nearly nothing, so alpha is the + entire payload. diff --git a/docs/adr/0004-detection-proposes-the-human-disposes.md b/docs/adr/0004-detection-proposes-the-human-disposes.md new file mode 100644 index 0000000..b2e0852 --- /dev/null +++ b/docs/adr/0004-detection-proposes-the-human-disposes.md @@ -0,0 +1,55 @@ +# Detection proposes, the human disposes — there is no unattended mode + +Every automatic result the slicer produces — skew angle, cut positions, source +type, staff height, ink bounds — is a **suggestion the user confirms or modifies** +before it is committed. There is no batch mode, no headless "slice this folder", +and no code path that writes a bundle without a human having looked at it. + +This is a constraint on the tool's shape, not a UI preference, which is why it +gets an ADR: it deletes an entire phase of the original plan and it will look +like a missing feature to anyone who finds the detection code and wonders why it +isn't wired to a CLI. + +## Why + +The corpus is PDFs from a choir's distribution channel, and quality varies +wildly — clean vector engravings at one end, noisy scans with a previous owner's +pencil markings at the other. **Testing showed the detection algorithms produce +unusable slices on any source with speckles or otherwise poor quality.** Not +slightly-off slices: unusable ones. + +But the same testing showed the suggestions land *close* on decent sources — +close enough that correcting them is faster than placing cuts from scratch. So +detection earns its place as an accelerator, and loses any claim to being +load-bearing. + +## What this rejected + +The original plan's **Phase A** was a deliberately non-interactive CLI: +rasterize, auto-deskew, auto-detect boundaries, write numbered slices, and fix +the misses by hand in GIMP. Its justification was "learn the failure modes before +designing the editor," which is a good idea. + +It doesn't survive the premise. A CLI whose output can't be trusted has GIMP as +its repair path — routing work back into the manual process the project exists to +remove. A diagnostic variant (dump per-page PNGs with proposed cuts drawn in red) +was considered and also dropped: it only re-shows a failure already confirmed by +testing, and the editor shows the same thing live. + +Release 1 is therefore the editor and detection together. There is no smaller +first release that is actually usable. + +## Consequences + +- **Manual placement is the primary interaction**, not a correction affordance. + The editor must be fully usable with detection producing nothing. +- **Despeckling targets the detector, not the output.** The known failure mode is + specks, so a median blur and a small-component filter clean the row-darkness + profile the detector reads; the shipped pixels come from the levels-adjusted + image. +- Cut placement is deliberately **forgiving** — anywhere in the whitespace gap + yields the same output, since trim crops to ink afterwards. Precision is not + asked of the human. +- The editor should surface **slice edges**, not just cut lines, so trim + anomalies (a speck anchoring the bounding box) are visible rather than + discovered later in the viewer. diff --git a/docs/adr/0005-pymupdf-for-all-pdf-access.md b/docs/adr/0005-pymupdf-for-all-pdf-access.md new file mode 100644 index 0000000..261551b --- /dev/null +++ b/docs/adr/0005-pymupdf-for-all-pdf-access.md @@ -0,0 +1,36 @@ +# PyMuPDF for all PDF access, accepting AGPL + +All PDF work — rasterizing at a chosen DPI, exporting SVG, and inspecting page +content to classify a source as bitmap or vector — goes through **PyMuPDF**. It +is a single wheel with MuPDF bundled, so the tool needs no system packages. Its +licence is **AGPL-3.0**, which we accept. + +## Why not the permissive combination + +The obvious permissive stack was `pypdfium2` (Apache/BSD) for rasterizing plus +`mutool` or `pdftocairo` shelled out for SVG. Both of those are **system +packages** — `mupdf-tools`, `poppler` — and a system package on the vector path +is precisely the failure the language choice was made to avoid: the tool is +supposed to install once and run from any directory on any machine. + +The SVG step can't simply be skipped, either. Music glyphs come from a notation +font (Emmentaler, Bravura, or Sibelius/Finale's). An SVG that *references* a font +renders as garbage on a device that lacks it, so text must be converted to paths +at export. PyMuPDF does this **by default** — `page.get_svg_image(text_as_path=1)`, +verified to emit `` elements and zero `` — so the font risk is closed +with no extra tooling. + +Mixing the two (pypdfium2 for raster, PyMuPDF only for SVG) is the worst option: +two libraries with overlapping responsibilities, and AGPL linked in anyway. + +## Consequences + +- **The AGPL propagates only if the slicer is published.** For a local personal + tool it costs nothing. A future permissive release would need the rasterizer + swapped back to `pypdfium2` — a contained change, since PDF access sits behind + the renderer-agnostic geometry model. +- **Source-type detection comes free** from the same library: `get_images()` plus + a full-page-image area check distinguishes a scan from an engraving. +- The SVG export path is present and working even though the SVG *renderer* is + deferred — see + [ADR 0002](0002-raster-only-svg-renderer-deferred.md). diff --git a/docs/adr/0006-systems-are-found-by-brackets-not-row-gaps.md b/docs/adr/0006-systems-are-found-by-brackets-not-row-gaps.md new file mode 100644 index 0000000..95fbda7 --- /dev/null +++ b/docs/adr/0006-systems-are-found-by-brackets-not-row-gaps.md @@ -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. diff --git a/docs/spec.md b/docs/spec.md new file mode 100644 index 0000000..62586e2 --- /dev/null +++ b/docs/spec.md @@ -0,0 +1,377 @@ +# noteman-slicer — specification + +What the tool does and how it behaves. Vocabulary is in +[`CONTEXT.md`](../CONTEXT.md); the reasoning behind the expensive decisions is in +[`docs/adr/`](adr/). + +## Scope + +A local, single-user tool that turns a score PDF into the ordered slice images +[noteman](../../noteman) consumes, plus the navigation markers that sit on them. +It automates the mechanical part of noteman's ingestion boundary. + +It is **not** a GIMP replacement. Erasing previous-owner pencil marks, chord +letters and breath marks stays in GIMP — the irreducible manual part, which GIMP +with a stylus already does well. + +**One PDF → one song → one project → one bundle.** Never a many-to-one in any +direction. A PDF is either bitmap or vector, never mixed. + +### Why it's separate from noteman + +Splitting it out removed the double-implementation constraint — in-app, every +operation needs both a fast browser preview and a real server-side render, and +that constraint is what priced dewarp and brush masking out entirely, not the +algorithms. It also removed infrastructure noteman doesn't otherwise need (a +scratch workspace for multi-MB rasters, an edit-list table, cleanup sweeps for +orphaned temp files, poppler in the Docker image, an admin UI surface), and +unlocked real image libraries. + +It costs nothing: song creation is admin-only, done at home, once per song. + +## Operating principle + +**Detection proposes, the human disposes.** Every automatic result — skew angle, +cut positions, source type, staff height, ink bounds — is a suggestion the user +confirms or modifies before it is committed. There is no unattended mode. See +[ADR 0004](adr/0004-detection-proposes-the-human-disposes.md). + +## Geometry model + +**One geometry model, two renderers.** Geometry is stored in **normalised page +coordinates** (0–1 of page width and height), independent of DPI and of which +renderer produces the output. Only the final stage differs. + +| Concept | Raster | Vector | +|---|---|---| +| Cut | y in pixels | y in PDF user space | +| Discard | drop the slice | drop the slice | +| Content rectangle | crop before cutting | clip before cutting | +| Trim | crop to ink bbox | crop `viewBox` to ink bbox | +| Uniform width | transparent right pad | wider `viewBox`, same content | +| Staff-height normalise | scale factor | scale factor | +| Deskew, levels, ink→alpha, 1920 cap | yes | no | + +Only the raster renderer ships in release 1 — see +[ADR 0002](adr/0002-raster-only-svg-renderer-deferred.md). The editor is one +editor regardless, since a vector PDF has to be rasterized just to display it on +screen. + +### Pipeline order + +``` +load raster → deskew → levels → content rect → cut → discard + → trim → scale → pad → ink→alpha → encode +``` + +**Load raster** differs by source type. A scanned PDF carries one full-page image +per page, and that image *is* the scan — extract it at its native resolution +(`extract_image`) rather than re-rendering the page. Re-rendering at a fixed +600 DPI resamples a 200 DPI scan up by 3×, which triples the pixel count and adds +no detail. A vector PDF has no embedded raster, so it is rendered — see the DPI +note in *Reference values*. + +The rest of the order is not arbitrary: + +- **Levels before anything geometric**, so the trim bounding box is computed on + the image that actually ships. +- **Content rect before cutting**, so margin junk never enters a slice. +- **Trim before scale**, since the scale factor derives from the widest *trimmed* + slice. + +### Slices, cuts and discard + +A page starts as a single slice; each cut splits one slice into two. Slices +therefore tile the page with no gaps and no overlap. + +Headers, footers and blank regions leave the song via a **discard** flag, not via +cuts at the page edges. Modelling a slice as "the region between two cuts" leaks: +page 2 has no header, so it would need an invented top cut whose position depends +on whether that page happens to have one. + +Cut placement is forgiving — anywhere inside the whitespace gap yields the same +output, because trim crops to ink afterwards. + +### Content rectangle + +The region of a page that holds music, set per PDF and adjustable per page, +applied before cutting. Everything outside it is dropped. + +This handles margin junk structurally rather than case-by-case, because margin +junk is by definition outside the music: scan-edge bands, spine shadows, and page +numbers printed in the side margin level with a system. That last one matters +more than it looks — see the trim consequences below. + +### Trim, scale, pad + +**Trim** tight on all four sides, per slice. This normalises away the left-margin +drift between scanned pages, and flattens the engraved indent of the first +system — correct here, since noteman strips the printed header the indent made +room for. + +Two consequences: + +- A stray speck at the far left anchors the trim, shifting that slice relative to + its neighbours. Mitigate by ignoring connected components under a few hundred + pixels (`cv2.connectedComponentsWithStats`) when computing the bounding box. +- A page number in the side margin level with a system would set that slice's + bounding box, which sets the song's widest slice, which scales the whole song + down. One artefact, whole song smaller. Hence the content rectangle. + +**Scale is normalised on staff height, not width.** Width-based scaling assumes +every slice comes from the same scan at the same DPI. It breaks for a rescanned +page, a PDF mixing scan generations, or a re-engraved replacement system — whose +width depends on how much music is in it, not on matching its neighbours. Staff +height is the invariant a reader perceives as "the notes are the same size", and +it falls out of the same row-darkness profile detection already computes. + +Two steps, both per song: normalise every slice to a common staff height, then +scale the song uniformly so its widest slice lands at **1920px**. That is a +ceiling, never a target — **never upscale**. A song that comes out narrower stays +narrower; enlarging a 600 DPI scan past its real resolution buys softness and +bytes and no detail. + +**Pad** narrower slices with transparency on the right, so every slice in a song +is the same width, flush left, notes the same size. A short system simply ends +earlier. + +### Encoding + +**Lossless WebP, with levels applied and alpha quantised to 16 levels.** Roughly +7 KB per slice, about 450 KB for a 65-system song. Lossy encodings and the +alternative formats are all *larger* for this content — measured, with the +figures, in +[ADR 0003](adr/0003-lossless-webp-with-levels-and-alpha-quantisation.md). + +Ink handling is luminance → alpha: ink forced to pure black, +`alpha = 255 − luminance`. Not `pixel == white` thresholding — staff lines are +antialiased, and binary removal leaves jagged edges. + +## Detection + +All of it is a suggestion, all of it overridable. + +**Deskew** — per page, and not optionally so: measured skew varies from −2.6° to ++1.2° *between pages of the same PDF*. Staff lines are by far the strongest +horizontal signal in sheet music, so a projection-profile variance sweep over ±5° +finds the angle reliably — sum row-darkness for each candidate angle, take the +angle of maximum variance. Run on a downscaled copy. Pair with a manual slider. + +**Systems** — anchored on the **vertical bracket** that spans a system's staves, +not on gaps in the row-darkness profile. A row profile cannot distinguish an +inter-staff gap from an inter-system gap on multi-voice choral scores, and gets +the system count wrong on every page. See +[ADR 0006](adr/0006-systems-are-found-by-brackets-not-row-gaps.md) for the +measurement and the full algorithm. In outline: + +1. Binarise; morphological open with a tall thin kernel so only long vertical + strokes survive; keep non-overlapping components taller than 4% of the page. + Each is one system. +2. Take ink runs from the row-darkness profile and assign each to the nearest + anchor. A system's extent is the union of its runs — this is what pulls in the + lyrics printed *below* the last staff, which the bracket stops short of. +3. Propose cuts at the midpoint between consecutive systems' ink extents, and + pre-set the discard flag on a page's top and bottom slice when they contain no + system. + +Scores with no bracket — single-staff melodies, lead sheets — have no anchors and +fall back to row-profile runs, which is correct there. + +**Staff height** — peak-to-peak spacing in the row profile. + +**Source type** — `get_images(full=True)` / `get_drawings()` proposes bitmap or +vector per PDF; the tool asks the user to confirm before routing. (`full=True` is +required, or `get_image_bbox` rejects the item.) + +**Despeckle feeds detection only.** A median blur plus dropping tiny connected +components denoises the *profile the detector reads*; the shipped pixels come +from the levels-adjusted image. The known failure mode is specks, so the fix +belongs on the signal, not the output. + +## Levels + +Two sliders per song (black point, white point) applied via `cv2.LUT`, with a +per-page override. + +In release 1, not deferred: with `alpha = 255 − luminance`, a scan's greyness +*becomes* transparency, so a faint or yellowed source produces washed-out notes +on a hazy background and **nothing downstream can rescue it**. Set the white +point just under the paper's luminance and the paper vanishes completely; set the +black point at the ink's darkest and notes go solid. It is also the single +biggest lever on output size. + +Adaptive methods (CLAHE, adaptive thresholding) are the trap — tuned for text, +they eat the thin stuff on notation: hairpin tips, slur ends, ledger lines, +tapered beams. A global LUT whose effect you can see beats a local algorithm you +can't predict. + +## Editor + +**PySide6.** `QGraphicsView` provides the viewport — pan, zoom, screen↔image +coordinate mapping, resampling, hit-testing — which would otherwise be ~150 lines +of hand-rolled geometry. `cv2.imshow` was rejected: OpenCV's highgui is GTK/X11 +and lands on XWayland at best, and it has no text input at all. + +What the editor does: pan and zoom the page, drag cut lines, toggle discard, +adjust the content rectangle, move the levels sliders, place markers, fill in +song metadata, export. + +Marker placement needs a **slice picker** — a `QListView` in icon mode over the +slice previews — since every jump source stores an explicit target. One widget +serving all six jump types. + +## Project file + +Autosaved JSON beside the source PDF, holding the source path and hash, cuts, +discards, content rectangle, skew angles, levels, staff-height overrides, markers +and metadata. The bundle is *generated* from it, so export is a pure function of +the project file plus the PDF. + +It buys crash safety, resume across sessions (authoring is trickle-in), and +**re-export** — change the 1920 cap, fix one cut, or add the SVG renderer later, +and every song's bundle regenerates without repeating any human work. + +The project file references the PDF and never contains it; the hash lets the +editor warn if the PDF changed underneath. + +## Markers + +Placed here rather than in noteman: at cut time you are already reading the score +page by page at full resolution, so the Segno, the Coda sign, the "to coda" text +and the rehearsal letters are on screen. Deferring means reading the whole score a +second time to find the same symbols. + +noteman's vocabulary, carried verbatim — `rehearsal_letter`, `section_label`, +`segno`, `coda`, `fine`, `repeat_start`, `repeat_end`, `volta`, `to_coda`, +`ds_al_coda`, `ds_al_fine`, `dc_al_coda`, `dc_al_fine`, `generic_jump`. A small +stable enum, but real coupling: adding a type means changing both repos. + +Three shapes among them: + +- **Bare tags:** `segno`, `coda`, `fine`, `repeat_start`, `repeat_end`. +- **Tags with free text:** `rehearsal_letter` ("C"), `section_label` ("CHORUS"), + `volta` ("1."). +- **Jump sources:** `to_coda`, `ds_al_coda`, `ds_al_fine`, `dc_al_coda`, + `dc_al_fine`, `generic_jump`. + +**Every jump source stores its target slice explicitly.** noteman's viewer +currently resolves by type — a `to_coda` finds the song's unique `coda` at tap +time — but that puts an unwritten "exactly one Coda per song" invariant into a +contract between two separately-maintained repos, enforced by neither. Authoring +the target costs one click on a slice already on screen, and in exchange the +bundle is self-describing and a score with two codas simply works. + +## Bundle + +The only channel to noteman. No API, no direct upload — see +[ADR 0001](adr/0001-slicer-owns-image-processing-bundle-is-the-only-channel.md). + +``` +song.zip + song.json + original.pdf + 001.webp 002.webp … +``` + +```json +{ + "v": 1, + "title": "…", "composer": "…", "arranger": "…", + "slices": [ + { "file": "001.webp" }, + { "file": "002.webp", "markers": [{ "type": "rehearsal_letter", "label": "A" }] }, + { "file": "003.webp", "markers": [{ "type": "to_coda", "destination": 7 }] } + ] +} +``` + +Array order **is** slice order — one ordering, not two. Markers nest inside the +slice they sit on, so indices appear in exactly one place: a jump source's +`destination`. + +`"v": 1` is eight bytes of insurance. The bundle is the only channel, MIDI and +MP3s are planned for a later phase, and bundles are archived artifacts that may be +re-imported a year later. + +Otherwise: plain zip, no manifest beyond this, no checksums, hand-fixable. +Python's `zipfile` is stdlib; the import side needs one zero-dep library +(`fflate`), since Bun has zlib but no zip reader. + +**Contents:** slices, markers, the original PDF, and song-level text metadata +(title, subtitle, composer, original artist, arranger, lyricist, translator, +voice list). Metadata is included not because the slicer transforms it but +because you have to read the title block anyway to mark the header slice +discarded — typing eight fields while it's on screen beats reopening the PDF +later. + +Rehearsal MIDI and MP3s are deliberately out of the first bundle. + +### One rule for the import side + +**Import creates a new song only; never re-import onto an existing one.** Jump +destinations reference slices by ID, so replacing a song's slices silently +orphans every marker on it. Re-cutting happens *before* marker authoring in +practice, so forbidding it costs nothing and prevents a genuinely nasty data-loss +mode. Re-export from the project file is the supported path. + +## Implementation + +**Python**, chosen for OpenCV access and iteration speed. Installed as a package +via `uv tool install --editable .`, which puts a `noteman-slicer` command on PATH +that runs from any directory with no venv to activate. The one cwd trap: load +bundled data via `Path(__file__).parent` or `importlib.resources`, never a +relative path. + +Dependencies: **PyMuPDF**, **PySide6**, **opencv-python-headless**, **numpy** — +all wheels, no system packages. PyMuPDF covers every PDF need; see +[ADR 0005](adr/0005-pymupdf-for-all-pdf-access.md). + +Verified: `cv2` 5.0.0 writes 4-channel lossless WebP with alpha preserved +byte-exact (`IMWRITE_WEBP_QUALITY, 101`). + +Module boundaries: `pdf.py` (load, source-type detect, rasterize), `detect.py` +(deskew, row-darkness profile, system runs, staff height), `bundle.py`, +`editor.py`. + +## Changes required in noteman + +On noteman's timeline, not the slicer's — but release 1 produces artifacts +nothing consumes until this lands. + +1. **Delete the sharp normalisation pipeline.** The slicer's output is final. +2. **Bundle import** — unzip → read `song.json` → create song → insert slices in + array order → insert markers, mapping index → new slice UUID → store the PDF. +3. **Jump sources carry explicit destinations** — `destinationSliceId` is already + nullable on every marker type, so this is viewer logic, not schema. + +SVG support on the noteman side (`image/svg+xml` in the upload path, `.svg` in +`CONTENT_TYPES`, and a CSP header on SVG responses) is not needed until the SVG +renderer ships. + +## Phasing + +**Release 1 — editor + detection + bundle export, raster only.** Vector PDFs are +rasterized like everything else; they're the clean case, where deskew is a no-op +and detection works best. Levels, content rectangle, discard, markers, project +file. + +Everything else is deferred and tracked as issues on the Gitea repo. + +## Reference values + +- Final slice width cap: **1920px**, matching the viewer sheet's max-width. A + ceiling, not a target. +- Output format: **lossless WebP**. +- Working resolution: + - **Scanned sources — the embedded image's native resolution.** Never + re-render. Real scans in this corpus run ~200 DPI (1653×2332 for A4), which + is *below* the 1920 cap, so those songs ship narrower than 1920 and are never + upscaled. + - **Vector sources — 600 DPI**, configurable. A4 @ 600 DPI is ~4960×7016 px; + the ~2.6× downsample to 1920 is itself a quality win via antialiasing. 300 + DPI would suffice for the target, but 600 buys headroom for deskew + resampling. +- A slice = **one system** = one full line of music across all voices, typically + 4–12 bars, lyrics intact. +- Upload/bundle sizes are not constrained by noteman's old 25 MB/file limits — + the bundle bypasses that path entirely. From 01227b3382b62650fe409761e31e1f52dfacdd24 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 28 Jul 2026 22:22:03 +0300 Subject: [PATCH 2/6] Model cuts as polylines, not horizontal lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page 1 of Engel (Bosse/Partitura) has a boxed VERSE 1 label in the left margin at the same rows as the preceding system's bass lyric line — ink on both sides of the page throughout the band, so no horizontal line separates them. The label belongs to system 2, the lyrics to system 1. A cut is therefore a polyline spanning the page, two points being the ordinary straight case. A slice bounded by a non-straight cut is not rectangular: its image is the region's bounding box with everything outside made transparent, which composites invisibly on the viewer's sheet. Masking paints transparency, never white. --- CONTEXT.md | 23 +++++++++++++++-------- docs/spec.md | 17 ++++++++++++++++- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index a82232c..c4ce60e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,9 +20,11 @@ _Avoid_: mode, format **Slice**: The atomic visual unit of a song — one *system*, one full line of music across all voices, typically 4–12 bars with lyrics intact. Same definition as noteman's. -Structurally, a horizontal region of a page: a page begins as a single slice and -each cut splits one slice into two, so slices always tile the page with no gaps -and no overlap. +Structurally, a region of a page bounded above and below by cuts: a page begins +as a single slice and each cut splits one slice into two, so slices always tile +the page with no gaps and no overlap. Rectangular when its cuts are straight, +and stepped when they are not — the slice image is then its bounding box with +everything outside the region transparent. _Avoid_: segment, strip, row, band **Discard**: @@ -76,11 +78,16 @@ living unenforced in two repos, and a score with two codas simply works. _Avoid_: link, reference, pointer **Cut**: -A horizontal line placed on a page that splits one slice into two. Straight at -first; a later polyline form handles pages where systems slant or interleave. -Placement is forgiving — anywhere inside the whitespace gap yields the same -output, because trim crops to ink afterwards. -_Avoid_: split, divider, break +A boundary placed on a page that splits one slice into two. Modelled as a +**polyline** spanning the page from left edge to right edge, with two points — +a straight horizontal line — as the ordinary case. Extra vertices handle the +common publisher habit of printing a section label (`VERSE 1`, `INTRO`) in the +left margin at the same height as the previous system's lyrics: the cut steps +above the label on the left and below the lyrics on the right. + +Placement along the boundary is forgiving — anywhere inside the whitespace yields +the same output, because trim crops to ink afterwards. +_Avoid_: split, divider, break, cut line **Content rectangle**: The region of a page that holds music. Set per PDF, adjustable per page, applied diff --git a/docs/spec.md b/docs/spec.md index 62586e2..d074de6 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -89,9 +89,24 @@ cuts at the page edges. Modelling a slice as "the region between two cuts" leaks page 2 has no header, so it would need an invented top cut whose position depends on whether that page happens to have one. -Cut placement is forgiving — anywhere inside the whitespace gap yields the same +Cut placement is forgiving — anywhere inside the whitespace yields the same output, because trim crops to ink afterwards. +**A cut is a polyline, not a line.** Two points — a straight horizontal +boundary — is the ordinary case and what detection proposes. Extra vertices exist +because publishers routinely print a section label in the left margin at the same +height as the *previous* system's lyrics. On page 1 of *Engel* (Bosse/Partitura +edition), the boxed `VERSE 1` label and the preceding system's bass lyric line +occupy the same rows: ink is present on both sides of the page throughout that +band, so no horizontal line separates them. `VERSE 1` belongs to system 2, the +lyrics to system 1. The cut has to step — above the label on the left, below the +lyrics on the right. + +A slice bounded by a non-straight cut is **not rectangular**. Its image is the +bounding box of the region, with everything outside the region made transparent. +That composites invisibly on the viewer's sheet, so nothing downstream needs to +know. This is also why masking must paint transparency rather than white. + ### Content rectangle The region of a page that holds music, set per PDF and adjustable per page, From e2f3e8fbdca06a1630a1d0e32c5f5552e59fad55 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 28 Jul 2026 22:33:26 +0300 Subject: [PATCH 3/6] Add package skeleton, source classification and raster loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pdf module is the whole of M1 except the debug overlay, which needs detection results to draw. Source type is detected per PDF by looking for a page-covering image, and is always reported for confirmation rather than applied silently (ADR 0004). Rasters are extracted via Pixmap(doc, xref) rather than by decoding extract_image() bytes, because MuPDF handles JBIG2 and CCITT scans that no image library will. Scanned pages are extracted at the embedded image's native resolution; only vector pages are rendered, at 600 DPI. Verified against the corpus: Elaman nalka (vector) renders 4959x7017, Ketun joululaulu (scan) loads 1653x2332, Engel (scan) 2552x3504 — and Engel's page 2 is 2480 wide where page 1 is 2552, so scan width varies within one PDF. tests/test_pdf.py builds its own PDFs so the check runs without corpus files, which are copyrighted and gitignored. Closes #1, #2, #3 --- noteman_slicer/__init__.py | 1 + noteman_slicer/cli.py | 45 +++++++++++ noteman_slicer/pdf.py | 103 ++++++++++++++++++++++++ pyproject.toml | 14 +++- tests/test_pdf.py | 82 +++++++++++++++++++ uv.lock | 158 +++++++++++++++++++++++++++++++++++++ 6 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 noteman_slicer/__init__.py create mode 100644 noteman_slicer/cli.py create mode 100644 noteman_slicer/pdf.py create mode 100644 tests/test_pdf.py create mode 100644 uv.lock diff --git a/noteman_slicer/__init__.py b/noteman_slicer/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/noteman_slicer/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/noteman_slicer/cli.py b/noteman_slicer/cli.py new file mode 100644 index 0000000..c86f585 --- /dev/null +++ b/noteman_slicer/cli.py @@ -0,0 +1,45 @@ +"""Command line entry point.""" + +from __future__ import annotations + +import argparse +import sys + +from . import __version__ +from .pdf import SourceType, open_source, page_raster + + +def _info(args: argparse.Namespace) -> int: + source = open_source(args.pdf, SourceType(args.type) if args.type else None) + note = f" (detected {source.detected.value}, overridden)" if source.overridden else "" + print(f"{source.path.name}: {source.type.value}{note}, {len(source)} pages") + for i in range(len(source)): + h, w = page_raster(source, i).shape + print(f" p{i + 1:<3} {w}x{h}") + source.close() + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="noteman-slicer", + description="Cut score PDFs into noteman's slice images and markers.", + ) + parser.add_argument("--version", action="version", version=__version__) + sub = parser.add_subparsers(dest="command", required=True) + + info = sub.add_parser("info", help="classify a PDF and report its page rasters") + info.add_argument("pdf") + info.add_argument( + "--type", + choices=[t.value for t in SourceType], + help="override source-type detection", + ) + info.set_defaults(func=_info) + + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/noteman_slicer/pdf.py b/noteman_slicer/pdf.py new file mode 100644 index 0000000..6ec6263 --- /dev/null +++ b/noteman_slicer/pdf.py @@ -0,0 +1,103 @@ +"""PDF input: classify a score source and hand back page rasters. + +Two source types, never mixed within one PDF (docs/spec.md): + + raster — a scan; every page carries one full-page image, and *that image + is the scan*. It is extracted at its native resolution rather + than re-rendered: real scans in this corpus run ~200 DPI, and + re-rendering at 600 would triple the pixel count for no detail. + vector — an engraving; nothing to extract, so the page is rendered. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +import numpy as np +import pymupdf + +VECTOR_RENDER_DPI = 600 + +# An image covering at least this fraction of the page is the page's scan +# rather than an illustration sitting on an engraving. +_FULL_PAGE_AREA = 0.5 + + +class SourceType(Enum): + RASTER = "raster" + VECTOR = "vector" + + +@dataclass +class Source: + path: Path + doc: pymupdf.Document + type: SourceType + detected: SourceType + render_dpi: int = VECTOR_RENDER_DPI + + @property + def overridden(self) -> bool: + """True when the user's choice disagrees with detection.""" + return self.type is not self.detected + + def __len__(self) -> int: + return len(self.doc) + + def close(self) -> None: + self.doc.close() + + +def _full_page_image(page: pymupdf.Page) -> int | None: + """xref of the image covering this page, or None.""" + page_area = abs(page.rect.get_area()) + if page_area <= 0: + return None + # full=True is required, or get_image_bbox rejects the item. + for item in page.get_images(full=True): + try: + bbox = pymupdf.Rect(page.get_image_bbox(item)) + except ValueError: + continue + if abs(bbox.get_area()) >= page_area * _FULL_PAGE_AREA: + return item[0] + return None + + +def classify(doc: pymupdf.Document) -> SourceType: + """Detection only — the caller confirms with the user (ADR 0004).""" + scanned = sum(_full_page_image(page) is not None for page in doc) + return SourceType.RASTER if scanned * 2 > len(doc) else SourceType.VECTOR + + +def open_source(path: str | Path, source_type: SourceType | None = None) -> Source: + """Open a PDF. `source_type` overrides detection; it never silently wins.""" + path = Path(path) + doc = pymupdf.open(path) + detected = classify(doc) + return Source(path=path, doc=doc, type=source_type or detected, detected=detected) + + +def page_raster(source: Source, index: int) -> np.ndarray: + """One page as a grayscale array, at the resolution the pipeline should work at.""" + page = source.doc[index] + + if source.type is SourceType.RASTER: + xref = _full_page_image(page) + if xref is not None: + # Pixmap(doc, xref) rather than decoding extract_image() bytes: + # MuPDF handles JBIG2 and CCITT, which no image library will. + pix = pymupdf.Pixmap(source.doc, xref) + return _to_gray(pix) + # A scanned PDF whose page has no embedded image (a blank, or a + # cover typeset in vector). Rendering is the only option left. + + return _to_gray(page.get_pixmap(dpi=source.render_dpi, colorspace=pymupdf.csGRAY)) + + +def _to_gray(pix: pymupdf.Pixmap) -> np.ndarray: + if pix.alpha or pix.colorspace is None or pix.colorspace.n != 1: + pix = pymupdf.Pixmap(pymupdf.csGRAY, pix) + return np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width) diff --git a/pyproject.toml b/pyproject.toml index 88af297..879d6b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,4 +4,16 @@ version = "0.1.0" description = "Cuts score PDFs into noteman's slice images and markers" readme = "README.md" requires-python = ">=3.13" -dependencies = [] +dependencies = [ + "pymupdf>=1.26", + "numpy>=2.0", + "opencv-python-headless>=4.10", + "pyside6>=6.7", +] + +[project.scripts] +noteman-slicer = "noteman_slicer.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/test_pdf.py b/tests/test_pdf.py new file mode 100644 index 0000000..0711c9e --- /dev/null +++ b/tests/test_pdf.py @@ -0,0 +1,82 @@ +"""Runnable check for source classification and raster loading. + +Builds its own PDFs so it needs no corpus files (scores are copyrighted and +gitignored). Run with `python tests/test_pdf.py`. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pymupdf + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from noteman_slicer.pdf import SourceType, open_source, page_raster # noqa: E402 + +A4 = pymupdf.paper_rect("a4") + + +def _vector_pdf(path: Path, pages: int = 2) -> None: + doc = pymupdf.open() + for _ in range(pages): + page = doc.new_page(width=A4.width, height=A4.height) + page.draw_line((50, 100), (A4.width - 50, 100)) + page.insert_text((50, 150), "notation", fontsize=24) + doc.save(path) + + +def _scan_pdf(path: Path, pages: int = 2, w: int = 1653, h: int = 2332) -> None: + """Each page is one full-page grayscale image — what a real scan looks like.""" + art = np.full((h, w), 255, np.uint8) + art[500:505, 100 : w - 100] = 0 # a staff line, so it isn't uniform + pix = pymupdf.Pixmap(pymupdf.csGRAY, w, h, bytearray(art.tobytes()), False) + doc = pymupdf.open() + for _ in range(pages): + page = doc.new_page(width=A4.width, height=A4.height) + page.insert_image(page.rect, pixmap=pix) + doc.save(path) + + +def main() -> int: + tmp = Path(__file__).with_name("_tmp") + tmp.mkdir(exist_ok=True) + vec, scan = tmp / "vector.pdf", tmp / "scan.pdf" + _vector_pdf(vec) + _scan_pdf(scan) + + src = open_source(vec) + assert src.type is SourceType.VECTOR, src.type + assert not src.overridden + page = page_raster(src, 0) + # Rendered at 600 DPI, so an A4 page is ~4960px wide. + assert page.ndim == 2 and page.dtype == np.uint8, (page.ndim, page.dtype) + assert 4900 < page.shape[1] < 5000, page.shape + src.close() + + src = open_source(scan) + assert src.type is SourceType.RASTER, src.type + page = page_raster(src, 0) + # Native resolution of the embedded image, NOT a 600 DPI re-render. + assert page.shape == (2332, 1653), page.shape + assert page.min() == 0 and page.max() == 255, (page.min(), page.max()) + src.close() + + # An override must win over detection, and say so. + src = open_source(scan, SourceType.VECTOR) + assert src.type is SourceType.VECTOR and src.detected is SourceType.RASTER + assert src.overridden + assert page_raster(src, 0).shape[1] > 4000, "override must force a render" + src.close() + + for f in (vec, scan): + f.unlink() + tmp.rmdir() + print("ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3e1c465 --- /dev/null +++ b/uv.lock @@ -0,0 +1,158 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "noteman-slicer" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "pymupdf" }, + { name = "pyside6" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=2.0" }, + { name = "opencv-python-headless", specifier = ">=4.10" }, + { name = "pymupdf", specifier = ">=1.26" }, + { name = "pyside6", specifier = ">=6.7" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7c/8c8097891c509d98cd128493835c95631c80be6a8f37ed9d25716c2e16f1/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f", size = 48322581, upload-time = "2026-07-02T05:50:34.207Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/eab2ad388c3cbab2a350c10c2ef19ce6bd099240afc31789032c996bab52/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00", size = 34782894, upload-time = "2026-07-02T05:51:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/afca939f40ffe2b2380bfa86f812b2f7d4acc5a27b27dc41b49cad7ce7b4/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f", size = 36521085, upload-time = "2026-07-02T06:55:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/2b/97/8170e9819764c47e436c130d3ff6cfb73b58f923eae9d3a03d8982b04aec/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4", size = 56563598, upload-time = "2026-07-02T06:55:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/1a28a7101e31801042b3098871a74b76c61581d328ef40774ff4edb53a56/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4", size = 39648433, upload-time = "2026-07-02T06:56:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/9b/21/f6ef335f6e65724aa78b8d792b48d40a48c381715f1e62f5a5049e09d07e/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37", size = 61204038, upload-time = "2026-07-02T06:56:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8f/b8756467ea991449a293797f6b3fa80fcfdd29598a0a60d1cd5715b96e61/opencv_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9", size = 35411237, upload-time = "2026-07-02T05:50:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, +] + +[[package]] +name = "pymupdf" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/e9/6d6c5d6c0a3551bffd47681a6240caf941727f195b45593cf20ab36f018f/pymupdf-1.28.0.tar.gz", hash = "sha256:e53f3567403a92da15caa9e7ae0164327fff48817e9f40175367fb9de524258d", size = 87637751, upload-time = "2026-06-29T09:08:47.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/b7/88043e38cc7529de070f0c9bd267fa258035cca0b4ad5260536b994594a7/pymupdf-1.28.0-cp310-abi3-macosx_10_15_x86_64.whl", hash = "sha256:892b89ba88e8f98b53133b62877a9dc9b5e7dc6a4aeb837b612db56a8d2e03ac", size = 24597385, upload-time = "2026-06-29T09:03:30.608Z" }, + { url = "https://files.pythonhosted.org/packages/33/f4/23775bbda0781b61fc398cc75079a2b0e64696d8fcf93271748883e9627e/pymupdf-1.28.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:4d692dcf44d3566ae96bc6f6346c6ad432274a29ba617bf7a9fe18009e24adb4", size = 23828292, upload-time = "2026-06-29T09:03:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f5/bf75fc7a415722f8b33662054f82d88520c0cbfd4c36d0e08aeaec605e49/pymupdf-1.28.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:47a5c29ed4eb0744de9c4e37bb49b1259b18d4d75fcc8a7c130f7c9fa15956f6", size = 25045507, upload-time = "2026-06-29T09:04:03.86Z" }, + { url = "https://files.pythonhosted.org/packages/58/69/5d12c9f1f2d76f28383d6110a069c79fbfced5a4f97bb1ee6e8354f52bb7/pymupdf-1.28.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:44f0973f5e5edbaec95bc34b64e71d1959d4ee90b1328de1b4f4f5b4fa78673f", size = 25716599, upload-time = "2026-06-29T09:04:19.367Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b4/ec0e017bc42857cc86bd651441dbc41cc18be48d4698ecd27aac491e0c9a/pymupdf-1.28.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4d61ec323a706e153a12e262e51febfb43eeaa20977785ace135d18d48bcdc83", size = 25940489, upload-time = "2026-06-29T09:04:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/06/86/f831fef09013f33b3c9c09fb3923f2ff53e1e437f6ace14b8ae46392f558/pymupdf-1.28.0-cp310-abi3-win32.whl", hash = "sha256:caea2b3b67347fd79e5d15ed7929b0e886aac594ea228073b6d39de0078189da", size = 18489703, upload-time = "2026-06-29T20:50:30.599Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5d/1a03f53eb0449900469335fcfc742ca28e3ba159b7d650e0921d50b8b308/pymupdf-1.28.0-cp310-abi3-win_amd64.whl", hash = "sha256:e01e90fd86abfeb37ceb921eddb951f988a11d45ff6ce6b7664f2039849068ec", size = 19773102, upload-time = "2026-06-29T09:04:49.773Z" }, + { url = "https://files.pythonhosted.org/packages/72/f6/1e52ce243ca792254f6223b4017c5667194c146ce9b88baf37bc5eb3d1c9/pymupdf-1.28.0-cp313-abi3-pyemscripten_2025_0_wasm32.whl", hash = "sha256:74c6d00ba2a9aad3a635db73b07c15db462b480741d831a34a75a56535ebc22b", size = 18357011, upload-time = "2026-06-29T20:50:50.353Z" }, + { url = "https://files.pythonhosted.org/packages/62/b1/46b5b3d8ef3cc71114667cf10c4d8b33f39af97253af32e9a0986775b638/pymupdf-1.28.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:b3e1399c7a64c6914239116a369efcdaac4cfb9e838bde2656d7accc4a85c72d", size = 25753599, upload-time = "2026-06-29T09:05:09.398Z" }, +] + +[[package]] +name = "pyside6" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-addons" }, + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/d8/de/af89d71410c83b10654d86ff9aff2a4f87c30163658f1cc145242e222526/pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2", size = 572102, upload-time = "2026-05-13T09:47:38.249Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0e/d583bd3f7bf5046a4497b36f3902cfb64aa29554489a5a25c18e6b4ac0ac/pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84", size = 572098, upload-time = "2026-05-13T09:47:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/57/f2/d9d8ce1373dabb37e5919f63cd18446556079631d3f2eea3ada03c29f6b8/pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6", size = 578377, upload-time = "2026-05-13T09:47:40.76Z" }, + { url = "https://files.pythonhosted.org/packages/96/02/a6057d8bd2bdb1940820fff2d627fdf4013148c9c57adf69fa40d3452ac3/pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805", size = 561765, upload-time = "2026-05-13T09:47:41.9Z" }, +] + +[[package]] +name = "pyside6-addons" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/fb1428a523b2a4541e232aab50d9e789e6b4526f37fd9593452a7ea5b6b3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f", size = 175063224, upload-time = "2026-05-13T09:39:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9b/2ccd52f66db55c06de65d0501170a1935d04d64d0a230c0d892284a02ce3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993", size = 170553429, upload-time = "2026-05-13T09:39:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bd/8adc4d350b3b363f3dfc8fccdcf5bfed25f7e36c2fff30c64e106f4f1572/pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923", size = 168816308, upload-time = "2026-05-13T09:40:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/65/b7/9a840d97f0f0f04e372a87e205dd30ee285b4e3b021b188459a917c9dc76/pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37", size = 35759347, upload-time = "2026-05-13T09:40:21.199Z" }, +] + +[[package]] +name = "pyside6-essentials" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60", size = 79908535, upload-time = "2026-05-13T09:43:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/da4c5f23c6540ac5211a1f60177c8dee84b1bf40f2719479587ab8c60731/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d", size = 78960051, upload-time = "2026-05-13T09:43:35.423Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/b663ecc96ca57b5c91b83b6615d6b174380b0faf30338125c26e053d6aa7/pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40", size = 77549267, upload-time = "2026-05-13T09:43:44.92Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/eb6723faf5cb7fa581145da1c15f40d641b96e080f0491af2f1859fdeedb/pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f", size = 57964512, upload-time = "2026-05-13T09:43:52.968Z" }, +] + +[[package]] +name = "shiboken6" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/f3/f2b63df0251e7cd3172ea28e32ede52739de9566bcefcd0178681538ac81/shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02", size = 476874, upload-time = "2026-05-13T09:47:01.091Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe", size = 272222, upload-time = "2026-05-13T09:47:02.653Z" }, + { url = "https://files.pythonhosted.org/packages/57/d5/dd4f1defed400be03340f2ede34b61f846776650b4e7ed9ebaf4c71979a2/shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f", size = 270350, upload-time = "2026-05-13T09:47:04.02Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/3f6fb2ee65b534193fb4ef713dd619dc31dadff5d12c16979a7699ad58be/shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7", size = 1223647, upload-time = "2026-05-13T09:47:05.924Z" }, + { url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" }, +] From 6ff956a6226fd4b3bc3a0ea3c9dd66ebb71cac07 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 28 Jul 2026 22:38:50 +0300 Subject: [PATCH 4/6] Add detection: skew, system anchors, ink extents, cuts, staff height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Systems anchor on the vertical bracket and expand over nearby ink, per ADR 0006. Two corrections the corpus forced: - Expansion measures distance from the bracket, never from the growing extent. A chaining expansion hops between the closely-stacked lines of a title block and walks the whole way up the page — on Engel p1 it swallowed the title into system 1 and the copyright footer into system 3. - Reach is 1.5 staff heights, which takes in the lyrics below the last staff and the tempo mark and INTRO box above the first, while leaving the title block and footer out. Verified against the corpus. Ketun joululaulu: 2 systems per page except p7 (3) and p12 (1), staff 48px throughout, skew -2.7 to +1.2 per page, and p2 system 1 spans 179-1071 where its bracket is 177-994 — the difference being the bottom voice's lyric line. Engel: 2-3 systems per page, staff 70-72px. Elaman nalka: 3 systems per page, 0 skew, staff 118px at 600 DPI. The overlay dump is what made both bugs visible, and stays as the tool for diagnosing a page that comes out wrong. Closes #4, #5, #6, #7, #8, #9 --- noteman_slicer/cli.py | 39 ++++++- noteman_slicer/detect.py | 221 ++++++++++++++++++++++++++++++++++++++ noteman_slicer/overlay.py | 55 ++++++++++ tests/test_detect.py | 86 +++++++++++++++ 4 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 noteman_slicer/detect.py create mode 100644 noteman_slicer/overlay.py create mode 100644 tests/test_detect.py diff --git a/noteman_slicer/cli.py b/noteman_slicer/cli.py index c86f585..2aaa345 100644 --- a/noteman_slicer/cli.py +++ b/noteman_slicer/cli.py @@ -5,7 +5,12 @@ from __future__ import annotations import argparse import sys -from . import __version__ +import numpy as np + +from pathlib import Path + +from . import __version__, overlay +from .detect import detect_page from .pdf import SourceType, open_source, page_raster @@ -20,6 +25,31 @@ def _info(args: argparse.Namespace) -> int: return 0 +def _detect(args: argparse.Namespace) -> int: + source = open_source(args.pdf, SourceType(args.type) if args.type else None) + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + pages = range(len(source)) if args.page is None else [args.page - 1] + + for i in pages: + gray = page_raster(source, i) + detection = detect_page(gray) + staves = [s.staff_height for s in detection.systems if s.staff_height] + note = f", staff {np.median(staves):.0f}px" if staves else "" + print( + f"p{i + 1:<3} skew {detection.skew:+.2f}° " + f"{len(detection.systems)} systems{note}" + f"{' (no bracket)' if detection.bracketless else ''}" + ) + for n, system in enumerate(detection.systems, 1): + print(f" sys{n}: {system.top}–{system.bottom} h={system.height}") + overlay.write(gray, detection, out / f"{source.path.stem}-p{i + 1:02}.png") + + print(f"overlays written to {out}/") + source.close() + return 0 + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="noteman-slicer", @@ -37,6 +67,13 @@ def main(argv: list[str] | None = None) -> int: ) info.set_defaults(func=_info) + det = sub.add_parser("detect", help="run detection and write debug overlays") + det.add_argument("pdf") + det.add_argument("--out", default="overlays", help="output directory") + det.add_argument("--page", type=int, help="single 1-based page instead of all") + det.add_argument("--type", choices=[t.value for t in SourceType]) + det.set_defaults(func=_detect) + args = parser.parse_args(argv) return args.func(args) diff --git a/noteman_slicer/detect.py b/noteman_slicer/detect.py new file mode 100644 index 0000000..f59128b --- /dev/null +++ b/noteman_slicer/detect.py @@ -0,0 +1,221 @@ +"""Detection: skew, systems, cuts, staff height. + +Everything here is a *suggestion* the user confirms or edits (ADR 0004). +Nothing downstream may assume a result is right. + +Systems are anchored on the vertical bracket that spans their staves, not on +gaps in the row-darkness profile: a row profile cannot tell an inter-staff gap +from an inter-system gap, and gets the count wrong on every page of a +multi-voice choral score (ADR 0006). The row profile is still needed, to expand +each anchor to its true ink extent — a bracket stops at the last staff line, +but the slice must include the lyrics printed below it. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import cv2 +import numpy as np + +SKEW_LIMIT_DEG = 5.0 +SKEW_COARSE_STEP = 1.0 +SKEW_FINE_STEP = 0.1 +_SKEW_WORK_SCALE = 0.25 + +_INK = 128 # below this is ink, above is paper +_ANCHOR_KERNEL = 0.03 # vertical open kernel, as a fraction of page height +_ANCHOR_MIN = 0.04 # a bracket is at least this tall, as a fraction of page +_PROFILE_FLOOR = 0.02 # ink-run threshold, as a fraction of the profile peak +_EXPAND_REACH = 1.5 # how far past the bracket a system's ink reaches, in staff heights + + +@dataclass +class System: + """One line of music: the ink extent that becomes a slice.""" + + top: int + bottom: int + staff_height: float | None = None + + @property + def height(self) -> int: + return self.bottom - self.top + + +@dataclass +class PageDetection: + skew: float + systems: list[System] = field(default_factory=list) + cuts: list[int] = field(default_factory=list) + + @property + def bracketless(self) -> bool: + """True when no bracket was found and the row profile was used alone.""" + return not self.systems or all(s.staff_height is None for s in self.systems) + + +def row_darkness(gray: np.ndarray) -> np.ndarray: + return (255 - gray.astype(np.float32)).sum(axis=1) + + +def deskew_angle(gray: np.ndarray) -> float: + """Angle maximising row-darkness variance — staff lines are the signal. + + Coarse then fine, on a downscaled copy: 31 warps instead of 101. + """ + work = cv2.resize(gray, None, fx=_SKEW_WORK_SCALE, fy=_SKEW_WORK_SCALE, + interpolation=cv2.INTER_AREA) + + def score(angle: float) -> float: + return float(row_darkness(_rotate(work, angle, cv2.INTER_LINEAR)).var()) + + coarse = np.arange(-SKEW_LIMIT_DEG, SKEW_LIMIT_DEG + 1e-9, SKEW_COARSE_STEP) + best = max(coarse, key=score) + fine = np.arange(best - SKEW_COARSE_STEP, best + SKEW_COARSE_STEP + 1e-9, SKEW_FINE_STEP) + fine = fine[np.abs(fine) <= SKEW_LIMIT_DEG] + return round(float(max(fine, key=score)), 2) + + +def _rotate(gray: np.ndarray, angle: float, flags: int = cv2.INTER_CUBIC) -> np.ndarray: + if angle == 0.0: + return gray + h, w = gray.shape + m = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0) + return cv2.warpAffine(gray, m, (w, h), flags=flags, borderValue=255) + + +def deskew(gray: np.ndarray, angle: float) -> np.ndarray: + return _rotate(gray, angle) + + +def system_anchors(gray: np.ndarray) -> list[tuple[int, int]]: + """y-extents of the vertical brackets, one per system.""" + h = gray.shape[0] + binary = (gray < _INK).astype(np.uint8) + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(3, int(h * _ANCHOR_KERNEL)))) + strokes = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) + + count, _, stats, _ = cv2.connectedComponentsWithStats(strokes, 8) + tall = [ + (stats[i, cv2.CC_STAT_TOP], stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT]) + for i in range(1, count) + if stats[i, cv2.CC_STAT_HEIGHT] > h * _ANCHOR_MIN + ] + + # Tallest first, keeping only strokes that don't overlap one already kept: + # a system's barlines all overlap its bracket, so each system yields one. + anchors: list[tuple[int, int]] = [] + for top, bottom in sorted(tall, key=lambda s: s[1] - s[0], reverse=True): + if any(not (bottom < a[0] or top > a[1]) for a in anchors): + continue + anchors.append((top, bottom)) + return sorted(anchors) + + +def ink_runs(gray: np.ndarray) -> list[tuple[int, int]]: + """Rows containing ink, despeckled — specks are the known failure mode.""" + profile = row_darkness(cv2.medianBlur(gray, 3)) + if profile.max() <= 0: + return [] + inked = profile > profile.max() * _PROFILE_FLOOR + + runs: list[tuple[int, int]] = [] + start: int | None = None + for i, on in enumerate(inked): + if on and start is None: + start = i + elif not on and start is not None: + runs.append((start, i)) + start = None + if start is not None: + runs.append((start, len(inked))) + return runs + + +def staff_height(gray: np.ndarray, top: int, bottom: int) -> float | None: + """Distance between a staff's outer lines, from staff-line spacing.""" + profile = row_darkness(gray[top:bottom]) + if profile.size == 0 or profile.max() <= 0: + return None + peaks = np.where(profile > profile.max() * 0.55)[0] + if peaks.size < 2: + return None + + centres = [] + run = [peaks[0]] + for prev, cur in zip(peaks, peaks[1:]): + if cur - prev > 3: + centres.append(float(np.mean(run))) + run = [] + run.append(cur) + centres.append(float(np.mean(run))) + if len(centres) < 2: + return None + + gaps = np.diff(centres) + # Keep intra-staff gaps; the big ones are the spaces between staves. + intra = gaps[gaps < np.median(gaps) * 2] + if intra.size == 0: + return None + return float(np.median(intra) * 4) # 5 lines, 4 spaces + + +def _expand( + runs: list[tuple[int, int]], + top: int, + bottom: int, + max_gap: float, + others: list[tuple[int, int]], +) -> tuple[int, int]: + """Grow a bracket span over the ink close to it. + + Lyrics printed below the last staff, and the tempo mark or section label + printed above the first, sit within about a staff height of the bracket and + get absorbed. A title block or a copyright footer is far further away and + does not. + + Distance is measured from the *bracket*, never from the growing extent: a + title block's credit lines are stacked closely enough that a chaining + expansion hops from one to the next and walks the whole way up the page. + """ + lo, hi = top, bottom + for start, end in runs: + if any(end > o[0] and start < o[1] for o in others): + continue # belongs to a different system + if end > top and start < bottom: # overlaps the bracket itself + lo, hi = min(lo, start), max(hi, end) + elif end <= top and top - end <= max_gap: + lo = min(lo, start) + elif start >= bottom and start - bottom <= max_gap: + hi = max(hi, end) + return lo, hi + + +def detect_page(gray: np.ndarray, skew: float | None = None) -> PageDetection: + """Full proposal for one page raster. `gray` is the *unrotated* page.""" + angle = deskew_angle(gray) if skew is None else skew + straight = deskew(gray, angle) + + runs = ink_runs(straight) + anchors = system_anchors(straight) + + if anchors: + systems = [] + for i, (top, bottom) in enumerate(anchors): + # Staff height is measured on the bracket span, before expansion, + # so a swallowed title block can't distort it. + height = staff_height(straight, top, bottom) + others = [a for j, a in enumerate(anchors) if j != i] + reach = (height or gray.shape[0] * 0.02) * _EXPAND_REACH + lo, hi = _expand(runs, top, bottom, reach, others) + systems.append(System(top=lo, bottom=hi, staff_height=height)) + else: + # No bracket: a single-staff melody or lead sheet, where every ink run + # genuinely is its own system. + systems = [System(top=t, bottom=b) for t, b in runs] + + cuts = [ + (systems[i].bottom + systems[i + 1].top) // 2 for i in range(len(systems) - 1) + ] + return PageDetection(skew=angle, systems=systems, cuts=cuts) diff --git a/noteman_slicer/overlay.py b/noteman_slicer/overlay.py new file mode 100644 index 0000000..e221408 --- /dev/null +++ b/noteman_slicer/overlay.py @@ -0,0 +1,55 @@ +"""Debug overlay: what detection proposed, drawn on the page. + +The fastest way to judge a detection change, and the tool for working out why +song #40 came out wrong. Kept after release for that reason. +""" + +from __future__ import annotations + +from pathlib import Path + +import cv2 +import numpy as np + +from .detect import PageDetection + +_SYSTEM = (0, 160, 0) +_CUT = (0, 0, 255) +_PROFILE = (220, 120, 0) +_PREVIEW_WIDTH = 1100 + + +def draw(gray: np.ndarray, detection: PageDetection) -> np.ndarray: + """Straightened page with systems boxed, cuts lined, row profile down the side.""" + from .detect import deskew, row_darkness + + straight = deskew(gray, detection.skew) + vis = cv2.cvtColor(straight, cv2.COLOR_GRAY2BGR) + h, w = straight.shape + thickness = max(1, w // 700) + + profile = row_darkness(straight) + if profile.max() > 0: + scaled = (profile / profile.max() * (w * 0.08)).astype(int) + for y in range(0, h, max(1, h // 900)): + cv2.line(vis, (0, y), (int(scaled[y]), y), _PROFILE, 1) + + for i, system in enumerate(detection.systems): + cv2.rectangle(vis, (2, system.top), (w - 3, system.bottom), _SYSTEM, thickness) + label = f"{i + 1}" + if system.staff_height: + label += f" staff {system.staff_height:.0f}px" + cv2.putText(vis, label, (int(w * 0.10), system.top + int(h * 0.02)), + cv2.FONT_HERSHEY_SIMPLEX, w / 1400, _SYSTEM, thickness) + + for y in detection.cuts: + cv2.line(vis, (0, y), (w, y), _CUT, thickness) + + return vis + + +def write(gray: np.ndarray, detection: PageDetection, path: Path) -> Path: + vis = draw(gray, detection) + height = int(vis.shape[0] * _PREVIEW_WIDTH / vis.shape[1]) + cv2.imwrite(str(path), cv2.resize(vis, (_PREVIEW_WIDTH, height), interpolation=cv2.INTER_AREA)) + return path diff --git a/tests/test_detect.py b/tests/test_detect.py new file mode 100644 index 0000000..7d13706 --- /dev/null +++ b/tests/test_detect.py @@ -0,0 +1,86 @@ +"""Runnable check for detection, on a synthetic page. + +Draws the structure that matters — a bracket per system, staves, lyrics close +below, and a title and footer far away — so the check is about the algorithm +rather than about any one scan. Run with `python tests/test_detect.py`. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from noteman_slicer.detect import deskew, deskew_angle, detect_page # noqa: E402 + +W, H = 1000, 1400 +STAFF_GAP = 15 # → staff height 60, so expansion reaches 90px past a bracket + + +def _system(page: np.ndarray, top: int) -> tuple[int, int]: + """Two staves joined by a bracket, with a lyric line below. Returns its span.""" + bottom = top + 200 + page[top:bottom, 100:104] = 0 # the bracket + for staff_top in (top, top + 140): + for i in range(5): + y = staff_top + i * STAFF_GAP + page[y : y + 2, 110:900] = 0 + page[staff_top + 90 : staff_top + 105, 200:800] = 0 # lyrics under the staff + return top, bottom + + +def _page() -> np.ndarray: + page = np.full((H, W), 255, np.uint8) + page[50:70, 300:700] = 0 # title, far above system 1 + _system(page, 200) + _system(page, 700) + page[1350:1365, 100:600] = 0 # footer, far below system 2 + return page + + +def main() -> int: + page = _page() + + det = detect_page(page) + assert len(det.systems) == 2, f"expected 2 systems, got {len(det.systems)}" + assert len(det.cuts) == 1, det.cuts + + first, second = det.systems + # The bracket spans 200–400; the lyric line under the lower staff reaches + # ~445 and must be absorbed. + assert first.top == 200, first.top + assert 400 < first.bottom < 500, first.bottom + assert second.top == 700, second.top + + # The title and footer are far from any bracket and must not be swallowed — + # the bug that a chaining expansion reintroduces. + assert first.top > 70, "title block was swallowed" + assert second.bottom < 1350, "footer was swallowed" + + # The cut falls between the two systems, in the whitespace. + assert first.bottom < det.cuts[0] < second.top, det.cuts + + assert first.staff_height is not None + assert abs(first.staff_height - STAFF_GAP * 4) < STAFF_GAP, first.staff_height + + # Skew is recovered to within one fine step. + for angle in (-1.5, 0.8): + found = deskew_angle(deskew(page, angle)) + assert abs(found + angle) <= 0.15, f"skew {angle}: got {found}" + + # No brackets: every ink run is its own system. + bare = np.full((H, W), 255, np.uint8) + for y in (200, 500, 800): + bare[y : y + 20, 100:900] = 0 + assert len(detect_page(bare).systems) == 3 + assert detect_page(bare).bracketless + + print("ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 61dac44037538a46eed5d6ee2ad4f17a97c1877e Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 28 Jul 2026 22:46:27 +0300 Subject: [PATCH 5/6] Give each ink run a single owner, by precedence not proximity Feliz Navidad exposed two assignment faults. Each bracket was expanding independently, so a lyric line between two systems could be claimed by both, producing overlapping extents. And nearest-bracket is the wrong rule: engravers space lyrics generously, so a line sits 43px under its own system's bracket but only 10px above the next one's. Now one pass assigns every run exactly once: ink overlapping a bracket belongs to it, and otherwise the system above wins over the system below. Text under a staff belongs to that staff. Known limit, documented in _assign: where a lyric is printed tight enough that no blank row separates it from the next system's staves, the two fuse into one ink run and no row profile can split them. The lyric goes to the system below and the cut lands ~90px high. Dragging it is the fix. Feliz Navidad now reads 5 systems on every page, staff 70px, with no overlapping extents; Ketun joululaulu, Engel and Elaman nalka are unchanged. --- noteman_slicer/detect.py | 98 +++++++++++++++++++++++++++------------- 1 file changed, 66 insertions(+), 32 deletions(-) diff --git a/noteman_slicer/detect.py b/noteman_slicer/detect.py index f59128b..23d1102 100644 --- a/noteman_slicer/detect.py +++ b/noteman_slicer/detect.py @@ -161,35 +161,70 @@ def staff_height(gray: np.ndarray, top: int, bottom: int) -> float | None: return float(np.median(intra) * 4) # 5 lines, 4 spaces -def _expand( +def _gap(run: tuple[int, int], span: tuple[int, int]) -> int: + """Vertical distance between an ink run and a bracket span; 0 if they overlap.""" + start, end = run + top, bottom = span + if end > top and start < bottom: + return 0 + return top - end if end <= top else start - bottom + + +def _assign( runs: list[tuple[int, int]], - top: int, - bottom: int, - max_gap: float, - others: list[tuple[int, int]], -) -> tuple[int, int]: - """Grow a bracket span over the ink close to it. + anchors: list[tuple[int, int]], + reaches: list[float], +) -> list[tuple[int, int]]: + """Give every ink run to one system, and return each system's extent. - Lyrics printed below the last staff, and the tempo mark or section label - printed above the first, sit within about a staff height of the bracket and - get absorbed. A title block or a copyright footer is far further away and - does not. + A run between two systems is resolved by **precedence, not proximity**: the + system above wins if the run is within its reach. Text printed under a staff + belongs to that staff, and engravers space lyrics generously — on *Feliz + Navidad* a lyric line sits 43px under its own system's bracket but only 10px + above the next one's, so nearest-bracket gives it to the wrong system. - Distance is measured from the *bracket*, never from the growing extent: a + Distance is measured from the *bracket*, never from a growing extent — a title block's credit lines are stacked closely enough that a chaining expansion hops from one to the next and walks the whole way up the page. + + One pass over all systems, rather than each bracket expanding on its own, so + that a run has exactly one owner and extents cannot overlap. + + Known limit: when a lyric line is printed tight enough under its system that + no blank row separates it from the *next* system's staves, the two fuse into + a single ink run and no row profile can split them — the lyric is then given + to the system below and the cut lands high. Dragging the cut is the fix; + separating them needs a signal this pass doesn't have. """ - lo, hi = top, bottom - for start, end in runs: - if any(end > o[0] and start < o[1] for o in others): - continue # belongs to a different system - if end > top and start < bottom: # overlaps the bracket itself - lo, hi = min(lo, start), max(hi, end) - elif end <= top and top - end <= max_gap: - lo = min(lo, start) - elif start >= bottom and start - bottom <= max_gap: - hi = max(hi, end) - return lo, hi + bounds = [list(a) for a in anchors] + + def claim(index: int, run: tuple[int, int]) -> None: + bounds[index][0] = min(bounds[index][0], run[0]) + bounds[index][1] = max(bounds[index][1], run[1]) + + for run in runs: + gaps = [_gap(run, a) for a in anchors] + + # Ink overlapping a bracket belongs to it — to the one it overlaps most, + # whatever else is in reach. + inside = [ + (min(run[1], anchors[i][1]) - max(run[0], anchors[i][0]), i) + for i, g in enumerate(gaps) + if g == 0 + ] + if inside: + claim(max(inside)[1], run) + continue + + within = [i for i, g in enumerate(gaps) if g <= reaches[i]] + if not within: + continue # a title block or a footer: too far from any system + + # Otherwise the system above wins, and only failing that the one below. + above = [i for i in within if anchors[i][1] <= run[0]] + claim(above[-1] if above else within[0], run) + + return [(lo, hi) for lo, hi in bounds] def detect_page(gray: np.ndarray, skew: float | None = None) -> PageDetection: @@ -201,15 +236,14 @@ def detect_page(gray: np.ndarray, skew: float | None = None) -> PageDetection: anchors = system_anchors(straight) if anchors: - systems = [] - for i, (top, bottom) in enumerate(anchors): - # Staff height is measured on the bracket span, before expansion, - # so a swallowed title block can't distort it. - height = staff_height(straight, top, bottom) - others = [a for j, a in enumerate(anchors) if j != i] - reach = (height or gray.shape[0] * 0.02) * _EXPAND_REACH - lo, hi = _expand(runs, top, bottom, reach, others) - systems.append(System(top=lo, bottom=hi, staff_height=height)) + # Staff height is measured on the bracket span, before expansion, so a + # swallowed title block can't distort it. + heights = [staff_height(straight, top, bottom) for top, bottom in anchors] + reaches = [(h or gray.shape[0] * 0.02) * _EXPAND_REACH for h in heights] + systems = [ + System(top=lo, bottom=hi, staff_height=h) + for (lo, hi), h in zip(_assign(runs, anchors, reaches), heights) + ] else: # No bracket: a single-staff melody or lead sheet, where every ink run # genuinely is its own system. From 9ed38323ef89ebef603bf2f7bc53e4014d51fe06 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 28 Jul 2026 22:49:51 +0300 Subject: [PATCH 6/6] Add project state with cuts, discards and atomic save Everything the human decides, in normalised page coordinates so the file is independent of DPI and of which renderer produced it. The bundle will be generated from this, which is what makes re-export possible without repeating human work. Cuts are polylines from the start, two points being the ordinary straight case, so the stepped cuts Engel needs are a data question rather than a migration. Adding a cut splits a slice and copies its discard flag to both halves; removing one merges them. Boundary cuts belong here rather than in detection: detection emits cuts only between systems, so a page would have exactly as many slices as systems, with the header and footer inside the first and last. Isolating and discarding them is a slicing decision. Saves are write-then-rename, so a crash mid-save cannot destroy the previous state. The PDF is hashed, not copied, so an edit underneath is reported rather than silently re-cut. Ketun joululaulu now yields 24 kept slices over 12 pages and Feliz Navidad 20 over 4, with headers and footers discarded on every page. Closes #10, #11, #13 --- noteman_slicer/cli.py | 39 ++++++ noteman_slicer/project.py | 241 ++++++++++++++++++++++++++++++++++++++ tests/test_project.py | 82 +++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 noteman_slicer/project.py create mode 100644 tests/test_project.py diff --git a/noteman_slicer/cli.py b/noteman_slicer/cli.py index 2aaa345..2f804ff 100644 --- a/noteman_slicer/cli.py +++ b/noteman_slicer/cli.py @@ -50,6 +50,38 @@ def _detect(args: argparse.Namespace) -> int: return 0 +def _project(args: argparse.Namespace) -> int: + from .project import Project, default_path + + source = open_source(args.pdf, SourceType(args.type) if args.type else None) + path = default_path(source.path) + + if path.exists() and not args.force: + project = Project.load(path) + print(f"{path.name}: loaded") + if project.source_changed(): + print(" WARNING: the PDF has changed since these cuts were made") + else: + detections, heights = [], [] + for i in range(len(source)): + gray = page_raster(source, i) + detections.append(detect_page(gray)) + heights.append(gray.shape[0]) + project = Project.from_detection(source.path, detections, heights) + print(f"{path.name}: created from detection") + + kept = project.kept_slices() + for i, page in enumerate(project.pages): + flags = "".join("." if d else "#" for d in page.discards) + print(f" p{i + 1:<3} skew {page.skew:+.2f}° {page.slice_count} slices [{flags}]") + print(f" {len(kept)} slices kept, {sum(p.slice_count for p in project.pages) - len(kept)} discarded") + + if args.save: + print(f" saved to {project.save(path)}") + source.close() + return 0 + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="noteman-slicer", @@ -74,6 +106,13 @@ def main(argv: list[str] | None = None) -> int: det.add_argument("--type", choices=[t.value for t in SourceType]) det.set_defaults(func=_detect) + proj = sub.add_parser("project", help="create or inspect the project file for a PDF") + proj.add_argument("pdf") + proj.add_argument("--save", action="store_true", help="write the project file") + proj.add_argument("--force", action="store_true", help="re-detect, discarding existing state") + proj.add_argument("--type", choices=[t.value for t in SourceType]) + proj.set_defaults(func=_project) + args = parser.parse_args(argv) return args.func(args) diff --git a/noteman_slicer/project.py b/noteman_slicer/project.py new file mode 100644 index 0000000..3375cca --- /dev/null +++ b/noteman_slicer/project.py @@ -0,0 +1,241 @@ +"""Project state: everything the human decided, on disk beside the PDF. + +The bundle is generated from this, so export is a pure function of the project +plus the PDF. That buys crash safety, resume across sessions, and re-export — +change the width cap or fix one cut and every song regenerates without +repeating any human work. + +All geometry is stored in **normalised page coordinates** (0–1 of the deskewed +page), so the file is independent of DPI and of which renderer produced it. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from pathlib import Path + +from .detect import PageDetection + +FORMAT_VERSION = 1 +SUFFIX = ".slicer.json" + +Point = tuple[float, float] + + +@dataclass +class Cut: + """A boundary splitting one slice into two, spanning the page left to right. + + A polyline, not a line. Two points is the ordinary straight case; extra + vertices handle a section label printed in the left margin at the same + height as the previous system's lyrics, where no horizontal line separates + the two (see docs/spec.md). + """ + + points: list[Point] + + @classmethod + def straight(cls, y: float) -> Cut: + return cls([(0.0, y), (1.0, y)]) + + @property + def straight_y(self) -> float | None: + """The single y of a straight cut, or None if it steps.""" + ys = {y for _, y in self.points} + return self.points[0][1] if len(ys) == 1 else None + + def y_at(self, x: float) -> float: + """Height of the boundary at a horizontal position.""" + pts = self.points + if x <= pts[0][0]: + return pts[0][1] + for (x0, y0), (x1, y1) in zip(pts, pts[1:]): + if x <= x1: + if x1 == x0: + return y1 + return y0 + (y1 - y0) * (x - x0) / (x1 - x0) + return pts[-1][1] + + @property + def lowest(self) -> float: + return max(y for _, y in self.points) + + @property + def highest(self) -> float: + return min(y for _, y in self.points) + + +@dataclass +class Page: + """One page's decisions. `cuts` are ordered top to bottom.""" + + skew: float = 0.0 + cuts: list[Cut] = field(default_factory=list) + discards: list[bool] = field(default_factory=lambda: [False]) + content_rect: tuple[float, float, float, float] | None = None + levels: tuple[int, int] | None = None + + @property + def slice_count(self) -> int: + return len(self.cuts) + 1 + + def bounds(self, index: int) -> tuple[Cut | None, Cut | None]: + """The cuts above and below a slice; None means the page edge.""" + above = self.cuts[index - 1] if index > 0 else None + below = self.cuts[index] if index < len(self.cuts) else None + return above, below + + def add_cut(self, cut: Cut) -> int: + """Insert a cut, splitting the slice it lands in. Returns its index.""" + y = cut.points[0][1] + index = sum(1 for c in self.cuts if c.points[0][1] < y) + self.cuts.insert(index, cut) + # The split slice keeps its flag on both halves. + self.discards.insert(index, self.discards[index]) + return index + + def remove_cut(self, index: int) -> None: + """Drop a cut, merging the two slices it separated.""" + self.cuts.pop(index) + merged = self.discards[index] and self.discards[index + 1] + self.discards.pop(index + 1) + self.discards[index] = merged + + +@dataclass +class Project: + source: Path + source_hash: str + pages: list[Page] + content_rect: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0) + levels: tuple[int, int] = (0, 255) + metadata: dict[str, str] = field(default_factory=dict) + path: Path | None = None + + # -- geometry helpers ------------------------------------------------- + + def page_content_rect(self, index: int) -> tuple[float, float, float, float]: + return self.pages[index].content_rect or self.content_rect + + def page_levels(self, index: int) -> tuple[int, int]: + return self.pages[index].levels or self.levels + + def kept_slices(self) -> list[tuple[int, int]]: + """(page, slice) of every slice that will be exported, in song order.""" + return [ + (p, s) + for p, page in enumerate(self.pages) + for s in range(page.slice_count) + if not page.discards[s] + ] + + # -- persistence ------------------------------------------------------ + + @classmethod + def from_detection( + cls, source: Path, detections: list[PageDetection], heights: list[int] + ) -> Project: + """Seed a project from detection. Every value here is a suggestion. + + Detection emits cuts only *between* systems, so a page would otherwise + have exactly as many slices as it has systems, with the header and + footer inside the first and last. The boundary cuts that isolate them — + and the discard flags that drop them — are a slicing decision, not a + detection result, so they are added here. + """ + pages = [] + for detection, height in zip(detections, heights): + ys = list(detection.cuts) + leading = trailing = False + + if detection.systems: + first, last = detection.systems[0], detection.systems[-1] + if first.top > 0: + ys.insert(0, first.top // 2) + leading = True + if last.bottom < height: + ys.append((last.bottom + height) // 2) + trailing = True + + discards = [False] * (len(ys) + 1) + if leading: + discards[0] = True + if trailing: + discards[-1] = True + + pages.append( + Page( + skew=detection.skew, + cuts=[Cut.straight(y / height) for y in ys], + discards=discards, + ) + ) + return cls(source=source, source_hash=hash_file(source), pages=pages) + + def save(self, path: Path | None = None) -> Path: + """Atomic write, so a crash mid-save cannot destroy the previous state.""" + target = Path(path or self.path or default_path(self.source)) + payload = { + "v": FORMAT_VERSION, + "source": self.source.name, + "source_hash": self.source_hash, + "content_rect": list(self.content_rect), + "levels": list(self.levels), + "metadata": self.metadata, + "pages": [ + { + "skew": page.skew, + "cuts": [[list(p) for p in cut.points] for cut in page.cuts], + "discards": page.discards, + "content_rect": list(page.content_rect) if page.content_rect else None, + "levels": list(page.levels) if page.levels else None, + } + for page in self.pages + ], + } + tmp = target.with_suffix(target.suffix + ".tmp") + tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False)) + tmp.replace(target) + self.path = target + return target + + @classmethod + def load(cls, path: Path, source: Path | None = None) -> Project: + path = Path(path) + data = json.loads(path.read_text()) + if data.get("v") != FORMAT_VERSION: + raise ValueError(f"unsupported project version {data.get('v')!r}") + pdf = Path(source) if source else path.parent / data["source"] + pages = [ + Page( + skew=page["skew"], + cuts=[Cut([tuple(p) for p in cut]) for cut in page["cuts"]], + discards=page["discards"], + content_rect=tuple(page["content_rect"]) if page["content_rect"] else None, + levels=tuple(page["levels"]) if page["levels"] else None, + ) + for page in data["pages"] + ] + return cls( + source=pdf, + source_hash=data["source_hash"], + pages=pages, + content_rect=tuple(data["content_rect"]), + levels=tuple(data["levels"]), + metadata=data.get("metadata", {}), + path=path, + ) + + def source_changed(self) -> bool: + """True when the PDF no longer matches what these decisions were made on.""" + return self.source.exists() and hash_file(self.source) != self.source_hash + + +def default_path(source: Path) -> Path: + return Path(source).with_suffix(SUFFIX) + + +def hash_file(path: Path) -> str: + return hashlib.sha256(Path(path).read_bytes()).hexdigest() diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..9c93497 --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,82 @@ +"""Runnable check for project state: round-trip, cut edits, discard pre-set.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from noteman_slicer.detect import PageDetection, System # noqa: E402 +from noteman_slicer.project import Cut, Project, default_path # noqa: E402 + + +def main() -> int: + tmp = Path(__file__).with_name("_tmp") + tmp.mkdir(exist_ok=True) + pdf = tmp / "song.pdf" + pdf.write_bytes(b"%PDF-1.7 not really a pdf, only its bytes are hashed") + + height = 1000 + detection = PageDetection( + skew=-1.1, + systems=[System(200, 400, 60.0), System(600, 800, 60.0)], + cuts=[500], + ) + project = Project.from_detection(pdf, [detection], [height]) + page = project.pages[0] + + # One cut between the systems, plus a boundary cut above the first and + # below the last — so the header and footer become their own slices. + assert len(page.cuts) == 3, [c.points for c in page.cuts] + assert page.discards == [True, False, False, True], page.discards + assert page.slice_count == 4 + assert project.kept_slices() == [(0, 1), (0, 2)], project.kept_slices() + + # Geometry is normalised, so it survives any change of resolution. + assert all(0.0 <= y <= 1.0 for cut in page.cuts for _, y in cut.points) + assert page.cuts[1].straight_y == 0.5 + + # A straight cut is flat; a stepped one is not, and interpolates. + step = Cut([(0.0, 0.20), (0.35, 0.20), (0.35, 0.40), (1.0, 0.40)]) + assert step.straight_y is None + assert step.y_at(0.0) == 0.20 + assert step.y_at(1.0) == 0.40 + assert step.y_at(0.35) == 0.20 or step.y_at(0.35) == 0.40 + assert step.highest == 0.20 and step.lowest == 0.40 + + # Adding a cut splits a slice and keeps that slice's flag on both halves. + before = page.slice_count + index = page.add_cut(Cut.straight(0.65)) + assert page.slice_count == before + 1 + assert index == 2, index + assert page.discards == [True, False, False, False, True], page.discards + + # Removing it merges them again. + page.remove_cut(index) + assert page.slice_count == before + assert page.discards == [True, False, False, True], page.discards + + # Round-trip. + saved = project.save() + assert saved == default_path(pdf), saved + reloaded = Project.load(saved) + assert reloaded.pages[0].skew == -1.1 + assert reloaded.pages[0].discards == page.discards + assert [c.points for c in reloaded.pages[0].cuts] == [c.points for c in page.cuts] + assert reloaded.source_hash == project.source_hash + assert not reloaded.source_changed() + + # A PDF edited underneath must be reported, not silently re-cut. + pdf.write_bytes(b"%PDF-1.7 different bytes entirely") + assert reloaded.source_changed() + + for f in (pdf, saved): + f.unlink() + tmp.rmdir() + print("ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main())