Compare commits

..
12 Commits
Author SHA1 Message Date
Esa Kataja 6ce4fc8f99 Merge dev: engrave window bar numbers and LilyPond fixes 2026-07-29 13:46:41 +03:00
Esa Kataja 8f670cf7db Engrave window: bar numbers, and two silent LilyPond faults
A system can now be given the measure it starts at, in a First bar field
beside key and time. Per slice, since it is the one thing about a
replacement that cannot be inherited from the song. Bar numbering is a
Score property, so it is set once on the first staff, and made visible
only at a line beginning — that vector is fussy: #(#f #t #t) also prints
a number mid-system and #(#f #t #f) prints the second bar's rather than
the first's. It travels in the bundle's engraving object as `bar`.

Two things LilyPond 2.24 was quietly refusing to draw:

`\bar ":|"` and the other old repeat names produce nothing at all — no
error, no warning, exit status 0, just a missing repeat that you find on
the tablet. Every book and forum answer still uses them, so translate
them to the modern spellings.

`\clef treble_8` unquoted is not an octavated clef either. It parses as a
plain treble plus a stray "8" markup that lands under the first note, and
the staff then reads an octave off — a tenor line engraved at soprano
pitch. Quote it.

The source pane, which is where either of those would have been visible,
is now a collapsed section at the bottom rather than a permanent slab. It
uses the panel's own disclosure helper, lifted out of Editor so both can
call it.
2026-07-29 13:44:42 +03:00
Esa Kataja 29d9129bb7 Merge dev: rotated scans, detection and levels fixes, editor tweaks 2026-07-29 13:09:33 +03:00
Esa Kataja 24b12214bb Fit the page on startup, name the bundle, show the selection
Three things a first session trips over.

The editor opened at an arbitrary zoom. show_page does fit the page, but
it runs before the window has been laid out, when the viewport is still
its default size; redo it once when the real size arrives.

The bundle was named after the PDF, which is whatever the download was
called. Take the song's title instead — unsafe characters dropped, then
whitespace collapsed to dashes. Accented letters stay, since ä and ö are
not a filesystem's problem, but a leading dot would hide the file.

And the selected slice was drawn as an outline whose top and bottom edges
run under the cut lines painted over them, leaving two thin verticals in
the margins and no way to tell what was selected. Wash the slice, as the
discard and engraved states already do, and keep the outline for the trim
anomalies it exists to show.
2026-07-29 13:09:27 +03:00
Esa Kataja 19f28f4da8 Propose black and white points from the scan
Levels shipped at 0–255 unless someone moved the sliders, and Bicycle
Race showed what that costs. Its ink is grey, not black — a scanned
engraving, ink at 2–95, paper at 163–255 — and with alpha = 255 − luminance
that greyness becomes transparency. No pixel in the exported bundle was
even fully opaque, and the downscale to the song's width blended every
stroke edge further. Nothing downstream can rescue it.

So detection proposes levels too, like it proposes cuts and skew.
Notation is two-tone, which makes Otsu's split the measurement wanted;
the points sit halfway from it to each end of the range, so the ramp
between them survives as antialiasing rather than going jagged. A page
already scanned bilevel has no interior split — Otsu degenerates to 0 —
and is left alone. Per page, with the median becoming the song's, so a
near-blank page cannot set them.
2026-07-29 12:50:37 +03:00
Esa Kataja 38cb6ce09a Stop an edge artefact and a speck filter from destroying a song
Olukainen juomukainen came out unusable, from two separate faults.

The scanner left a dark line down the sheet edge, running the full height
of every page but the first. Being taller than any bracket it won every
overlap in anchor selection and swallowed the page into one system, so
five pages of six proposed no cuts at all. A page's brackets and barlines
are all about one system tall, so a stroke far taller than the typical
one is not notation — relative to the page's own strokes, since a page
holding one big system is legitimate.

The trim then removed each system's bottom line of lyrics. It judged ink
blobs by area, and a letter is nowhere near the threshold; a whole line
of them is dozens of blobs, none of which qualifies. Measure ink per row
and per column instead — a line of text carries plenty in total, and a
fleck's row carries almost none, which is the case the filter was for.

Detection now finds three systems on every page of that score, and every
slice keeps all four voices' words.
2026-07-29 12:22:08 +03:00
Esa Kataja 2a22fc469f Honour a page's /Rotate when extracting a scan
A scan fed sideways stores its image landscape and sets /Rotate 90 so a
viewer turns it upright. Extracting the image by xref — which is how a
raster source is read, to keep the scan's native resolution — bypasses
that, so every system ran down the page and detection found nothing.

Apply the page rotation to the extracted raster. Quarter turns only;
nothing produces anything else.
2026-07-29 12:08:45 +03:00
Esa Kataja 0da9dc29bf Merge dev: getting-started guide and engraving in the bundle 2026-07-29 11:42:59 +03:00
Esa Kataja cf1344c5bf Carry a re-engraved slice's notation in the bundle
A replaced slice shipped as pixels only, so the LilyPond behind it died
with the project file — and a project is spent once exported. Correcting
one wrong note meant retyping the system.

Slices gain an optional `engraving` object: the language, key, time and
one entry per voice holding the notes and lyrics verbatim. Structured per
staff rather than one blob of source, because that is what both a later
edit and a MIDI render want; song-level key and time are resolved per
slice so reading one needs no context from its neighbours.

Additive and ignorable, so the format stays at version 1.
2026-07-29 11:42:44 +03:00
Esa Kataja 630541c0cd Add a getting-started guide
A first user hit a wall at the door: the README still said "design only,
no code yet", and nothing anywhere said how to drive the editor.

docs/guide.md walks one PDF to one bundle — install, the five per-page
decisions, markers and jump targets, the title block, export — with a
mouse/key table and the failures that actually happen. The README's stale
status and installation lines go with it.
2026-07-29 11:42:36 +03:00
Esa Kataja b594968bb8 Add optional bilevel shrinking of the archived PDF
Scanned scores are 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. Across an 11-song corpus this is 27.6 MB to 7.7 MB; Engel's
bundle goes from 5997 KB to 2092 KB with byte-identical slices, since
only the archived copy changes.

Three approaches were measured and discarded first, which is worth
recording because two of them are the obvious ones. Converting RGB to
greyscale and re-encoding makes these files 20-86% LARGER: the source
JPEGs are already near 0.7 bits per pixel, so re-encoding adds
generation loss and spends more bits than the original did, and dropping
chroma recovers nothing because JPEG already subsamples it. Lossless
structural optimisation gains 0.1%, because images are 99% of every file
and there are no duplicates. Downsampling works but 300 DPI is print
resolution, and the PDF exists to be printed.

Two failure modes were found by looking at output rather than at byte
counts, and both are now refused:

- A scan at ~115 DPI came back with broken staff lines. Guarded on
  resolution as the image is *placed on the page*, so a tiled scan with
  126 small images still qualifies where a pixel count would reject it.
- Cover artwork was flattened to grey. Guarded on chroma: artwork
  measures 44% off-grey against 3% for sensor tint on a greyscale scan.
  The first threshold of 2% was a false positive that cost 685 KB on one
  song for nothing; 10% sits in the gap with room either side.

