diff --git a/CONTEXT.md b/CONTEXT.md index 33a8a9f..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**: @@ -32,10 +34,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**: @@ -75,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/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..d074de6 --- /dev/null +++ b/docs/spec.md @@ -0,0 +1,392 @@ +# 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 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, +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. 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..2f804ff --- /dev/null +++ b/noteman_slicer/cli.py @@ -0,0 +1,121 @@ +"""Command line entry point.""" + +from __future__ import annotations + +import argparse +import sys + +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 + + +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 _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 _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", + 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) + + 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) + + 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) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/noteman_slicer/detect.py b/noteman_slicer/detect.py new file mode 100644 index 0000000..23d1102 --- /dev/null +++ b/noteman_slicer/detect.py @@ -0,0 +1,255 @@ +"""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 _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]], + 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. + + 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 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. + """ + 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: + """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: + # 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. + 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/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/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/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_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()) 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/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()) 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" }, +]