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.
473 lines
18 KiB
Python
473 lines
18 KiB
Python
"""Project state: everything the human decided, on disk beside the PDF.
|
||
|
||
The bundle is generated from this, so export is a pure function of the project
|
||
plus the PDF. That buys crash safety, resume across sessions, and re-export —
|
||
change the width cap or fix one cut and every song regenerates without
|
||
repeating any human work.
|
||
|
||
All geometry is stored in **normalised page coordinates** (0–1 of the deskewed
|
||
page), so the file is independent of DPI and of which renderer produced it.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from statistics import median
|
||
|
||
from .detect import PageDetection
|
||
|
||
FORMAT_VERSION = 1
|
||
SUFFIX = ".slicer.json"
|
||
|
||
Point = tuple[float, float]
|
||
|
||
|
||
@dataclass
|
||
class Cut:
|
||
"""A boundary splitting one slice into two, spanning the page left to right.
|
||
|
||
A polyline, not a line. Two points is the ordinary straight case; extra
|
||
vertices handle a section label printed in the left margin at the same
|
||
height as the previous system's lyrics, where no horizontal line separates
|
||
the two (see docs/spec.md).
|
||
"""
|
||
|
||
points: list[Point]
|
||
|
||
@classmethod
|
||
def straight(cls, y: float) -> Cut:
|
||
return cls([(0.0, y), (1.0, y)])
|
||
|
||
@property
|
||
def straight_y(self) -> float | None:
|
||
"""The single y of a straight cut, or None if it steps."""
|
||
ys = {y for _, y in self.points}
|
||
return self.points[0][1] if len(ys) == 1 else None
|
||
|
||
def y_at(self, x: float) -> float:
|
||
"""Height of the boundary at a horizontal position."""
|
||
pts = self.points
|
||
if x <= pts[0][0]:
|
||
return pts[0][1]
|
||
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
|
||
if x <= x1:
|
||
if x1 == x0:
|
||
return y1
|
||
return y0 + (y1 - y0) * (x - x0) / (x1 - x0)
|
||
return pts[-1][1]
|
||
|
||
@property
|
||
def lowest(self) -> float:
|
||
return max(y for _, y in self.points)
|
||
|
||
@property
|
||
def highest(self) -> float:
|
||
return min(y for _, y in self.points)
|
||
|
||
|
||
# noteman's enum, verbatim. Real coupling between two repos: adding a type
|
||
# means changing both. Order is the order they appear in the editor's picker.
|
||
MARKER_TYPES = (
|
||
"rehearsal_letter",
|
||
"section_label",
|
||
"segno",
|
||
"coda",
|
||
"fine",
|
||
"repeat_start",
|
||
"repeat_end",
|
||
"volta",
|
||
"to_coda",
|
||
"ds_al_coda",
|
||
"ds_al_fine",
|
||
"dc_al_coda",
|
||
"dc_al_fine",
|
||
"generic_jump",
|
||
)
|
||
|
||
# The types that carry free text.
|
||
LABELLED_TYPES = frozenset({"rehearsal_letter", "section_label", "volta"})
|
||
|
||
# The types that send the reader elsewhere. Every one stores its target
|
||
# explicitly rather than resolving by type at read time, so the bundle is
|
||
# self-describing and a score with two codas simply works.
|
||
JUMP_TYPES = frozenset(
|
||
{"to_coda", "ds_al_coda", "ds_al_fine", "dc_al_coda", "dc_al_fine", "generic_jump"}
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class Marker:
|
||
"""A semantic tag on a slice, used by noteman's navigation."""
|
||
|
||
type: str
|
||
label: str | None = None
|
||
# (page, slot) of the target slice, for jump sources. Positional like the
|
||
# slices themselves; resolved to a bundle index at export.
|
||
destination: tuple[int, int] | None = None
|
||
|
||
@property
|
||
def is_jump(self) -> bool:
|
||
return self.type in JUMP_TYPES
|
||
|
||
def describe(self) -> str:
|
||
text = self.type
|
||
if self.label:
|
||
text += f" “{self.label}”"
|
||
if self.destination:
|
||
text += f" → p{self.destination[0] + 1}s{self.destination[1] + 1}"
|
||
return text
|
||
|
||
|
||
@dataclass
|
||
class Voice:
|
||
"""One staff of a re-engraved system.
|
||
|
||
`notes` and `lyrics` are raw LilyPond, so slurs, dynamics, tuplets and the
|
||
`\\laissezVibrer` / `\\repeatTie` idiom for ties crossing a slice boundary
|
||
all work without the form knowing anything about them.
|
||
"""
|
||
|
||
clef: str = "treble"
|
||
notes: str = ""
|
||
lyrics: str = ""
|
||
|
||
|
||
@dataclass
|
||
class Replacement:
|
||
"""A system engraved with LilyPond in place of the scanned one.
|
||
|
||
Key and time are per song in practice — Kaipaava is 4♭ and 4/4 from first
|
||
system to last — so they live on the project and are only set here when a
|
||
slice genuinely differs.
|
||
"""
|
||
|
||
voices: list[Voice] = field(default_factory=list)
|
||
key: str | None = None
|
||
time: str | None = None
|
||
# 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.
|
||
print_time: bool = False
|
||
|
||
|
||
@dataclass
|
||
class Page:
|
||
"""One page's decisions. `cuts` are ordered top to bottom."""
|
||
|
||
skew: float = 0.0
|
||
cuts: list[Cut] = field(default_factory=list)
|
||
discards: list[bool] = field(default_factory=lambda: [False])
|
||
# One list per slice, parallel to `discards`.
|
||
markers: list[list[Marker]] = field(default_factory=lambda: [[]])
|
||
# A re-engraved system per slice, when the scan is past saving. None for
|
||
# the ordinary case, which is nearly all of them.
|
||
replacements: list[Replacement | None] = field(default_factory=lambda: [None])
|
||
content_rect: tuple[float, float, float, float] | None = None
|
||
levels: tuple[int, int] | None = None
|
||
|
||
@property
|
||
def slice_count(self) -> int:
|
||
return len(self.cuts) + 1
|
||
|
||
def bounds(self, index: int) -> tuple[Cut | None, Cut | None]:
|
||
"""The cuts above and below a slice; None means the page edge."""
|
||
above = self.cuts[index - 1] if index > 0 else None
|
||
below = self.cuts[index] if index < len(self.cuts) else None
|
||
return above, below
|
||
|
||
def add_cut(self, cut: Cut) -> int:
|
||
"""Insert a cut, splitting the slice it lands in. Returns its index."""
|
||
y = cut.points[0][1]
|
||
index = sum(1 for c in self.cuts if c.points[0][1] < y)
|
||
self.cuts.insert(index, cut)
|
||
# The split slice keeps its flag on both halves. Its markers stay with
|
||
# the upper half: a marker sits on a printed symbol, and splitting a
|
||
# slice cannot say which side that symbol landed on — leaving them put
|
||
# is at least predictable, and moving one is a click.
|
||
self.discards.insert(index, self.discards[index])
|
||
self.markers.insert(index + 1, [])
|
||
self.replacements.insert(index + 1, None)
|
||
return index
|
||
|
||
def remove_cut(self, index: int) -> None:
|
||
"""Drop a cut, merging the two slices it separated."""
|
||
self.cuts.pop(index)
|
||
merged = self.discards[index] and self.discards[index + 1]
|
||
self.discards.pop(index + 1)
|
||
self.discards[index] = merged
|
||
self.markers[index].extend(self.markers.pop(index + 1))
|
||
# Two engraved halves cannot be merged, so the upper one wins.
|
||
below = self.replacements.pop(index + 1)
|
||
self.replacements[index] = self.replacements[index] or below
|
||
|
||
def remember_clefs(self, project: Project, slot: int) -> None:
|
||
"""Carry this slice's clefs forward as the song's defaults."""
|
||
replacement = self.replacements[slot]
|
||
if replacement and replacement.voices:
|
||
project.clefs = [v.clef for v in replacement.voices]
|
||
|
||
|
||
@dataclass
|
||
class Project:
|
||
source: Path
|
||
source_hash: str
|
||
pages: list[Page]
|
||
content_rect: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0)
|
||
levels: tuple[int, int] = (0, 255)
|
||
metadata: dict[str, str] = field(default_factory=dict)
|
||
# Engraving defaults for the song. Key and time are set once and inherited
|
||
# by every replacement; `clefs` remembers what each voice position was last
|
||
# given, so the second re-engraved system in a song opens already filled in.
|
||
key: str = "c"
|
||
time: str = "4/4"
|
||
clefs: list[str] = field(default_factory=list)
|
||
# Shrink the archival PDF carried in the bundle by converting its scanned
|
||
# pages to bilevel. Off by default: it is lossy on the copy kept for
|
||
# printing, and on some scans it breaks staff lines.
|
||
optimise_pdf: bool = False
|
||
path: Path | None = None
|
||
# Set once the song has been exported. A project is spent at that point:
|
||
# opening the PDF again starts a fresh session from detection rather than
|
||
# resuming, so a re-cut never begins from stale decisions. `--resume`
|
||
# overrides it when the old state really is wanted.
|
||
exported: bool = False
|
||
|
||
# -- geometry helpers -------------------------------------------------
|
||
|
||
def page_content_rect(self, index: int) -> tuple[float, float, float, float]:
|
||
return self.pages[index].content_rect or self.content_rect
|
||
|
||
def page_levels(self, index: int) -> tuple[int, int]:
|
||
return self.pages[index].levels or self.levels
|
||
|
||
def kept_slices(self) -> list[tuple[int, int]]:
|
||
"""(page, slice) of every slice that will be exported, in song order."""
|
||
return [
|
||
(p, s)
|
||
for p, page in enumerate(self.pages)
|
||
for s in range(page.slice_count)
|
||
if not page.discards[s]
|
||
]
|
||
|
||
# -- persistence ------------------------------------------------------
|
||
|
||
@classmethod
|
||
def from_detection(
|
||
cls, source: Path, detections: list[PageDetection], heights: list[int]
|
||
) -> Project:
|
||
"""Seed a project from detection. Every value here is a suggestion.
|
||
|
||
Detection emits cuts only *between* systems, so a page would otherwise
|
||
have exactly as many slices as it has systems, with the header and
|
||
footer inside the first and last. The boundary cuts that isolate them —
|
||
and the discard flags that drop them — are a slicing decision, not a
|
||
detection result, so they are added here.
|
||
"""
|
||
pages = []
|
||
for detection, height in zip(detections, heights):
|
||
ys = list(detection.cuts)
|
||
leading = trailing = False
|
||
|
||
if detection.systems:
|
||
first, last = detection.systems[0], detection.systems[-1]
|
||
if first.top > 0:
|
||
ys.insert(0, first.top // 2)
|
||
leading = True
|
||
if last.bottom < height:
|
||
ys.append((last.bottom + height) // 2)
|
||
trailing = True
|
||
|
||
discards = [False] * (len(ys) + 1)
|
||
if leading:
|
||
discards[0] = True
|
||
if trailing:
|
||
discards[-1] = True
|
||
|
||
pages.append(
|
||
Page(
|
||
skew=detection.skew,
|
||
cuts=[Cut.straight(y / height) for y in ys],
|
||
discards=discards,
|
||
markers=[[] for _ in discards],
|
||
replacements=[None] * len(discards),
|
||
# Per page, not per song: scans drift, so the margin junk
|
||
# sits in a different place on each one.
|
||
content_rect=detection.content,
|
||
)
|
||
)
|
||
# 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:
|
||
"""Atomic write, so a crash mid-save cannot destroy the previous state."""
|
||
target = Path(path or self.path or default_path(self.source))
|
||
payload = {
|
||
"v": FORMAT_VERSION,
|
||
"source": self.source.name,
|
||
"source_hash": self.source_hash,
|
||
"exported": self.exported,
|
||
"content_rect": list(self.content_rect),
|
||
"levels": list(self.levels),
|
||
"metadata": self.metadata,
|
||
"key": self.key,
|
||
"time": self.time,
|
||
"clefs": self.clefs,
|
||
"optimise_pdf": self.optimise_pdf,
|
||
"pages": [
|
||
{
|
||
"skew": page.skew,
|
||
"cuts": [[list(p) for p in cut.points] for cut in page.cuts],
|
||
"discards": page.discards,
|
||
"markers": [
|
||
[
|
||
{
|
||
"type": m.type,
|
||
**({"label": m.label} if m.label else {}),
|
||
**(
|
||
{"destination": list(m.destination)}
|
||
if m.destination
|
||
else {}
|
||
),
|
||
}
|
||
for m in slot
|
||
]
|
||
for slot in page.markers
|
||
],
|
||
"replacements": [
|
||
None
|
||
if r is None
|
||
else {
|
||
"voices": [
|
||
{"clef": v.clef, "notes": v.notes, "lyrics": v.lyrics}
|
||
for v in r.voices
|
||
],
|
||
**({"key": r.key} if r.key else {}),
|
||
**({"time": r.time} if r.time else {}),
|
||
**({"print_time": True} if r.print_time else {}),
|
||
}
|
||
for r in page.replacements
|
||
],
|
||
"content_rect": list(page.content_rect) if page.content_rect else None,
|
||
"levels": list(page.levels) if page.levels else None,
|
||
}
|
||
for page in self.pages
|
||
],
|
||
}
|
||
tmp = target.with_suffix(target.suffix + ".tmp")
|
||
tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
|
||
tmp.replace(target)
|
||
self.path = target
|
||
return target
|
||
|
||
@classmethod
|
||
def load(cls, path: Path, source: Path | None = None) -> Project:
|
||
path = Path(path)
|
||
data = json.loads(path.read_text())
|
||
if data.get("v") != FORMAT_VERSION:
|
||
raise ValueError(f"unsupported project version {data.get('v')!r}")
|
||
pdf = Path(source) if source else path.parent / data["source"]
|
||
pages = [
|
||
Page(
|
||
skew=page["skew"],
|
||
cuts=[Cut([tuple(p) for p in cut]) for cut in page["cuts"]],
|
||
discards=page["discards"],
|
||
markers=[
|
||
[
|
||
Marker(
|
||
type=m["type"],
|
||
label=m.get("label"),
|
||
destination=tuple(m["destination"]) if m.get("destination") else None,
|
||
)
|
||
for m in slot
|
||
]
|
||
for slot in page.get("markers", [[] for _ in page["discards"]])
|
||
],
|
||
replacements=[
|
||
# A bare string is the short-lived raw-source form, which
|
||
# never shipped: dropped rather than migrated, so the rest
|
||
# of the project still opens.
|
||
None
|
||
if not isinstance(r, dict)
|
||
else Replacement(
|
||
voices=[
|
||
Voice(
|
||
clef=v.get("clef", "treble"),
|
||
notes=v.get("notes", ""),
|
||
lyrics=v.get("lyrics", ""),
|
||
)
|
||
for v in r.get("voices", [])
|
||
],
|
||
key=r.get("key"),
|
||
time=r.get("time"),
|
||
print_time=r.get("print_time", False),
|
||
)
|
||
for r in page.get("replacements", [None] * len(page["discards"]))
|
||
],
|
||
content_rect=tuple(page["content_rect"]) if page["content_rect"] else None,
|
||
levels=tuple(page["levels"]) if page["levels"] else None,
|
||
)
|
||
for page in data["pages"]
|
||
]
|
||
return cls(
|
||
source=pdf,
|
||
source_hash=data["source_hash"],
|
||
pages=pages,
|
||
content_rect=tuple(data["content_rect"]),
|
||
levels=tuple(data["levels"]),
|
||
metadata=data.get("metadata", {}),
|
||
path=path,
|
||
exported=data.get("exported", False),
|
||
key=data.get("key", "c"),
|
||
time=data.get("time", "4/4"),
|
||
clefs=data.get("clefs", []),
|
||
optimise_pdf=data.get("optimise_pdf", False),
|
||
)
|
||
|
||
def source_changed(self) -> bool:
|
||
"""True when the PDF no longer matches what these decisions were made on."""
|
||
return self.source.exists() and hash_file(self.source) != self.source_hash
|
||
|
||
|
||
def open_project(source, *, resume: bool = False) -> Project:
|
||
"""The project for a PDF: resumed, or a fresh session from detection.
|
||
|
||
A project that has been exported is spent. Opening the PDF again starts
|
||
over from detection rather than resuming, so a re-cut never inherits stale
|
||
decisions. `resume` overrides that when the old state really is wanted.
|
||
"""
|
||
from .detect import detect_page
|
||
from .pdf import page_raster
|
||
|
||
path = default_path(source.path)
|
||
if path.exists():
|
||
existing = Project.load(path)
|
||
if resume or not existing.exported:
|
||
return existing
|
||
|
||
detections, heights = [], []
|
||
for i in range(len(source)):
|
||
gray = page_raster(source, i)
|
||
detections.append(detect_page(gray))
|
||
heights.append(gray.shape[0])
|
||
return Project.from_detection(source.path, detections, heights)
|
||
|
||
|
||
def default_path(source: Path) -> Path:
|
||
return Path(source).with_suffix(SUFFIX)
|
||
|
||
|
||
def hash_file(path: Path) -> str:
|
||
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|