Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5af7b6159 | ||
|
|
9ed38323ef | ||
|
|
61dac44037 | ||
|
|
6ff956a622 | ||
|
|
e2f3e8fbdc | ||
|
|
01227b3382 | ||
|
|
46ae7e813a |
+20
-12
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 `<defs>` glyph table (111 KB of a 256 KB
|
||||
page) referenced by `<use transform="matrix(...)">`, plus body `<path>`
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 `<path>` elements and zero `<text>` — 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).
|
||||
@@ -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.
|
||||
+392
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
+13
-1
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user