Merge dev: getting-started guide and engraving in the bundle

This commit is contained in:
Esa Kataja
2026-07-29 11:42:59 +03:00
10 changed files with 961 additions and 27 deletions
+11 -4
View File
@@ -8,7 +8,8 @@ A slice is one *system* — one full line of music across all voices, typically
scroll, so the slicer's job is to cut a printed page into systems, clean them up
enough to read on a tablet, and tag them with the score's navigation symbols.
**Status: design only.** No code yet. The design is settled; see below.
**New here? [docs/guide.md](docs/guide.md) walks you through making your first
bundle.**
## How it works
@@ -29,22 +30,28 @@ at it.
## Installation
Not yet installable. When it is:
```
uv tool install --editable .
```
That puts a `noteman-slicer` command on PATH which runs from any directory — no venv to
activate. Dependencies (PyMuPDF, PySide6, OpenCV, numpy) are all wheels; nothing
needs a system package.
needs a system package. LilyPond is optional and only enables re-engraving.
Then:
```
noteman-slicer edit my-song.pdf
```
## Documentation
| | |
|---|---|
| [docs/guide.md](docs/guide.md) | How to use it: install, cut a score, place markers, export a bundle. Start here if you just want to make one. |
| [CONTEXT.md](CONTEXT.md) | Glossary. What a slice, cut, discard, bundle and song scale actually mean here. Start here. |
| [docs/spec.md](docs/spec.md) | The specification: pipeline, geometry model, detection, editor, bundle format, and what noteman has to change. |
| [docs/bundle-format.md](docs/bundle-format.md) | The Score Bundle Format — a standalone specification of the export format, independent of this tool. |
Deferred work is tracked as issues and milestones on the Gitea repo, not in this
tree.
+364
View File
@@ -0,0 +1,364 @@
# Score Bundle Format, version 1
A container for one musical score, prepared for continuous-scroll display.
A bundle holds the score as a sequence of images — one per system of music —
together with the metadata that names the piece and the markers that describe
how a performer navigates it. It is self-contained: nothing outside the file is
needed to present the score.
This document defines the format. It does not describe any particular program
that writes or reads one.
## Terminology
**Slice** — one *system* of music: a single line spanning all voices, typically
four to twelve bars, with lyrics intact. A slice is the atomic unit of the
format. A slice is presented as an image; a slice that was engraved rather than
scanned may also carry the notation it was engraved from.
**Marker** — a semantic annotation attached to a slice, describing a navigational
feature printed in the score: a rehearsal letter, a repeat, a jump.
**Producer** — anything that writes a bundle. **Consumer** — anything that reads
one.
## Container
A bundle is a ZIP archive.
```
<name>.zip
├── song.json manifest: metadata, slice order, markers
├── original.pdf the source document (optional)
├── 001.webp
├── 002.webp
└── … one file per slice
```
- `song.json` is required and must be at the archive root.
- Slice images are at the archive root. Their names are given in `song.json`;
the zero-padded numbering shown is conventional, not required.
- `original.pdf` is optional. When present it is the document the score was
prepared from, carried along for printing or archival. It is not required to
present the score and consumers may ignore it.
- No directories, and no entries beyond those referenced by the manifest plus
the optional PDF.
- Compression method is unconstrained. Producers typically deflate `song.json`
and store the images and PDF, which are already compressed.
For scale: a twelve-page, twenty-four-slice choral score runs about 2.4 MB, of
which roughly 830 KB is the source PDF and the rest slice images at ~20 KB each.
## Manifest
`song.json` is UTF-8 encoded JSON.
```json
{
"v": 1,
"title": "Ketun joululaulu",
"composer": "trad.",
"arranger": "P. Rapi",
"tempo": 92,
"slices": [
{
"file": "001.webp",
"markers": [
{ "type": "rehearsal_letter", "label": "A" }
]
},
{
"file": "002.webp",
"markers": [
{ "type": "segno" },
{ "type": "to_coda", "destination": 7 }
]
},
{ "file": "003.webp" }
]
}
```
### Top-level fields
| Field | Type | | |
|---|---|---|---|
| `v` | integer | required | Format version. `1` for this document. |
| `slices` | array | required | Ordered, at least one entry. See below. |
| `title` | string | required | The name of the piece. |
| `subtitle` | string | optional | Alternate or translated title. |
| `composer` | string | optional | Who wrote the music. |
| `original_artist` | string | optional | Who originally performed the work, where that differs from the composer. |
| `arranger` | string | optional | Who adapted it for these forces. |
| `lyricist` | string | optional | Who wrote the words. |
| `translator` | string | optional | Who translated the words. |
| `tempo` | integer | optional | Beats per minute. |
| `voices` | string | optional | The parts in this arrangement, as free text. |
**Optional fields are omitted when they have no value.** A consumer will not
encounter an empty string or a null in place of an absent field.
`tempo` is a number, never a word: a figure can drive a metronome or a click
track, and verbal markings are not interchangeable between readers.
Unrecognised top-level fields may be added by future versions. A consumer should
ignore fields it does not know rather than reject the bundle.
### Slices
Each entry of `slices` is an object:
| Field | Type | | |
|---|---|---|---|
| `file` | string | required | Name of the image entry in the archive. |
| `markers` | array | optional | Markers on this slice. Omitted when there are none. |
| `engraving` | object | optional | The notation this slice's image was engraved from, when it was engraved rather than scanned. See [Engraving](#engraving). |
**The array order is the reading order of the score.** It is the only ordering
the format defines. Filenames often sort into the same order, but a consumer
must not derive order from them.
A slice's **index** is its zero-based position in this array. Indices are the
only identifiers the format has, and they are meaningful only within one bundle.
## Slice images
Every slice image in a bundle satisfies the following. A consumer can rely on
these and does not need to inspect the images to lay them out.
- **Format: WebP, losslessly encoded.** (Lossless rather than lossy because
engraved music is line art — large flat areas separated by thin high-contrast
strokes — which lossless encoders compress *better* than lossy ones as well as
exactly.)
- **RGBA, with all three colour channels zero.** The image is carried entirely
by the alpha channel: ink is opaque black, paper is fully transparent, and
antialiased edges are partially transparent. Compositing a slice over a
background of any colour reproduces the printed appearance on that colour of
paper.
- **Uniform width within a bundle.** Every slice has the same pixel width, so a
consumer can lay them out in a single column without measuring. Systems
shorter than the widest are padded on the right with transparent pixels; they
end early rather than stretching.
- **Width is at most 1920 pixels**, and is frequently less. A narrower bundle is
not a defect: images are never enlarged beyond the resolution of their source,
because that adds bytes and softness without adding detail. Consumers should
scale to fit their own layout and should not treat 1920 as a target.
- **Height varies per slice**, being the height of that system.
- **Slices need not be rectangular in content.** Where two systems interleave —
for example a section label printed level with the previous system's lyric
line — the boundary between them steps, and each slice is delivered as its
bounding box with the region belonging to its neighbour left transparent. This
requires nothing special from a consumer; it composites correctly.
Images are **presentation-ready**. They have already been deskewed, cropped,
levelled and scaled as a set. Re-encoding, re-cropping or re-scaling them
individually will at best waste work and at worst break the uniformity the
format guarantees.
One specific hazard is worth naming, because it is silent: an image pipeline
that *discards* the alpha channel rather than compositing it will turn every
slice into a solid black rectangle, since the colour channels are all zero.
## Engraving
Most slices are photographs of print: an image and nothing more. A slice that
was *engraved* — set from notation rather than scanned — can carry the notation
it came from, in an `engraving` object.
```json
{
"file": "007.webp",
"engraving": {
"lang": "lilypond",
"key": "aes",
"time": "4/4",
"print_time": false,
"voices": [
{ "clef": "treble", "notes": "c4 des ees f | ees2. r4", "lyrics": "Kai -- paa -- va sy -- dän" },
{ "clef": "treble_8", "notes": "aes,4 aes aes aes | aes2. r4" },
{ "clef": "bass", "notes": "aes,4 ges f ees | aes2. r4" }
]
}
}
```
| Field | Type | | |
|---|---|---|---|
| `lang` | string | required | The notation language. `"lilypond"` is the only value defined by this version. |
| `voices` | array | required | One entry per staff, in the order they are printed top to bottom. At least one. |
| `key` | string | optional | Key signature, in `lang`'s spelling. For `lilypond`, the tonic of the major spelling: `"aes"`, `"c"`, `"fis"`. |
| `time` | string | optional | Time signature, as `"4/4"`. |
| `print_time` | boolean | optional | Whether the time signature is printed on this system. Default `false`. |
Each entry of `voices`:
| Field | Type | | |
|---|---|---|---|
| `notes` | string | required | The music for this staff, verbatim in `lang`. |
| `clef` | string | optional | `"treble"`, `"treble_8"`, `"alto"`, `"bass"`. Default `"treble"`. |
| `lyrics` | string | optional | The words under this staff, verbatim in `lang`. Omitted when the staff has none. |
Three properties make this worth carrying:
- **It is the source, not a transcription.** The image was engraved from exactly
these strings. A consumer that re-engraves them gets the same system back.
- **It is editable.** A wrong note can be corrected here and the slice engraved
again, which a raster image does not allow.
- **It is playable.** `voices` are separated per staff with pitches, durations
and a key, so the passage can be sounded — a practice track, a click, a
pitch reference — without anyone reading the image.
`notes` and `lyrics` are opaque to this format. They are whatever `lang` accepts,
including constructs the fields above say nothing about: slurs, dynamics,
tuplets, and the tie idioms that carry a note across a slice boundary. A consumer
that does not speak `lang` must pass them through unaltered or ignore them, never
attempt to repair them.
An `engraving` **describes the slice above it, not the whole song**. Each is
self-contained: `key` and `time` are stated per slice, so nothing has to be
inherited from a neighbour or from the bundle. Slices without an `engraving` are
scanned, and the two kinds mix freely within one score — re-engraving a single
ruined system is the ordinary case.
A consumer that only presents the score can ignore `engraving` entirely. The
image is always the authority on what the slice looks like; where an image and
its engraving disagree, the image is what the producer intended to be read.
Notation languages other than `lilypond` may be added by future versions. A
consumer should ignore an `engraving` whose `lang` it does not know, and present
the slice image as it would any other.
## Markers
A marker annotates the slice it appears on.
| Field | Type | | |
|---|---|---|---|
| `type` | string | required | One of the vocabulary below. |
| `label` | string | optional | Free text. Meaningful for `rehearsal_letter`, `section_label` and `volta`. |
| `destination` | integer | optional | Index into `slices`. Present on jump types. |
A slice may carry several markers. Their order within the array is not
significant.
### Vocabulary
Named positions — places a performer may be directed to:
| `type` | Meaning |
|---|---|
| `rehearsal_letter` | A boxed letter or number printed above a system, used to say "from C". `label` holds it. |
| `section_label` | A named section: INTRO, VERSE, CHORUS. `label` holds the name. |
| `segno` | The 𝄋 sign, target of a *dal segno*. |
| `coda` | The 𝄌 sign, beginning of the closing section. |
| `fine` | The end of the piece when reached by a *da capo* or *dal segno*. |
Structural notation — printed context, affecting how the music is read but not
directing the reader elsewhere:
| `type` | Meaning |
|---|---|
| `repeat_start` | The start of a repeated passage. |
| `repeat_end` | The end of a repeated passage. |
| `volta` | An alternative ending bracket. `label` holds its number. |
Jumps — points where the reader is directed to another slice:
| `type` | Meaning |
|---|---|
| `to_coda` | "To Coda": leave here for the coda. |
| `ds_al_coda` | *Dal segno al coda*: return to the segno. |
| `ds_al_fine` | *Dal segno al fine*: return to the segno and play to the fine. |
| `dc_al_coda` | *Da capo al coda*: return to the beginning. |
| `dc_al_fine` | *Da capo al fine*: return to the beginning and play to the fine. |
| `generic_jump` | An unclassified jump. |
**Every jump marker states its destination explicitly**, as an index into
`slices`. A consumer does not need to infer where a jump leads by searching for
a matching `coda` or `segno`, and must not assume a bundle contains only one of
each. A `destination` always refers to an existing index.
Unrecognised marker types may be added by future versions. A consumer should
ignore markers it does not understand rather than reject the bundle.
## Versioning
`v` is an integer that increases when a change would break an existing consumer.
Additions that a consumer can safely ignore — new optional fields, new marker
types, new `engraving` languages — do not increase it. `engraving` was added
this way: a bundle carrying one is still a version 1 bundle, and a consumer that
has never heard of it presents the score unchanged.
A consumer should refuse a bundle whose `v` it does not recognise rather than
attempt to interpret it.
## Validating a bundle
A consumer is advised to check:
- `v` is a recognised version.
- `title` is present and non-empty; `slices` is a non-empty array.
- Every `file` names an entry present in the archive.
- Every `destination` is within the bounds of `slices`.
- Every `engraving` has a `lang` and a non-empty `voices`; unknown `lang` values
are ignored rather than rejected.
- Archive entry names contain no path separators, no `..`, and no absolute
paths, as with any archive from an untrusted source.
## Identity and updates
A bundle describes one complete score. The format has no notion of updating a
previously read bundle: there are no stable identifiers, and a slice's index is
meaningful only within the bundle that contains it.
Two bundles of the same piece are therefore independent documents, not versions
of one. A consumer that stores imported bundles and assigns its own identifiers
should treat a second bundle as a new score rather than merging it into an
existing one — jump destinations resolved against the first bundle's slices do
not survive being repointed at a second bundle's.
## Complete example
A 24-slice bundle, abbreviated:
```
song.zip
├── song.json 1.4 KB
├── original.pdf 827 KB
├── 001.webp 21 KB 1489 × 1058
├── 002.webp 18 KB 1489 × 818
├── …
└── 024.webp 1489 px wide, like every other slice
```
```json
{
"v": 1,
"title": "Ketun joululaulu",
"composer": "trad.",
"arranger": "P. Rapi",
"tempo": 92,
"slices": [
{ "file": "001.webp", "markers": [ { "type": "rehearsal_letter", "label": "A" } ] },
{ "file": "002.webp", "markers": [ { "type": "segno" },
{ "type": "to_coda", "destination": 7 } ] },
{ "file": "003.webp" },
{ "file": "004.webp" },
{ "file": "005.webp" },
{ "file": "006.webp" },
{ "file": "007.webp", "engraving": { "lang": "lilypond", "key": "aes", "time": "4/4",
"voices": [ { "clef": "treble",
"notes": "c4 des ees f | ees2. r4",
"lyrics": "Kai -- paa -- va sy -- dän" },
{ "clef": "bass",
"notes": "aes,4 ges f ees | aes2. r4" } ] } },
{ "file": "008.webp", "markers": [ { "type": "coda" } ] },
{ "file": "009.webp" }
]
}
```
Reading the score means presenting `001.webp` through `024.webp` in that order,
in one column, each scaled to the same width. A reader who follows the `to_coda`
on slice index 1 continues at slice index 7.
+145
View File
@@ -0,0 +1,145 @@
# Making a bundle
Start to finish: a score PDF in, one `.zip` out that noteman can open. Fifteen
minutes for a typical four-page song, most of it spent nudging cuts.
## Install
```
uv tool install --editable .
```
That puts `noteman-slicer` on PATH; it runs from any directory. Everything it
needs is a wheel — no system packages. LilyPond is optional and only enables
re-engraving (below); without it the tool works the same minus that pane.
## The one command you need
```
noteman-slicer edit my-song.pdf
```
The editor opens on page 1 with detection's guesses already drawn: horizontal
**cuts** between the systems, a **skew** correction, and a blue **content
rectangle** marking what is music rather than page margin. All of it is a
starting point — detection is an accelerator, not an authority. Fix whatever is
wrong.
Your work is saved to `my-song.slicer.json` next to the PDF, automatically on
export and with Ctrl+S any time. Closing and reopening picks up where you left
off.
## What you do on each page
1. **Straighten it.** If the staff lines slope, turn the *Skew* dial until they
are level. The preview updates live.
2. **Fix the cuts.** One cut line per boundary between systems. Double-click to
add one, drag to move it, right-click to delete it. A cut is a polyline, not
a straight line — Ctrl-click on a cut adds a vertex, so it can bend around a
low-hanging lyric or a slur that crosses the gap. Right-click a vertex to
drop it.
3. **Discard what isn't music.** Page headers, footers, page numbers and title
blocks are slices too, and they should not reach the tablet. Click the slice,
press <kbd>D</kbd>. Discarded slices show hatched. <kbd>D</kbd> again brings
one back.
4. **Set the content rectangle.** Drag the blue edges so they hold the music and
nothing else. This is the horizontal crop for every slice on the page.
5. **Set black and white points.** Pull the *White point* down until the paper
goes pure white and its texture disappears; pull *Black point* up until the
notes are solid black rather than grey mush. Scans need this; clean digital
PDFs usually don't.
Page Up / Page Down move between pages. Levels carry over from the previous
page, so a consistent scan only needs setting once.
## Markers
Markers are the navigation symbols noteman uses to jump around the score:
rehearsal letters, section labels, segno, coda, fine, repeats, voltas, and the
D.S./D.C. instructions. They belong to a slice.
Select the slice, pick the type, type a label if the type takes one (rehearsal
letters, section labels and voltas do), and press **Add**.
Jump markers — *to coda*, *D.S. al coda*, *D.C. al fine* and friends — also need
a destination. After adding one, press **Set target…** and click the slice it
jumps to, on any page. That is what lets noteman follow the repeat structure
instead of just scrolling.
## The title block
Fill in the *Song* section. **Title is required** — export refuses without one.
The rest (subtitle, composer, original artist, arranger, lyricist, translator,
voices) is optional and travels with the bundle into noteman's library.
*Tempo* is beats per minute, a number, because a number can drive a metronome
and "Andante" cannot.
## Export
**Export bundle…**, choose where the `.zip` goes, done. Inside are the slice
images in order, their markers, the song metadata, and the original PDF as the
archive copy. That zip is the whole interface to noteman; hand it over and open
it there.
Two things worth knowing:
- **Shrink the original PDF…** offers to store the archived PDF as bilevel,
which is dramatically smaller for scans. It shows you a before/after crop
first — check that the staff lines survived. It never touches the slices.
- **An exported project is spent.** Reopening the same PDF starts fresh from
detection rather than resuming decisions that already shipped. If you really
want the old cuts back, `noteman-slicer edit my-song.pdf --resume`.
## Re-engraving a slice (optional, needs LilyPond)
When a system is beyond rescue — a bad scan, a wrong transposition, a passage
you want rewritten — shift-double-click it. A window opens where you enter the
music as LilyPond, one block per voice, render, and compare against the
original. Accept and the rendered version replaces that slice in the bundle.
The LilyPond you typed travels in the bundle alongside the image, so the passage
can be corrected and re-engraved later, or played, without the project file.
## Mouse and keyboard
| | |
|---|---|
| Double-click | add a cut |
| Drag a cut | move it |
| Ctrl-click a cut | add a vertex |
| Right-click | delete the cut or vertex under the cursor |
| Click a slice, then <kbd>D</kbd> | discard it (or bring it back) |
| Drag the blue edges | resize the content rectangle |
| Shift-double-click a slice | re-engrave it |
| <kbd>Page Up</kbd> / <kbd>Page Down</kbd> | previous / next page |
| <kbd>Ctrl</kbd>+<kbd>S</kbd> | save the project |
## What the tool won't do
Erasing a previous owner's pencil marks, chord letters and breath marks. Do that
in GIMP before slicing — with a stylus it is quick, and no amount of thresholding
substitutes for it.
## When something looks wrong
| | |
|---|---|
| Detection found no systems, or one giant one | The score has no bracket joining the staves; add the cuts by hand. |
| "The PDF has changed since these cuts were made" | The file was edited or replaced under an existing project. The cuts probably no longer line up — re-cut. |
| Export says a title is required | Fill in *Song → Title*. |
| Slices look grey and washed out | The white point is too high. |
| Notes have holes in them | The black point is too high. |
## The command line
The editor is the tool; these exist for checking things quickly.
```
noteman-slicer info my-song.pdf # source type and page rasters
noteman-slicer detect my-song.pdf # detection results + debug overlays
noteman-slicer project my-song.pdf # what the project file currently holds
noteman-slicer export my-song.pdf # export without opening the editor
```
Every command takes `--type raster|vector` to override source-type detection.
+9 -3
View File
@@ -343,13 +343,19 @@ 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,
**Contents:** slices, markers, the original PDF, and song-level metadata (title,
subtitle, composer, original artist, arranger, lyricist, translator, tempo,
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
discarded — typing the fields while it's on screen beats reopening the PDF
later.
**Title is required**; everything else is optional and omitted when blank.
**Tempo is an integer**, beats per minute — a number can drive a metronome and
a starting-chord playback where *Andante* cannot, and two people will not agree
what *Andante* means. noteman's column is currently free-form text and needs
changing; see [`bundle-format.md`](bundle-format.md).
Rehearsal MIDI and MP3s are deliberately out of the first bundle.
### One rule for the import side
+50 -5
View File
@@ -29,18 +29,52 @@ METADATA_FIELDS = (
"arranger",
"lyricist",
"translator",
# Free-form, matching noteman's own column: scores notate tempo as a mix of
# BPM ("♩=72"), Italian ("Andante") and prose.
"tempo",
"voices",
)
# Beats per minute, exported as a JSON number. A figure is worth more than a
# word here: "Andante" cannot drive a metronome and two people will not agree
# what it means.
NUMERIC_FIELDS = frozenset({"tempo"})
def _engraving(project: Project, page: int, slot: int) -> dict | None:
"""The notation behind a re-engraved slice, or None for a scanned one.
The slice image stays the presentation; this is the notation it was made
from, carried so the music can be edited again or turned into sound. Key
and time are resolved against the song defaults here — a consumer reading
one slice should not have to know what the rest of the song inherited.
"""
replacement = project.pages[page].replacements[slot]
if not replacement or not replacement.voices:
return None
return {
"lang": "lilypond",
"key": replacement.key or project.key,
"time": replacement.time or project.time,
"print_time": replacement.print_time,
"voices": [
{"clef": v.clef, "notes": v.notes.strip()}
| ({"lyrics": v.lyrics.strip()} if v.lyrics.strip() else {})
for v in replacement.voices
],
}
def song_json(project: Project, files: list[str]) -> dict:
payload: dict = {"v": FORMAT_VERSION}
for field in METADATA_FIELDS:
value = project.metadata.get(field)
if value:
value = (project.metadata.get(field) or "").strip()
if not value:
continue
if field in NUMERIC_FIELDS:
try:
payload[field] = int(value)
except ValueError:
continue # not a number, so not worth exporting as one
else:
payload[field] = value
kept = project.kept_slices()
@@ -52,6 +86,9 @@ def song_json(project: Project, files: list[str]) -> dict:
slices: list[dict] = []
for name, (page, slot) in zip(files, kept):
entry: dict = {"file": name}
engraving = _engraving(project, page, slot)
if engraving:
entry["engraving"] = engraving
markers = []
for marker in project.pages[page].markers[slot]:
item: dict = {"type": marker.type}
@@ -100,7 +137,15 @@ def write(project: Project, source: Source, path: Path) -> Path:
zipfile.ZIP_DEFLATED,
)
if project.source.exists():
zf.write(project.source, "original.pdf")
pdf = project.source.read_bytes()
if project.optimise_pdf:
import pymupdf
from .pdfopt import optimise
shrunk, _ = optimise(pymupdf.open(project.source), len(pdf))
pdf = shrunk or pdf # empty means it found no saving
zf.writestr("original.pdf", pdf, zipfile.ZIP_STORED)
for name, data in zip(names, images):
zf.writestr(name, data, zipfile.ZIP_STORED)
+75 -15
View File
@@ -19,6 +19,7 @@ from PySide6.QtGui import (
QBrush,
QColor,
QImage,
QIntValidator,
QKeySequence,
QPainter,
QPen,
@@ -33,6 +34,7 @@ from PySide6.QtWidgets import (
QGraphicsScene,
QGraphicsView,
QComboBox,
QDialog,
QHBoxLayout,
QLabel,
QLineEdit,
@@ -50,7 +52,7 @@ from PySide6.QtWidgets import (
)
from . import bundle, lilypond
from .bundle import METADATA_FIELDS
from .bundle import METADATA_FIELDS, NUMERIC_FIELDS
from .detect import deskew, detect_page
from .pdf import Source, open_source, page_raster
from .project import (
@@ -204,20 +206,6 @@ class PageView(QGraphicsView):
text.setPos(x, y)
return x + box.width() * scale + pad * 3
for i, cut in enumerate(self.page.cuts):
colour = _CUT_ACTIVE if i == self.selected_cut else _CUT
pen = QPen(colour, 2)
pen.setCosmetic(True)
points = [QPointF(x * w, y * h) for x, y in cut.points]
for a, b in zip(points, points[1:]):
scene.addLine(a.x(), a.y(), b.x(), b.y(), pen)
if i == self.selected_cut:
r = HIT * 1.5 / max(self.transform().m11(), 1e-6)
for p in points:
scene.addEllipse(
p.x() - r, p.y() - r, r * 2, r * 2, QPen(Qt.NoPen), QBrush(_VERTEX)
)
def _slice_polygon(self, slot: int, w: int, h: int) -> QPolygonF:
above, below = self.page.bounds(slot)
top = [(0.0, 0.0), (1.0, 0.0)] if above is None else above.points
@@ -564,8 +552,19 @@ class Editor(QMainWindow):
required = field == "title"
if required:
edit.setPlaceholderText("required")
if field in NUMERIC_FIELDS:
# Beats per minute, and only that: a number can drive a
# metronome where "Andante" cannot.
edit.setValidator(QIntValidator(20, 400, edit))
edit.setPlaceholderText("BPM")
edit.setFixedWidth(90)
meta_form.addRow(f"{field.replace('_', ' ').title()}{' *' if required else ''}", edit)
self.optimise = QPushButton("Shrink the original PDF…")
self.optimise.setToolTip("Convert scanned pages to bilevel in the archived PDF")
self.optimise.clicked.connect(self._optimise_pdf)
meta_layout.addWidget(self.optimise)
self.summary = QLabel()
self.summary.setWordWrap(True)
box.addWidget(self.summary)
@@ -758,6 +757,67 @@ class Editor(QMainWindow):
}
self.autosave.start()
def _optimise_pdf(self) -> None:
"""Offer to shrink the archived PDF, showing the result before agreeing.
A before/after crop rather than a checkbox: the failure this can produce
— broken staff lines on a coarse scan — is obvious at a glance and
invisible in a byte count.
"""
import pymupdf
from .pdfopt import Report, optimise, preview
self.statusBar().showMessage("examining the PDF…")
QApplication.processEvents()
source = self.project.source
data, report = optimise(pymupdf.open(source), source.stat().st_size)
self.statusBar().clearMessage()
if not data:
QMessageBox.information(self, "Nothing to shrink", report.summary())
self.project.optimise_pdf = False
return
dialog = QDialog(self)
dialog.setWindowTitle("Shrink the original PDF")
layout = QVBoxLayout(dialog)
text = QLabel(report.summary() + "\n\nThe slices are unaffected — only the archived PDF.")
text.setWordWrap(True)
layout.addWidget(text)
crop = preview(pymupdf.open(source), pymupdf.open(stream=data, filetype="pdf"))
crop = np.ascontiguousarray(crop)
h, w, _ = crop.shape
image = QImage(crop.data, w, h, w * 3, QImage.Format_BGR888).copy()
label = QLabel()
label.setPixmap(QPixmap.fromImage(image))
area = QScrollArea()
area.setWidget(label)
area.setWidgetResizable(True)
area.setMinimumHeight(420)
layout.addWidget(area)
layout.addWidget(QLabel("Original above, shrunk below. Check the staff lines."))
buttons = QHBoxLayout()
use = QPushButton("Use the smaller PDF")
use.clicked.connect(dialog.accept)
keep = QPushButton("Keep the original")
keep.clicked.connect(dialog.reject)
buttons.addWidget(use)
buttons.addWidget(keep)
layout.addLayout(buttons)
dialog.resize(1100, 700)
self.project.optimise_pdf = dialog.exec() == QDialog.Accepted
self._touched()
self.statusBar().showMessage(
"the bundle will carry the shrunk PDF"
if self.project.optimise_pdf
else "the bundle will carry the original PDF",
4000,
)
def _reset_rect(self) -> None:
"""Back to what detection proposed for this page.
+172
View File
@@ -0,0 +1,172 @@
"""Optional shrinking of the original PDF carried in a bundle.
Scanned scores are usually black ink on white paper stored as 8-bit greyscale
or RGB, which costs several times what the same page costs as a bilevel image.
Converting them is worth 79× on a real corpus.
Two things it must not do, both found by looking at output rather than at
numbers:
* A page that is genuinely coloured — cover artwork — loses its artwork.
* A scan too coarse to have more than about one pixel per staff line comes
back with the staff lines broken.
Both are detectable before converting, so both are skipped. Everything skipped
is reported, so a caller can say what was left alone and why.
This affects only the archival copy of the score. Slices are cut from the
original before any of this and are unchanged either way.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import cv2
import numpy as np
import pymupdf
# Below this many pixels per inch as the image is *placed on the page*, staff
# lines are about a pixel wide and thresholding breaks them. Measured against a
# corpus where the one failure sat at ~115 DPI and the successes at 260+.
MIN_DPI = 200
# An image is "coloured" when this share of sampled pixels are off-grey by
# more than _CHROMA. The two populations are far apart: measured on a corpus,
# cover artwork sits at 44% while a greyscale scan's sensor tint reaches 3%.
# Ten percent sits in the gap with room on both sides.
_CHROMA = 24
_COLOUR_SHARE = 0.10
_BLOCK = 31 # adaptive threshold window
_OFFSET = 15
@dataclass
class Report:
before: int = 0
after: int = 0
converted: int = 0
skipped: dict[str, int] = field(default_factory=dict)
@property
def ratio(self) -> float:
return self.after / self.before if self.before else 1.0
def skip(self, reason: str) -> None:
self.skipped[reason] = self.skipped.get(reason, 0) + 1
def summary(self) -> str:
if not self.converted:
return "nothing to optimise — every image is already bilevel, coloured or too coarse"
parts = [
f"{self.before / 1024:.0f} KB → {self.after / 1024:.0f} KB "
f"({self.ratio * 100:.0f}%), {self.converted} images converted"
]
for reason, count in sorted(self.skipped.items()):
parts.append(f"{count} left alone: {reason}")
return "\n".join(parts)
def _is_coloured(image: np.ndarray) -> bool:
if image.ndim != 3 or image.shape[2] < 3:
return False
sample = image[::4, ::4, :3].astype(np.int16)
spread = sample.max(axis=2) - sample.min(axis=2)
return float((spread > _CHROMA).mean()) > _COLOUR_SHARE
def _placed_dpi(page: pymupdf.Page, item, width: int) -> float:
"""Pixels per inch of an image as it appears on the page.
Not the pixel count: a page split into tiles has small images at a high
resolution, and a full-page image can be large yet coarse.
"""
try:
bbox = pymupdf.Rect(page.get_image_bbox(item))
except (ValueError, RuntimeError):
return float("inf")
inches = abs(bbox.width) / 72.0
return width / inches if inches > 0 else float("inf")
def optimise(doc: pymupdf.Document, source_bytes: int) -> tuple[bytes, Report]:
"""Return the optimised PDF and a report of what was done.
`doc` is modified in place, so pass a copy or reopen afterwards.
"""
report = Report(before=source_bytes)
for page in doc:
for item in page.get_images(full=True):
xref = item[0]
info = doc.extract_image(xref)
if info.get("bpc") == 1:
report.skip("already bilevel")
continue
if _placed_dpi(page, item, info["width"]) < MIN_DPI:
report.skip(f"below {MIN_DPI} DPI, staff lines would break")
continue
raw = cv2.imdecode(np.frombuffer(info["image"], np.uint8), cv2.IMREAD_UNCHANGED)
if raw is None:
report.skip("unreadable encoding")
continue
if _is_coloured(raw):
report.skip("coloured artwork")
continue
gray = cv2.cvtColor(raw, cv2.COLOR_BGR2GRAY) if raw.ndim == 3 else raw
bilevel = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, _BLOCK, _OFFSET
)
ok, buffer = cv2.imencode(".png", bilevel, [cv2.IMWRITE_PNG_COMPRESSION, 9])
if not ok:
report.skip("re-encoding failed")
continue
try:
page.replace_image(xref, stream=buffer.tobytes())
except (ValueError, RuntimeError):
report.skip("could not be replaced")
continue
report.converted += 1
data = doc.tobytes(garbage=4, deflate=True, clean=True)
# Never hand back something larger than what came in.
if len(data) >= source_bytes:
report.after = source_bytes
report.converted = 0
report.skip("no saving available")
return b"", report
report.after = len(data)
return data, report
def preview(original: pymupdf.Document, optimised: pymupdf.Document, dpi: int = 260):
"""A stacked before/after crop of the first page, for eyeballing the result.
The numbers cannot show the failure this guards against — a broken staff
line is obvious at a glance and invisible in a byte count.
"""
rect = original[0].rect
clip = pymupdf.Rect(
rect.x0 + rect.width * 0.08,
rect.y0 + rect.height * 0.20,
rect.x0 + rect.width * 0.58,
rect.y0 + rect.height * 0.33,
)
def render(doc: pymupdf.Document) -> np.ndarray:
pix = doc[0].get_pixmap(dpi=dpi, clip=clip)
image = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, pix.n)
return image[:, :, :3] if pix.n >= 3 else cv2.cvtColor(image[:, :, 0], cv2.COLOR_GRAY2BGR)
before, after = render(original), render(optimised)
h = min(before.shape[0], after.shape[0])
w = min(before.shape[1], after.shape[1])
divider = np.full((4, w, 3), 128, np.uint8)
return np.vstack([before[:h, :w], divider, after[:h, :w]])
+6
View File
@@ -222,6 +222,10 @@ class Project:
key: str = "c"
time: str = "4/4"
clefs: list[str] = field(default_factory=list)
# Shrink the archival PDF carried in the bundle by converting its scanned
# pages to bilevel. Off by default: it is lossy on the copy kept for
# printing, and on some scans it breaks staff lines.
optimise_pdf: bool = False
path: Path | None = None
# Set once the song has been exported. A project is spent at that point:
# opening the PDF again starts a fresh session from detection rather than
@@ -308,6 +312,7 @@ class Project:
"key": self.key,
"time": self.time,
"clefs": self.clefs,
"optimise_pdf": self.optimise_pdf,
"pages": [
{
"skew": page.skew,
@@ -415,6 +420,7 @@ class Project:
key=data.get("key", "c"),
time=data.get("time", "4/4"),
clefs=data.get("clefs", []),
optimise_pdf=data.get("optimise_pdf", False),
)
def source_changed(self) -> bool:
+33
View File
@@ -22,6 +22,8 @@ from noteman_slicer.project import ( # noqa: E402
Cut,
Marker,
Project,
Replacement,
Voice,
default_path,
)
@@ -76,13 +78,44 @@ def main() -> int:
# Export resolves (page, slot) to the slice's index in the bundle.
names = [f"{i + 1:03}.webp" for i in range(len(project.kept_slices()))]
project.metadata.update({"title": "Test song", "tempo": "92", "composer": ""})
payload = song_json(project, names)
# Tempo is a number, not a string; empty fields are absent, not "".
assert payload["tempo"] == 92, payload["tempo"]
assert "composer" not in payload
project.metadata["tempo"] = "Andante"
assert "tempo" not in song_json(project, names), "words are not a tempo"
project.metadata["tempo"] = "92"
slices = payload["slices"]
assert [s["file"] for s in slices] == names
assert slices[0]["markers"][0] == {"type": "rehearsal_letter", "label": "A"}
assert slices[1]["markers"][0] == {"type": "coda"}
assert slices[0]["markers"][1] == {"type": "to_coda", "destination": 1}
# A re-engraved slice carries its notation into the bundle; a scanned one
# carries none. This is what makes a later edit or a MIDI render possible
# from the bundle alone.
project.key, project.time = "aes", "3/4"
page.replacements[second] = Replacement(
voices=[Voice("treble", "c4 d e f", "la la la la"), Voice("bass", " c4 d e f ", " ")]
)
engraved = song_json(project, names)["slices"]
assert "engraving" not in engraved[0], "a scanned slice has no notation"
ly = engraved[1]["engraving"]
assert ly["lang"] == "lilypond"
# Song defaults are resolved per slice: reading one slice needs no context.
assert (ly["key"], ly["time"], ly["print_time"]) == ("aes", "3/4", False)
assert ly["voices"][0] == {"clef": "treble", "notes": "c4 d e f", "lyrics": "la la la la"}
assert "lyrics" not in ly["voices"][1], "an empty field is absent, not empty"
assert ly["voices"][1]["notes"] == "c4 d e f"
override = Replacement(voices=page.replacements[second].voices, key="d", print_time=True)
page.replacements[second] = override
ly = song_json(project, names)["slices"][1]["engraving"]
assert (ly["key"], ly["time"], ly["print_time"]) == ("d", "3/4", True)
page.replacements[second] = None
# A jump whose target got discarded is dropped, not exported dangling.
project.pages[0].discards[second] = True
dropped = song_json(project, ["001.webp"])
+96
View File
@@ -0,0 +1,96 @@
"""Runnable check for optional PDF shrinking, including what it refuses to do."""
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.pdfopt import MIN_DPI, optimise # noqa: E402
A4_PT = (595, 842)
def _pdf(path: Path, width: int, height: int, *, colour: bool = False, bilevel: bool = False):
"""One full-page image of ruled lines, at the given pixel size."""
art = np.full((height, width, 3), 255, np.uint8)
for i in range(6):
y = int(height * (0.2 + i * 0.03))
art[y : y + max(1, height // 900), int(width * 0.1) : int(width * 0.9)] = 0
if colour:
art[: height // 3, :, 0] = 40 # a strong blue cast over the top third
art[: height // 3, :, 1] = 90
grey = art[:, :, 0] if not colour else None
doc = pymupdf.open()
page = doc.new_page(width=A4_PT[0], height=A4_PT[1])
if bilevel:
pix = pymupdf.Pixmap(pymupdf.csGRAY, width, height, bytearray(grey.tobytes()), False)
page.insert_image(page.rect, pixmap=pix)
doc.save(path, garbage=4, deflate=True)
# Re-save through a 1-bit PNG so the stored image really is bilevel.
import cv2
ok, buf = cv2.imencode(".png", (grey > 127).astype(np.uint8) * 255)
doc2 = pymupdf.open()
p2 = doc2.new_page(width=A4_PT[0], height=A4_PT[1])
p2.insert_image(p2.rect, stream=buf.tobytes())
doc2.save(path, garbage=4, deflate=True)
return
stream = art if colour else np.dstack([grey] * 3)
import cv2
ok, buf = cv2.imencode(".jpg", stream, [cv2.IMWRITE_JPEG_QUALITY, 92])
page.insert_image(page.rect, stream=buf.tobytes())
doc.save(path, garbage=4, deflate=True)
def main() -> int:
tmp = Path(__file__).with_name("_tmp")
tmp.mkdir(exist_ok=True)
# A4 is 8.26in wide, so 2480px is ~300 DPI and 800px is ~97 DPI.
fine, coarse, colour = tmp / "fine.pdf", tmp / "coarse.pdf", tmp / "colour.pdf"
_pdf(fine, 2480, 3508)
_pdf(coarse, 800, 1130)
_pdf(colour, 2480, 3508, colour=True)
data, report = optimise(pymupdf.open(fine), fine.stat().st_size)
assert report.converted == 1, report.summary()
assert data, "a greyscale scan at 300 DPI should shrink"
assert report.ratio < 0.9, report.ratio
# The result must still be a readable PDF of the same page count.
assert len(pymupdf.open(stream=data, filetype="pdf")) == 1
# Too coarse: staff lines would break, so it is left alone.
_, report = optimise(pymupdf.open(coarse), coarse.stat().st_size)
assert report.converted == 0, report.summary()
assert any("DPI" in reason for reason in report.skipped), report.skipped
# Genuine colour: artwork is not thrown away.
_, report = optimise(pymupdf.open(colour), colour.stat().st_size)
assert report.converted == 0, report.summary()
assert any("colour" in reason for reason in report.skipped), report.skipped
# A no-op run reports honestly rather than returning something bigger.
empty = pymupdf.open()
empty.new_page()
data, report = optimise(empty, 1)
assert data == b"" and report.converted == 0
assert report.ratio == 1.0
assert MIN_DPI >= 150, "the floor exists to protect thin staff lines"
for f in (fine, coarse, colour):
f.unlink(missing_ok=True)
tmp.rmdir()
print("ok")
return 0
if __name__ == "__main__":
sys.exit(main())