Exposed as a button rather than a checkbox. It reports what it skipped
and why, and shows a before/after crop, because the failure it can
produce is obvious at a glance and invisible in a size figure. Off by
default: this is lossy on the copy kept for printing.
2026-07-29 10:42:59 +03:00
Esa Kataja b8d93cee47 Specify the Score Bundle Format, and make tempo an integer
The bundle is documented as a standalone format rather than as a note
between two programs: producers and consumers are generic, the marker
vocabulary is defined musically rather than by what a viewer does with
it, and image properties are stated as guarantees with the reasoning
where it is not obvious. Anything that reads scores can implement it
without knowing this tool exists.

Taking that view changed the substance in three places. Marker types now
carry their musical meaning rather than a UI mapping. The rule against
re-importing became a statement about identity - indices mean something
only within one bundle, so two bundles of a piece are independent
documents. And forward-compatibility rules were added, which a protocol
needs and a handover note did not: ignore unknown fields and marker
types, refuse an unknown version.

Tempo is now an integer, beats per minute, exported as a JSON number and
omitted when blank; the editor accepts digits only. A figure can drive a
metronome or a click track where a verbal marking cannot, and readers do
not agree on what Andante means. This needs the consumer's column
changed from free-form text, which the format document flags.

Every figure in the document comes from a real export.
2026-07-29 09:22:43 +03:00
21 changed files with 1336 additions and 109 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 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. 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 ## How it works
@@ -29,22 +30,28 @@ at it.
## Installation ## Installation
Not yet installable. When it is:
``` ```
uv tool install --editable . uv tool install --editable .
``` ```
That puts a `noteman-slicer` command on PATH which runs from any directory — no venv to 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 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 ## 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. | | [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/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 Deferred work is tracked as issues and milestones on the Gitea repo, not in this
tree. tree.
+366
View File
@@ -0,0 +1,366 @@
# 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,
"bar": 33,
"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`. |
| `bar` | integer | optional | The measure this system starts at, as printed above its first bar. Omitted when the system is not numbered. |
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.
+147
View File
@@ -0,0 +1,147 @@
# 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. **Check black and white points.** These arrive proposed from the scan, like
the cuts do. If the paper still shows texture, pull *White point* down; if the
notes look grey rather than solid, pull *Black point* up. Getting this wrong
is the one mistake you cannot see until the bundle is on the tablet — grey ink
becomes half-transparent ink, and nothing downstream can rescue it.
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. It is named after the
song's title — *Bicycle Race* becomes `Bicycle-Race.zip`. 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.
+17 -3
View File
@@ -236,6 +236,14 @@ point just under the paper's luminance and the paper vanishes completely; set th
black point at the ink's darkest and notes go solid. It is also the single black point at the ink's darkest and notes go solid. It is also the single
biggest lever on output size. biggest lever on output size.
**Detection proposes both**, like it proposes cuts and skew, because the default
0255 is the one setting whose harm is invisible until the bundle is on a tablet.
Notation is two-tone, so Otsu's split between ink and paper is the measurement;
the points sit halfway from it to each end of the range, leaving the ramp between
them as the antialiasing. A page already scanned bilevel has no interior split —
Otsu degenerates to 0 there — and is left at 0255. The proposal is per page and
the median becomes the song's, so a near-blank page cannot set it.
Adaptive methods (CLAHE, adaptive thresholding) are the trap — tuned for text, Adaptive methods (CLAHE, adaptive thresholding) are the trap — tuned for text,
they eat the thin stuff on notation: hairpin tips, slur ends, ledger lines, 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 tapered beams. A global LUT whose effect you can see beats a local algorithm you
@@ -343,13 +351,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 Python's `zipfile` is stdlib; the import side needs one zero-dep library
(`fflate`), since Bun has zlib but no zip reader. (`fflate`), since Bun has zlib but no zip reader.
**Contents:** slices, markers, the original PDF, and song-level text metadata **Contents:** slices, markers, the original PDF, and song-level metadata (title,
(title, subtitle, composer, original artist, arranger, lyricist, translator, subtitle, composer, original artist, arranger, lyricist, translator, tempo,
voice list). Metadata is included not because the slicer transforms it but 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 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. 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. Rehearsal MIDI and MP3s are deliberately out of the first bundle.
### One rule for the import side ### One rule for the import side
+65 -5
View File
@@ -13,6 +13,7 @@ a jump source's `destination`.
from __future__ import annotations from __future__ import annotations
import json import json
import re
import zipfile import zipfile
from pathlib import Path from pathlib import Path
@@ -29,18 +30,66 @@ METADATA_FIELDS = (
"arranger", "arranger",
"lyricist", "lyricist",
"translator", "translator",
# Free-form, matching noteman's own column: scores notate tempo as a mix of
# BPM ("♩=72"), Italian ("Andante") and prose.
"tempo", "tempo",
"voices", "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 filename(project: Project) -> str:
"""The bundle's name, from the song's title.
Spaces become dashes and anything that is not a letter, digit, dash, dot or
underscore goes. Letters keep their accents — ä and ö are not a filesystem's
problem — but a leading dot would make the bundle invisible.
"""
# Drop the unsafe characters before collapsing whitespace, not after, or
# "Sävel & Ääni" keeps the dash the ampersand left behind.
title = re.sub(r"[^\w\s.-]", "", (project.metadata.get("title") or ""))
return f"{re.sub(r'\s+', '-', title.strip()).lstrip('.-') or 'song'}.zip"
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,
**({"bar": replacement.bar} if replacement.bar else {}),
"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: def song_json(project: Project, files: list[str]) -> dict:
payload: dict = {"v": FORMAT_VERSION} payload: dict = {"v": FORMAT_VERSION}
for field in METADATA_FIELDS: for field in METADATA_FIELDS:
value = project.metadata.get(field) value = (project.metadata.get(field) or "").strip()
if value: 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 payload[field] = value
kept = project.kept_slices() kept = project.kept_slices()
@@ -52,6 +101,9 @@ def song_json(project: Project, files: list[str]) -> dict:
slices: list[dict] = [] slices: list[dict] = []
for name, (page, slot) in zip(files, kept): for name, (page, slot) in zip(files, kept):
entry: dict = {"file": name} entry: dict = {"file": name}
engraving = _engraving(project, page, slot)
if engraving:
entry["engraving"] = engraving
markers = [] markers = []
for marker in project.pages[page].markers[slot]: for marker in project.pages[page].markers[slot]:
item: dict = {"type": marker.type} item: dict = {"type": marker.type}
@@ -100,7 +152,15 @@ def write(project: Project, source: Source, path: Path) -> Path:
zipfile.ZIP_DEFLATED, zipfile.ZIP_DEFLATED,
) )
if project.source.exists(): 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): for name, data in zip(names, images):
zf.writestr(name, data, zipfile.ZIP_STORED) zf.writestr(name, data, zipfile.ZIP_STORED)
+1 -1
View File
@@ -87,7 +87,7 @@ def _export(args: argparse.Namespace) -> int:
elif project.source_changed(): elif project.source_changed():
print("WARNING: the PDF has changed since these cuts were made") print("WARNING: the PDF has changed since these cuts were made")
out = Path(args.out) if args.out else source.path.with_suffix(".zip") out = Path(args.out) if args.out else source.path.with_name(bundle.filename(project))
try: try:
bundle.write(project, source, out) bundle.write(project, source, out)
except ValueError as error: except ValueError as error:
+38 -1
View File
@@ -26,6 +26,7 @@ _SKEW_WORK_SCALE = 0.25
_INK = 128 # below this is ink, above is paper _INK = 128 # below this is ink, above is paper
_ANCHOR_KERNEL = 0.03 # vertical open kernel, as a fraction of page height _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 _ANCHOR_MIN = 0.04 # a bracket is at least this tall, as a fraction of page
_ANCHOR_MAX_RATIO = 2.0 # a stroke this much taller than the typical one is an artefact
_PROFILE_FLOOR = 0.02 # ink-run threshold, as a fraction of the profile peak _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 _EXPAND_REACH = 1.5 # how far past the bracket a system's ink reaches, in staff heights
_STAFF_KERNEL = 0.05 # horizontal open kernel, as a fraction of page width _STAFF_KERNEL = 0.05 # horizontal open kernel, as a fraction of page width
@@ -64,6 +65,7 @@ class PageDetection:
systems: list[System] = field(default_factory=list) systems: list[System] = field(default_factory=list)
cuts: list[int] = field(default_factory=list) cuts: list[int] = field(default_factory=list)
content: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0) content: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0)
levels: tuple[int, int] = (0, 255)
@property @property
def bracketless(self) -> bool: def bracketless(self) -> bool:
@@ -123,6 +125,17 @@ def system_anchors(gray: np.ndarray) -> list[Anchor]:
if stats[i, cv2.CC_STAT_HEIGHT] > h * _ANCHOR_MIN if stats[i, cv2.CC_STAT_HEIGHT] > h * _ANCHOR_MIN
] ]
# A scanner leaves a dark line down the sheet edge — the binder shadow, the
# glass, the page next to it — and it runs the whole height of the scan.
# Being the tallest stroke on the page it wins every overlap below and
# swallows every system into one. A page's brackets and barlines are all
# about one system tall, so anything wildly taller than the typical stroke
# is not notation. Relative, not an absolute fraction of the page: a page
# holding one big system is legitimate and must survive.
if len(tall) > 1:
limit = float(np.median([a.bottom - a.top for a in tall])) * _ANCHOR_MAX_RATIO
tall = [a for a in tall if a.bottom - a.top <= limit] or tall
# Tallest first, keeping only strokes that don't overlap one already kept: # 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. # a system's barlines all overlap its bracket, so each system yields one.
# The kept stroke is the tallest, which is the bracket rather than a barline. # The kept stroke is the tallest, which is the bracket rather than a barline.
@@ -280,6 +293,26 @@ def staff_height(gray: np.ndarray, top: int, bottom: int) -> float | None:
return float(np.median(intra) * 4) # 5 lines, 4 spaces return float(np.median(intra) * 4) # 5 lines, 4 spaces
def ink_levels(gray: np.ndarray) -> tuple[int, int]:
"""Black and white points that put the ink on black and the paper on white.
Left at 0255 a slice ships whatever grey the scanner produced, and the
downscale to the song's width then blends every stroke edge further, so a
fine engraving arrives on the tablet as a wash. Notation is two-tone by
nature — ink and paper, nothing in between — so Otsu's split is exactly the
measurement wanted, and the points sit halfway to each end of the range from
it. Halfway rather than at the split itself: the ramp between them is the
antialiasing, and collapsing it would leave the notes jagged.
"""
split = float(cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[0])
if split < 1:
# A page already scanned bilevel has no interior split to find, and
# Otsu degenerates to 0. There is nothing between ink and paper to
# stretch, so leave the sliders where they are.
return 0, 255
return int(split / 2), int(split + (255 - split) / 2)
def _gap(run: tuple[int, int], anchor: Anchor) -> int: def _gap(run: tuple[int, int], anchor: Anchor) -> int:
"""Vertical distance between an ink run and a bracket; 0 if they overlap.""" """Vertical distance between an ink run and a bracket; 0 if they overlap."""
start, end = run start, end = run
@@ -375,5 +408,9 @@ def detect_page(gray: np.ndarray, skew: float | None = None) -> PageDetection:
# would risk clipping a tempo mark or a section label above the first staff. # would risk clipping a tempo mark or a section label above the first staff.
left, right = content_columns(straight, anchors) left, right = content_columns(straight, anchors)
return PageDetection( return PageDetection(
skew=angle, systems=systems, cuts=cuts, content=(left, 0.0, right, 1.0) skew=angle,
systems=systems,
cuts=cuts,
content=(left, 0.0, right, 1.0),
levels=ink_levels(straight),
) )
+146 -65
View File
@@ -19,6 +19,7 @@ from PySide6.QtGui import (
QBrush, QBrush,
QColor, QColor,
QImage, QImage,
QIntValidator,
QKeySequence, QKeySequence,
QPainter, QPainter,
QPen, QPen,
@@ -33,6 +34,7 @@ from PySide6.QtWidgets import (
QGraphicsScene, QGraphicsScene,
QGraphicsView, QGraphicsView,
QComboBox, QComboBox,
QDialog,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
@@ -50,7 +52,7 @@ from PySide6.QtWidgets import (
) )
from . import bundle, lilypond from . import bundle, lilypond
from .bundle import METADATA_FIELDS from .bundle import METADATA_FIELDS, NUMERIC_FIELDS
from .detect import deskew, detect_page from .detect import deskew, detect_page
from .pdf import Source, open_source, page_raster from .pdf import Source, open_source, page_raster
from .project import ( from .project import (
@@ -73,6 +75,8 @@ _CUT = QColor(220, 40, 40)
_CUT_ACTIVE = QColor(255, 120, 0) _CUT_ACTIVE = QColor(255, 120, 0)
_VERTEX = QColor(255, 200, 0) _VERTEX = QColor(255, 200, 0)
_DISCARD = QColor(120, 120, 140, 90) _DISCARD = QColor(120, 120, 140, 90)
_SELECT = QColor(0, 170, 0)
_SELECT_WASH = QColor(0, 200, 60, 40)
_RECT = QColor(40, 140, 220) _RECT = QColor(40, 140, 220)
_MARKER = QColor(150, 60, 190) _MARKER = QColor(150, 60, 190)
_ENGRAVED = QColor(200, 120, 0) _ENGRAVED = QColor(200, 120, 0)
@@ -80,6 +84,49 @@ _ENGRAVED_WASH = QColor(230, 160, 30, 55)
_BADGE_Z = 10 _BADGE_Z = 10
def section(title: str, box: QVBoxLayout, *, expanded: bool = True) -> QVBoxLayout:
"""A collapsible section. Returns the layout its contents go into.
A disclosure arrow, not a checkable QGroupBox: a checkbox in a group
header reads as "enable this feature" rather than "expand this", and a
column of framed boxes with checkboxes is hard to scan.
"""
header = QToolButton()
header.setText(title)
header.setCheckable(True)
header.setChecked(expanded)
header.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
header.setAutoRaise(True)
header.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
# Bold and greyed: a mid grey reads as a heading against both light and
# dark palettes without needing a second stylesheet.
header.setStyleSheet(
"QToolButton {"
" border: none;"
" font-weight: 700;"
" color: #808080;"
" padding: 7px 0 4px 0;"
" text-align: left;"
"}"
"QToolButton:hover { color: #a0a0a0; }"
)
body = QWidget()
layout = QVBoxLayout(body)
layout.setContentsMargins(10, 6, 0, 10)
body.setVisible(expanded)
def toggled(open_: bool) -> None:
body.setVisible(open_)
header.setArrowType(Qt.DownArrow if open_ else Qt.RightArrow)
header.toggled.connect(toggled)
box.addWidget(header)
box.addWidget(body)
return layout
class PageView(QGraphicsView): class PageView(QGraphicsView):
"""Pan, zoom, and direct manipulation of cuts and the content rectangle.""" """Pan, zoom, and direct manipulation of cuts and the content rectangle."""
@@ -102,6 +149,7 @@ class PageView(QGraphicsView):
self.selected_slice = 0 self.selected_slice = 0
self.picking = False self.picking = False
self._drag: tuple[str, int, int] | None = None self._drag: tuple[str, int, int] | None = None
self._fitted = False
# -- state ------------------------------------------------------------ # -- state ------------------------------------------------------------
@@ -135,10 +183,15 @@ class PageView(QGraphicsView):
self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_DISCARD) self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_DISCARD)
) )
# The selected slice, outlined so trim anomalies are visible. # The selected slice. The outline alone is nearly invisible: its top and
pen = QPen(QColor(0, 170, 0), 2) # bottom edges run under the cut lines drawn over them, leaving two thin
# verticals at the page margins. A wash says which slice is selected at
# a glance; the outline stays, because it is what shows trim anomalies.
selected = self._slice_polygon(self.selected_slice, w, h)
scene.addPolygon(selected, QPen(Qt.NoPen), QBrush(_SELECT_WASH))
pen = QPen(_SELECT, 3)
pen.setCosmetic(True) pen.setCosmetic(True)
scene.addPolygon(self._slice_polygon(self.selected_slice, w, h), pen) scene.addPolygon(selected, pen)
x0, y0, x1, y1 = self.project.page_content_rect(self.page_index) x0, y0, x1, y1 = self.project.page_content_rect(self.page_index)
pen = QPen(_RECT, 2, Qt.DashLine) pen = QPen(_RECT, 2, Qt.DashLine)
@@ -204,20 +257,6 @@ class PageView(QGraphicsView):
text.setPos(x, y) text.setPos(x, y)
return x + box.width() * scale + pad * 3 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: def _slice_polygon(self, slot: int, w: int, h: int) -> QPolygonF:
above, below = self.page.bounds(slot) above, below = self.page.bounds(slot)
top = [(0.0, 0.0), (1.0, 0.0)] if above is None else above.points top = [(0.0, 0.0), (1.0, 0.0)] if above is None else above.points
@@ -373,6 +412,15 @@ class PageView(QGraphicsView):
self.redraw() self.redraw()
self.changed.emit() self.changed.emit()
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
# The fit in show_page runs before the window has been laid out, when
# the viewport is still its default size, so the first page opens at
# some arbitrary zoom. Redo it once, when the real size arrives.
if not self._fitted and self.pixmap is not None:
self._fitted = True
self.fitInView(self.scene().sceneRect(), Qt.KeepAspectRatio)
def wheelEvent(self, event) -> None: def wheelEvent(self, event) -> None:
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15 factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
self.scale(factor, factor) self.scale(factor, factor)
@@ -421,48 +469,6 @@ class Editor(QMainWindow):
# -- ui --------------------------------------------------------------- # -- ui ---------------------------------------------------------------
def _section(self, title: str, box: QVBoxLayout, *, expanded: bool = True) -> QVBoxLayout:
"""A collapsible section. Returns the layout its contents go into.
A disclosure arrow, not a checkable QGroupBox: a checkbox in a group
header reads as "enable this feature" rather than "expand this", and a
column of framed boxes with checkboxes is hard to scan.
"""
header = QToolButton()
header.setText(title)
header.setCheckable(True)
header.setChecked(expanded)
header.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
header.setAutoRaise(True)
header.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
# Bold and greyed: a mid grey reads as a heading against both light and
# dark palettes without needing a second stylesheet.
header.setStyleSheet(
"QToolButton {"
" border: none;"
" font-weight: 700;"
" color: #808080;"
" padding: 7px 0 4px 0;"
" text-align: left;"
"}"
"QToolButton:hover { color: #a0a0a0; }"
)
body = QWidget()
layout = QVBoxLayout(body)
layout.setContentsMargins(10, 6, 0, 10)
body.setVisible(expanded)
def toggled(open_: bool) -> None:
body.setVisible(open_)
header.setArrowType(Qt.DownArrow if open_ else Qt.RightArrow)
header.toggled.connect(toggled)
box.addWidget(header)
box.addWidget(body)
return layout
def _panel(self) -> QWidget: def _panel(self) -> QWidget:
inner = QWidget() inner = QWidget()
box = QVBoxLayout(inner) box = QVBoxLayout(inner)
@@ -481,7 +487,7 @@ class Editor(QMainWindow):
nav.addWidget(nxt) nav.addWidget(nxt)
box.addLayout(nav) box.addLayout(nav)
page_section = self._section("Page", box) page_section = section("Page", box)
form = QFormLayout() form = QFormLayout()
page_section.addLayout(form) page_section.addLayout(form)
self.skew = QDoubleSpinBox() self.skew = QDoubleSpinBox()
@@ -510,7 +516,7 @@ class Editor(QMainWindow):
reset.clicked.connect(self._reset_rect) reset.clicked.connect(self._reset_rect)
form.addRow(reset) form.addRow(reset)
marker_layout = self._section("Markers on this slice", box) marker_layout = section("Markers on this slice", box)
self.marker_list = QListWidget() self.marker_list = QListWidget()
self.marker_list.setMaximumHeight(110) self.marker_list.setMaximumHeight(110)
marker_layout.addWidget(self.marker_list) marker_layout.addWidget(self.marker_list)
@@ -543,7 +549,7 @@ class Editor(QMainWindow):
# slices are never re-engraved, and it is the tallest block here. # slices are never re-engraved, and it is the tallest block here.
self.ly_status = None self.ly_status = None
if lilypond.available(): if lilypond.available():
ly_layout = self._section("Re-engrave this slice", box, expanded=False) ly_layout = section("Re-engrave this slice", box, expanded=False)
open_engrave = QPushButton("Open engrave window…") open_engrave = QPushButton("Open engrave window…")
open_engrave.setToolTip("Or double-click the slice on the page") open_engrave.setToolTip("Or double-click the slice on the page")
open_engrave.clicked.connect(self._open_engrave) open_engrave.clicked.connect(self._open_engrave)
@@ -553,7 +559,7 @@ class Editor(QMainWindow):
self.ly_status.setStyleSheet("color: #808080;") self.ly_status.setStyleSheet("color: #808080;")
ly_layout.addWidget(self.ly_status) ly_layout.addWidget(self.ly_status)
meta_layout = self._section("Song", box) meta_layout = section("Song", box)
meta_form = QFormLayout() meta_form = QFormLayout()
meta_layout.addLayout(meta_form) meta_layout.addLayout(meta_form)
self.metadata: dict[str, QLineEdit] = {} self.metadata: dict[str, QLineEdit] = {}
@@ -564,8 +570,19 @@ class Editor(QMainWindow):
required = field == "title" required = field == "title"
if required: if required:
edit.setPlaceholderText("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) 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 = QLabel()
self.summary.setWordWrap(True) self.summary.setWordWrap(True)
box.addWidget(self.summary) box.addWidget(self.summary)
@@ -758,6 +775,67 @@ class Editor(QMainWindow):
} }
self.autosave.start() 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: def _reset_rect(self) -> None:
"""Back to what detection proposed for this page. """Back to what detection proposed for this page.
@@ -784,7 +862,10 @@ class Editor(QMainWindow):
self.metadata["title"].setFocus() self.metadata["title"].setFocus()
return return
target, _ = QFileDialog.getSaveFileName( target, _ = QFileDialog.getSaveFileName(
self, "Export bundle", str(self.source.path.with_suffix(".zip")), "Bundle (*.zip)" self,
"Export bundle",
str(self.source.path.with_name(bundle.filename(self.project))),
"Bundle (*.zip)",
) )
if not target: if not target:
return return
+22 -3
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import cv2 import cv2
import numpy as np import numpy as np
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QImage, QKeySequence, QPixmap, QShortcut from PySide6.QtGui import QImage, QIntValidator, QKeySequence, QPixmap, QShortcut
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox, QComboBox,
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
from . import lilypond from . import lilypond
from .detect import staff_height from .detect import staff_height
from .editor import section
from .project import Project, Replacement, Voice from .project import Project, Replacement, Voice
@@ -180,6 +181,16 @@ class EngraveWindow(QDialog):
self.print_time.toggled.connect(self._settings_changed) self.print_time.toggled.connect(self._settings_changed)
row.addWidget(self.print_time, 1) row.addWidget(self.print_time, 1)
top.addRow("Time", row) top.addRow("Time", row)
# Per slice, unlike key and time: which measure a system starts at is
# the one thing that changes with every slice and cannot be inherited.
self.bar = QLineEdit("" if self.replacement.bar is None else str(self.replacement.bar))
self.bar.setValidator(QIntValidator(1, 9999, self.bar))
self.bar.setFixedWidth(70)
self.bar.setPlaceholderText("none")
self.bar.setToolTip("Printed above the first bar, as a printed score numbers its systems")
self.bar.textChanged.connect(self._bar_changed)
top.addRow("First bar", self.bar)
box.addLayout(top) box.addLayout(top)
voices_label = QLabel("Voices") voices_label = QLabel("Voices")
@@ -209,11 +220,15 @@ class EngraveWindow(QDialog):
self.status.setWordWrap(True) self.status.setWordWrap(True)
box.addWidget(self.status) box.addWidget(self.status)
# Collapsed: the source is what the form writes for you, so it is for
# checking what a field did, not for working in. Open it and it stays
# open for the life of the window.
raw = section("LilyPond source", box, expanded=False)
self.generated = QPlainTextEdit() self.generated = QPlainTextEdit()
self.generated.setReadOnly(True) self.generated.setReadOnly(True)
self.generated.setMaximumHeight(120) self.generated.setMaximumHeight(220)
self.generated.setStyleSheet("color: #808080;") self.generated.setStyleSheet("color: #808080;")
box.addWidget(self.generated) raw.addWidget(self.generated)
self._refresh_source() self._refresh_source()
return panel return panel
@@ -240,6 +255,10 @@ class EngraveWindow(QDialog):
row.setParent(None) row.setParent(None)
self._refresh_source() self._refresh_source()
def _bar_changed(self, text: str) -> None:
self.replacement.bar = int(text) if text.strip().isdigit() else None
self._refresh_source()
def _settings_changed(self) -> None: def _settings_changed(self) -> None:
# Set on the song, not the slice: they are song properties in practice, # Set on the song, not the slice: they are song properties in practice,
# and this is what makes the next re-engraved slice open pre-filled. # and this is what makes the next re-engraved slice open pre-filled.
+40 -2
View File
@@ -12,6 +12,7 @@ scaling.
from __future__ import annotations from __future__ import annotations
import re
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
@@ -63,6 +64,25 @@ RELATIVE_REFERENCE = {
"bass": "c", "bass": "c",
} }
# LilyPond renamed the repeat barlines and silently draws *nothing* for the old
# names — no error, no warning, just a missing repeat that you find on the
# tablet. Every book, every forum answer and every score anyone has typed before
# uses the old ones, so translate them.
_BAR_ALIASES = {
"|:": ".|:",
":|": ":|.",
":|:": ":|.|:",
"||:": ".|:",
":||": ":|.",
":||:": ":|.|:",
}
_BAR = re.compile(r'(\\bar\s*")([^"]*)(")')
def _modernise_bars(notes: str) -> str:
return _BAR.sub(lambda m: m[1] + _BAR_ALIASES.get(m[2], m[2]) + m[3], notes)
_PREAMBLE = """\\version "2.24.0" _PREAMBLE = """\\version "2.24.0"
\\paper { \\paper {
indent = 0\\mm indent = 0\\mm
@@ -85,17 +105,35 @@ def generate(replacement, key: str, time: str) -> str:
key = replacement.key or key key = replacement.key or key
time = replacement.time or time time = replacement.time or time
# Bar numbering is a Score property, so it is set once, on the first staff.
# Visible at the beginning of a line and nowhere else — which in a
# one-system slice means exactly one number, above the first bar, the way a
# printed score numbers its systems. The empty bar line is what gives the
# number a line beginning to attach to.
number = ""
if replacement.bar:
number = (
f" \\set Score.currentBarNumber = #{int(replacement.bar)}\n"
" \\override Score.BarNumber.break-visibility = #'#(#f #f #t)\n"
' \\bar ""\n'
)
staves = [] staves = []
for voice in replacement.voices: for voice in replacement.voices:
hide = "" if replacement.print_time else " \\omit Staff.TimeSignature\n" hide = "" if replacement.print_time else " \\omit Staff.TimeSignature\n"
body = voice.notes.strip() or "s1" body = _modernise_bars(voice.notes.strip()) or "s1"
reference = RELATIVE_REFERENCE.get(voice.clef, "c'") reference = RELATIVE_REFERENCE.get(voice.clef, "c'")
staff = ( staff = (
" \\new Staff {\n" " \\new Staff {\n"
f"{hide}" f"{hide}"
f" \\clef {voice.clef}\n" # Quoted, because an octavated name has to be: unquoted,
# `\clef treble_8` parses as a plain treble clef with a stray "8"
# markup that lands under the first note, and the staff then reads
# an octave off.
f' \\clef "{voice.clef}"\n'
f" \\key {key} \\major\n" f" \\key {key} \\major\n"
f" \\time {time}\n" f" \\time {time}\n"
f"{number if not staves else ''}"
f" \\relative {reference} {{ {body} }}\n" f" \\relative {reference} {{ {body} }}\n"
" }\n" " }\n"
) )
+16 -1
View File
@@ -90,13 +90,28 @@ def page_raster(source: Source, index: int) -> np.ndarray:
# Pixmap(doc, xref) rather than decoding extract_image() bytes: # Pixmap(doc, xref) rather than decoding extract_image() bytes:
# MuPDF handles JBIG2 and CCITT, which no image library will. # MuPDF handles JBIG2 and CCITT, which no image library will.
pix = pymupdf.Pixmap(source.doc, xref) pix = pymupdf.Pixmap(source.doc, xref)
return _to_gray(pix) # The embedded image is in its own orientation, not the page's: a
# scanner that fed the sheet sideways stores it landscape and the
# PDF sets /Rotate so viewers turn it upright. Extracting by xref
# bypasses that, so apply it here — otherwise every system runs
# down the page and detection finds nothing.
return _rotate(_to_gray(pix), page.rotation)
# A scanned PDF whose page has no embedded image (a blank, or a # A scanned PDF whose page has no embedded image (a blank, or a
# cover typeset in vector). Rendering is the only option left. # cover typeset in vector). Rendering is the only option left.
return _to_gray(page.get_pixmap(dpi=source.render_dpi, colorspace=pymupdf.csGRAY)) return _to_gray(page.get_pixmap(dpi=source.render_dpi, colorspace=pymupdf.csGRAY))
def _rotate(gray: np.ndarray, degrees: int) -> np.ndarray:
"""Turn a page raster clockwise by a multiple of 90°, as /Rotate means it.
ponytail: quarter turns only. A page rotated by anything else would need
resampling, and no scanner produces one.
"""
turns = round(degrees / 90) % 4
return np.ascontiguousarray(np.rot90(gray, -turns)) if turns else gray
def _to_gray(pix: pymupdf.Pixmap) -> np.ndarray: def _to_gray(pix: pymupdf.Pixmap) -> np.ndarray:
if pix.alpha or pix.colorspace is None or pix.colorspace.n != 1: if pix.alpha or pix.colorspace is None or pix.colorspace.n != 1:
pix = pymupdf.Pixmap(pymupdf.csGRAY, pix) pix = pymupdf.Pixmap(pymupdf.csGRAY, pix)
+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]])
+25 -1
View File
@@ -15,6 +15,7 @@ import hashlib
import json import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from statistics import median
from .detect import PageDetection from .detect import PageDetection
@@ -146,6 +147,10 @@ class Replacement:
voices: list[Voice] = field(default_factory=list) voices: list[Voice] = field(default_factory=list)
key: str | None = None key: str | None = None
time: str | None = None time: str | None = None
# The measure this system starts at, printed above its first bar the way a
# score numbers its systems. Per slice and nothing else: it is the one thing
# about a replacement that cannot be inherited or guessed.
bar: int | None = None
# The printed score repeats the key signature at every system but not the # The printed score repeats the key signature at every system but not the
# time signature, so a re-engraved middle slice must not show one. # time signature, so a re-engraved middle slice must not show one.
print_time: bool = False print_time: bool = False
@@ -222,6 +227,10 @@ class Project:
key: str = "c" key: str = "c"
time: str = "4/4" time: str = "4/4"
clefs: list[str] = field(default_factory=list) 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 path: Path | None = None
# Set once the song has been exported. A project is spent at that point: # 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 # opening the PDF again starts a fresh session from detection rather than
@@ -292,7 +301,18 @@ class Project:
content_rect=detection.content, content_rect=detection.content,
) )
) )
return cls(source=source, source_hash=hash_file(source), pages=pages) # Levels per song, not per page: a scanner's contrast does not change
# between sheets, and one pair of sliders for the whole song is what a
# user actually wants to nudge. The median keeps a near-blank page —
# where the ink/paper split is guesswork — from setting them.
proposals = [d.levels for d in detections] or [(0, 255)]
levels = (
int(median(b for b, _ in proposals)),
int(median(w for _, w in proposals)),
)
return cls(
source=source, source_hash=hash_file(source), pages=pages, levels=levels
)
def save(self, path: Path | None = None) -> Path: def save(self, path: Path | None = None) -> Path:
"""Atomic write, so a crash mid-save cannot destroy the previous state.""" """Atomic write, so a crash mid-save cannot destroy the previous state."""
@@ -308,6 +328,7 @@ class Project:
"key": self.key, "key": self.key,
"time": self.time, "time": self.time,
"clefs": self.clefs, "clefs": self.clefs,
"optimise_pdf": self.optimise_pdf,
"pages": [ "pages": [
{ {
"skew": page.skew, "skew": page.skew,
@@ -339,6 +360,7 @@ class Project:
**({"key": r.key} if r.key else {}), **({"key": r.key} if r.key else {}),
**({"time": r.time} if r.time else {}), **({"time": r.time} if r.time else {}),
**({"print_time": True} if r.print_time else {}), **({"print_time": True} if r.print_time else {}),
**({"bar": r.bar} if r.bar else {}),
} }
for r in page.replacements for r in page.replacements
], ],
@@ -395,6 +417,7 @@ class Project:
key=r.get("key"), key=r.get("key"),
time=r.get("time"), time=r.get("time"),
print_time=r.get("print_time", False), print_time=r.get("print_time", False),
bar=r.get("bar"),
) )
for r in page.get("replacements", [None] * len(page["discards"])) for r in page.get("replacements", [None] * len(page["discards"]))
], ],
@@ -415,6 +438,7 @@ class Project:
key=data.get("key", "c"), key=data.get("key", "c"),
time=data.get("time", "4/4"), time=data.get("time", "4/4"),
clefs=data.get("clefs", []), clefs=data.get("clefs", []),
optimise_pdf=data.get("optimise_pdf", False),
) )
def source_changed(self) -> bool: def source_changed(self) -> bool:
+19 -18
View File
@@ -26,7 +26,10 @@ from .project import Cut, Project
MAX_WIDTH = 1920 MAX_WIDTH = 1920
ALPHA_LEVELS = 16 # quantising alpha costs nothing visible and ~32% of the bytes ALPHA_LEVELS = 16 # quantising alpha costs nothing visible and ~32% of the bytes
_SPECK_AREA = 300 # ink blobs smaller than this don't anchor a trim # A row or column carrying less ink than this is a fleck, not content: at least
# this many pixels, and at least this share of the slice's own size.
_SPECK_INK = 8
_SPECK_SHARE = 0.005
@dataclass @dataclass
@@ -105,26 +108,24 @@ def _ink_bbox(gray: np.ndarray) -> tuple[int, int, int, int] | None:
One scan fleck at the far left would otherwise anchor the trim and shift One scan fleck at the far left would otherwise anchor the trim and shift
that slice relative to every other one. that slice relative to every other one.
Measured per row and per column rather than per blob. Judging each blob on
its own area throws away a whole line of lyrics every letter is its own
small component, and no single one is big enough to keep which is how a
slice loses its bottom voice's words. A row carrying a line of text carries
plenty of ink *in total*, and a fleck's row carries almost none.
""" """
ink = (gray < 200).astype(np.uint8) ink = gray < 200
count, _, stats, _ = cv2.connectedComponentsWithStats(ink, 8) rows, cols = ink.sum(axis=1), ink.sum(axis=0)
boxes = [ kept_rows = np.where(rows >= max(_SPECK_INK, ink.shape[1] * _SPECK_SHARE))[0]
( kept_cols = np.where(cols >= max(_SPECK_INK, ink.shape[0] * _SPECK_SHARE))[0]
stats[i, cv2.CC_STAT_LEFT], if not kept_rows.size or not kept_cols.size:
stats[i, cv2.CC_STAT_TOP],
stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH],
stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT],
)
for i in range(1, count)
if stats[i, cv2.CC_STAT_AREA] >= _SPECK_AREA
]
if not boxes:
return None return None
return ( return (
min(b[0] for b in boxes), int(kept_cols[0]),
min(b[1] for b in boxes), int(kept_rows[0]),
max(b[2] for b in boxes), int(kept_cols[-1]) + 1,
max(b[3] for b in boxes), int(kept_rows[-1]) + 1,
) )
+23 -1
View File
@@ -14,7 +14,12 @@ import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from noteman_slicer.detect import deskew, deskew_angle, detect_page # noqa: E402 from noteman_slicer.detect import ( # noqa: E402
deskew,
deskew_angle,
detect_page,
ink_levels,
)
W, H = 1000, 1400 W, H = 1000, 1400
STAFF_GAP = 15 # → staff height 60, so expansion reaches 90px past a bracket STAFF_GAP = 15 # → staff height 60, so expansion reaches 90px past a bracket
@@ -71,6 +76,23 @@ def main() -> int:
found = deskew_angle(deskew(page, angle)) found = deskew_angle(deskew(page, angle))
assert abs(found + angle) <= 0.15, f"skew {angle}: got {found}" assert abs(found + angle) <= 0.15, f"skew {angle}: got {found}"
# Levels are proposed too. A grey scan left at 0255 ships its wash to the
# tablet, and the downscale to the song's width only blends it further.
grey = np.full((H, W), 210, np.uint8) # paper, not white
grey[200:400, 100:900] = 70 # ink, not black
black, white = ink_levels(grey)
assert black < 70 < white < 210, (black, white)
# A page already bilevel has nothing between ink and paper to stretch.
assert ink_levels(_page()) == (0, 255)
# A scanner's edge line runs the whole height of the sheet. Being taller
# than every bracket it used to win each overlap and swallow the page into
# one system — Olukainen juomukainen, where five pages of six came out as a
# single slice each.
scanned = _page()
scanned[10 : H - 10, W - 8 : W - 4] = 0
assert len(detect_page(scanned).systems) == 2, "an edge artefact is not a bracket"
# No brackets: every ink run is its own system. # No brackets: every ink run is its own system.
bare = np.full((H, W), 255, np.uint8) bare = np.full((H, W), 255, np.uint8)
for y in (200, 500, 800): for y in (200, 500, 800):
+23
View File
@@ -19,6 +19,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from PySide6.QtWidgets import QApplication # noqa: E402 from PySide6.QtWidgets import QApplication # noqa: E402
from noteman_slicer import bundle # noqa: E402
from noteman_slicer.detect import detect_page # noqa: E402 from noteman_slicer.detect import detect_page # noqa: E402
from noteman_slicer.editor import Editor # noqa: E402 from noteman_slicer.editor import Editor # noqa: E402
from noteman_slicer.pdf import open_source, page_raster # noqa: E402 from noteman_slicer.pdf import open_source, page_raster # noqa: E402
@@ -105,6 +106,28 @@ def main() -> int:
assert reloaded.pages[0].levels == (40, 210) assert reloaded.pages[0].levels == (40, 210)
assert [c.points for c in reloaded.pages[0].cuts] == [c.points for c in page.cuts] assert [c.points for c in reloaded.pages[0].cuts] == [c.points for c in page.cuts]
# The page fits the viewport once the window has a real size. show_page's
# own fit runs before layout, when the viewport is still its default.
editor.resize(900, 700)
editor.show()
app.processEvents()
scene = editor.view.sceneRect()
scale = editor.view.transform().m11()
viewport = editor.view.viewport()
fill = max(
scale * scene.width() / viewport.width(),
scale * scene.height() / viewport.height(),
)
# Fit means nearly touching one edge — Qt leaves a small margin of its own.
# A "≤ 1" check alone would pass a page zoomed down to a dot.
assert 0.9 <= fill <= 1.02, f"page is not fitted to the window: {fill:.3f}"
# The bundle is named after the song, not the PDF.
assert bundle.filename(project) == "Ketun-joululaulu.zip"
project.metadata["title"] = "AC/DC: T.N.T. (live)"
assert bundle.filename(project) == "ACDC-T.N.T.-live.zip"
project.metadata["title"] = "Ketun joululaulu"
editor.close() editor.close()
source.close() source.close()
for f in (pdf, saved): for f in (pdf, saved):
+35
View File
@@ -52,6 +52,39 @@ def _scan_pdf(path: Path) -> None:
def main() -> int: def main() -> int:
# Bar aliases are string work, so they are checked whether or not LilyPond
# is installed. The old repeat names draw nothing at all in 2.24 — silently,
# which is how a missing repeat reaches a tablet.
aliased = lilypond.generate(
Replacement(voices=[Voice("treble", 'c4 d \\bar ":|" e f \\bar "|:" g', "")]), "c", "4/4"
)
assert '\\bar ":|."' in aliased and '\\bar ".|:"' in aliased, aliased
kept = lilypond.generate(
Replacement(voices=[Voice("treble", 'c4 \\bar "|." d', "")]), "c", "4/4"
)
assert '\\bar "|."' in kept, "a name LilyPond still knows is left alone"
# An octavated clef name must be quoted. Unquoted, `\clef treble_8` is a
# plain treble with a stray "8" markup under the first note, an octave off.
tenor = lilypond.generate(
Replacement(voices=[Voice("treble_8", "c4 d", "")]), "c", "4/4"
)
assert '\\clef "treble_8"' in tenor, tenor
# A bar number is set once, on the first staff, since it is a Score
# property, and is visible only at a line beginning — one number above the
# first bar, as a printed score numbers its systems.
numbered = lilypond.generate(
Replacement(voices=[Voice("treble", "c4 d", ""), Voice("bass", "c4 d", "")], bar=33),
"c",
"4/4",
)
assert numbered.count("currentBarNumber = #33") == 1, numbered
assert "break-visibility = #'#(#f #f #t)" in numbered
assert "currentBarNumber" not in lilypond.generate(
Replacement(voices=[Voice("treble", "c4 d", "")]), "c", "4/4"
), "an unnumbered system prints no number"
if not lilypond.available(): if not lilypond.available():
print("ok (skipped: LilyPond not installed)") print("ok (skipped: LilyPond not installed)")
return 0 return 0
@@ -138,6 +171,7 @@ def main() -> int:
assert page.replacements[second] is SATB assert page.replacements[second] is SATB
# Round-trip, including the song-level engraving defaults. # Round-trip, including the song-level engraving defaults.
SATB.bar = 33
project.key, project.time, project.clefs = "aes", "3/4", ["treble", "bass"] project.key, project.time, project.clefs = "aes", "3/4", ["treble", "bass"]
saved = project.save() saved = project.save()
reloaded = Project.load(saved) reloaded = Project.load(saved)
@@ -146,6 +180,7 @@ def main() -> int:
assert restored is not None assert restored is not None
assert [v.clef for v in restored.voices] == ["treble", "bass"] assert [v.clef for v in restored.voices] == ["treble", "bass"]
assert restored.voices[0].lyrics == "la la la la la la" assert restored.voices[0].lyrics == "la la la la la la"
assert restored.bar == 33, "the slice's bar number survives a save"
assert reloaded.pages[0].replacements[first] is None assert reloaded.pages[0].replacements[first] is None
source.close() source.close()
+34
View File
@@ -22,6 +22,8 @@ from noteman_slicer.project import ( # noqa: E402
Cut, Cut,
Marker, Marker,
Project, Project,
Replacement,
Voice,
default_path, default_path,
) )
@@ -76,13 +78,45 @@ def main() -> int:
# Export resolves (page, slot) to the slice's index in the bundle. # 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()))] 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) 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"] slices = payload["slices"]
assert [s["file"] for s in slices] == names assert [s["file"] for s in slices] == names
assert slices[0]["markers"][0] == {"type": "rehearsal_letter", "label": "A"} assert slices[0]["markers"][0] == {"type": "rehearsal_letter", "label": "A"}
assert slices[1]["markers"][0] == {"type": "coda"} assert slices[1]["markers"][0] == {"type": "coda"}
assert slices[0]["markers"][1] == {"type": "to_coda", "destination": 1} 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 ", " ")],
bar=33,
)
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"], ly["bar"]) == ("aes", "3/4", False, 33)
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. # A jump whose target got discarded is dropped, not exported dangling.
project.pages[0].discards[second] = True project.pages[0].discards[second] = True
dropped = song_json(project, ["001.webp"]) dropped = song_json(project, ["001.webp"])
+26 -4
View File
@@ -28,15 +28,21 @@ def _vector_pdf(path: Path, pages: int = 2) -> None:
doc.save(path) doc.save(path)
def _scan_pdf(path: Path, pages: int = 2, w: int = 1653, h: int = 2332) -> None: def _scan_pdf(path: Path, pages: int = 2, w: int = 1653, h: int = 2332, rotation: int = 0) -> None:
"""Each page is one full-page grayscale image — what a real scan looks like.""" """Each page is one full-page grayscale image — what a real scan looks like.
`rotation` reproduces a sheet fed sideways: the image is stored in its own
orientation and /Rotate turns it upright for a viewer.
"""
art = np.full((h, w), 255, np.uint8) art = np.full((h, w), 255, np.uint8)
art[500:505, 100 : w - 100] = 0 # a staff line, so it isn't uniform art[500:505, 100 : w - 100] = 0 # a staff line, so it isn't uniform
art[:60, :60] = 0 # a corner mark, so orientation is checkable
pix = pymupdf.Pixmap(pymupdf.csGRAY, w, h, bytearray(art.tobytes()), False) pix = pymupdf.Pixmap(pymupdf.csGRAY, w, h, bytearray(art.tobytes()), False)
doc = pymupdf.open() doc = pymupdf.open()
for _ in range(pages): for _ in range(pages):
page = doc.new_page(width=A4.width, height=A4.height) page = doc.new_page(width=A4.width, height=A4.width * h / w)
page.insert_image(page.rect, pixmap=pix) page.insert_image(page.rect, pixmap=pix)
page.set_rotation(rotation)
doc.save(path) doc.save(path)
@@ -64,6 +70,22 @@ def main() -> int:
assert page.min() == 0 and page.max() == 255, (page.min(), page.max()) assert page.min() == 0 and page.max() == 255, (page.min(), page.max())
src.close() src.close()
# The corner mark sits top-left in an upright scan.
assert page[:60, :60].max() == 0 and page[:60, -60:].min() == 255
# A sideways scan comes back upright: the page's /Rotate applies to the
# image extracted by xref, which bypasses it. Okular gets this right and
# the slicer used to not.
sideways = tmp / "sideways.pdf"
_scan_pdf(sideways, pages=1, w=2332, h=1653, rotation=90)
src = open_source(sideways)
assert src.type is SourceType.RASTER
turned = page_raster(src, 0)
assert turned.shape == (2332, 1653), turned.shape
# Turned clockwise, so the mark that was top-left is now top-right.
assert turned[:60, -60:].max() == 0 and turned[:60, :60].min() == 255
src.close()
# An override must win over detection, and say so. # An override must win over detection, and say so.
src = open_source(scan, SourceType.VECTOR) src = open_source(scan, SourceType.VECTOR)
assert src.type is SourceType.VECTOR and src.detected is SourceType.RASTER assert src.type is SourceType.VECTOR and src.detected is SourceType.RASTER
@@ -71,7 +93,7 @@ def main() -> int:
assert page_raster(src, 0).shape[1] > 4000, "override must force a render" assert page_raster(src, 0).shape[1] > 4000, "override must force a render"
src.close() src.close()
for f in (vec, scan): for f in (vec, scan, sideways):
f.unlink() f.unlink()
tmp.rmdir() tmp.rmdir()
print("ok") print("ok")
+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())
+14
View File
@@ -19,6 +19,7 @@ from noteman_slicer.pdf import open_source, page_raster # noqa: E402
from noteman_slicer.project import Cut, Project, default_path # noqa: E402 from noteman_slicer.project import Cut, Project, default_path # noqa: E402
from noteman_slicer.render import ( # noqa: E402 from noteman_slicer.render import ( # noqa: E402
ALPHA_LEVELS, ALPHA_LEVELS,
_ink_bbox,
apply_levels, apply_levels,
encode, encode,
pad_right, pad_right,
@@ -87,6 +88,19 @@ def main() -> int:
assert rgba[:, :, 3].min() == 0, "paper must be fully transparent" assert rgba[:, :, 3].min() == 0, "paper must be fully transparent"
assert len(np.unique(rgba[:, :, 3])) <= ALPHA_LEVELS assert len(np.unique(rgba[:, :, 3])) <= ALPHA_LEVELS
# Trim keeps a line of lyrics and drops a fleck. Each letter is its own
# small blob, so judging blobs by area threw the whole line away and the
# bottom voice lost its words; a fleck's row carries almost no ink at all.
art = np.full((300, 800), 255, np.uint8)
art[100:150, 50:750] = 0 # a staff
for x in range(60, 700, 30): # lyrics: many small glyphs, one row
art[200:220, x : x + 14] = 0
art[5:9, 10:14] = 0 # a fleck in the far corner
x0, y0, x1, y1 = _ink_bbox(art)
assert (y0, y1) == (100, 220), f"lyrics kept, fleck dropped: {(y0, y1)}"
assert (x0, x1) == (50, 750), (x0, x1)
assert _ink_bbox(np.full((50, 50), 255, np.uint8)) is None, "blank slice has no box"
# Levels: a white point below the paper value wipes the paper out entirely. # Levels: a white point below the paper value wipes the paper out entirely.
faint = np.full((10, 10), 200, np.uint8) faint = np.full((10, 10), 200, np.uint8)
assert apply_levels(faint, 0, 180).max() == 255 assert apply_levels(faint, 0, 180).max() == 255