Add the render pipeline and bundle export

Project state plus PDF in, finished slice images out. Slices are cut as
polygons rather than row ranges, so a stepped cut yields a slice with a
transparent notch instead of one that covers its neighbour.

Masking paints white, which the ink-to-alpha step turns into full
transparency — the same outcome the spec asks for, one step earlier.

Scale normalises every slice to the median staff height before fitting
the song to 1920px, so a rescanned page sits at the same note size as
its neighbours. The cap only ever shrinks: a song narrower than 1920
stays narrower.

Alpha quantisation rounds to 16 values spanning 0-255 inclusive.
Flooring, as first written, capped full ink at 240 and left every note
6% transparent — caught by decoding an exported slice rather than by
reading the code.

Ketun joululaulu exports 24 slices at a uniform 1489px, under the cap
and correctly not upscaled from its 200 DPI source; Feliz Navidad 20;
Elaman nalka 18.

Closes #21
Closes #22
Closes #23
Closes #24
Closes #25
Closes #27
This commit is contained in:
Esa Kataja
2026-07-28 23:00:53 +03:00
parent 9ed38323ef
commit 974c91a727
4 changed files with 496 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
"""Bundle export — the only channel to noteman (ADR 0001).
song.zip
song.json
original.pdf
001.webp 002.webp …
Array order in `song.json` *is* slice order: one ordering, not two. Markers
nest inside the slice they sit on, so an index appears in exactly one place —
a jump source's `destination`.
"""
from __future__ import annotations
import json
import zipfile
from pathlib import Path
from .pdf import Source
from .project import Project
from .render import render_song
FORMAT_VERSION = 1
METADATA_FIELDS = (
"title",
"subtitle",
"composer",
"original_artist",
"arranger",
"lyricist",
"translator",
"voices",
)
def song_json(project: Project, files: list[str]) -> dict:
payload: dict = {"v": FORMAT_VERSION}
for field in METADATA_FIELDS:
value = project.metadata.get(field)
if value:
payload[field] = value
payload["slices"] = [{"file": name} for name in files]
return payload
def write(project: Project, source: Source, path: Path) -> Path:
"""Render the song and write the bundle. Returns the zip path."""
images = render_song(project, source)
names = [f"{i + 1:03}.webp" for i in range(len(images))]
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
# ZIP_STORED for the images: WebP is already compressed, so deflating it
# only costs time. The JSON is small enough not to care.
with zipfile.ZipFile(path, "w") as zf:
zf.writestr(
"song.json",
json.dumps(song_json(project, names), indent=2, ensure_ascii=False),
zipfile.ZIP_DEFLATED,
)
if project.source.exists():
zf.write(project.source, "original.pdf")
for name, data in zip(names, images):
zf.writestr(name, data, zipfile.ZIP_STORED)
return path
+35
View File
@@ -82,6 +82,35 @@ def _project(args: argparse.Namespace) -> int:
return 0
def _export(args: argparse.Namespace) -> int:
from . import bundle
from .project import Project, default_path
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
path = default_path(source.path)
if path.exists():
project = Project.load(path)
if project.source_changed():
print("WARNING: the PDF has changed since these cuts were made")
else:
detections, heights = [], []
for i in range(len(source)):
gray = page_raster(source, i)
detections.append(detect_page(gray))
heights.append(gray.shape[0])
project = Project.from_detection(source.path, detections, heights)
print("no project file; exporting straight from detection")
out = Path(args.out) if args.out else source.path.with_suffix(".zip")
bundle.write(project, source, out)
size = out.stat().st_size
slices = len(project.kept_slices())
print(f"{out} {slices} slices, {size / 1024:.0f} KB ({size / max(slices, 1) / 1024:.1f} KB/slice)")
source.close()
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="noteman-slicer",
@@ -113,6 +142,12 @@ def main(argv: list[str] | None = None) -> int:
proj.add_argument("--type", choices=[t.value for t in SourceType])
proj.set_defaults(func=_project)
exp = sub.add_parser("export", help="render the song and write a bundle")
exp.add_argument("pdf")
exp.add_argument("--out", help="output zip (default: alongside the PDF)")
exp.add_argument("--type", choices=[t.value for t in SourceType])
exp.set_defaults(func=_export)
args = parser.parse_args(argv)
return args.func(args)
+234
View File
@@ -0,0 +1,234 @@
"""Render project state into finished slice images.
load raster → deskew → levels → content rect → cut → discard
→ trim → scale → pad → ink→alpha → encode
The order is not arbitrary. Levels runs before anything geometric so the trim
bounding box is computed on the image that actually ships; the content
rectangle runs before cutting so margin junk never enters a slice; and trim
runs before scale because the scale factor derives from the widest *trimmed*
slice.
Output is final — nothing downstream reprocesses it (ADR 0001).
"""
from __future__ import annotations
from dataclasses import dataclass
import cv2
import numpy as np
from .detect import deskew, staff_height
from .pdf import Source, page_raster
from .project import Cut, Project
MAX_WIDTH = 1920
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
@dataclass
class SliceImage:
"""One rendered slice, before scaling."""
page: int
index: int
gray: np.ndarray
staff: float | None
@property
def width(self) -> int:
return self.gray.shape[1]
def apply_levels(gray: np.ndarray, black: int, white: int) -> np.ndarray:
"""Map [black, white] onto the full range with a lookup table.
A global LUT, not an adaptive method: CLAHE and adaptive thresholding are
tuned for text and eat the thin stuff on notation — hairpin tips, slur ends,
ledger lines, tapered beams.
"""
if (black, white) == (0, 255):
return gray
lo, hi = min(black, white), max(black, white)
if hi <= lo:
return gray
ramp = np.clip((np.arange(256) - lo) * 255.0 / (hi - lo), 0, 255)
return cv2.LUT(gray, ramp.astype(np.uint8))
def page_pixels(project: Project, source: Source, index: int) -> np.ndarray:
"""A page straightened and levelled, ready to be cut."""
page = project.pages[index]
gray = deskew(page_raster(source, index), page.skew)
black, white = project.page_levels(index)
return apply_levels(gray, black, white)
def _boundary(cut: Cut | None, width: int, height: int, *, bottom: bool) -> list[tuple[int, int]]:
"""A cut as pixel points spanning the page, or the page edge when absent."""
if cut is None:
y = height if bottom else 0
return [(0, y), (width, y)]
return [(int(round(x * width)), int(round(y * height))) for x, y in cut.points]
def slice_mask(project: Project, index: int, slot: int, shape: tuple[int, int]) -> np.ndarray:
"""Which pixels of a page belong to one slice.
A slice bounded by a stepped cut is not rectangular, so this is a polygon
rather than a row range: the top boundary left to right, then the bottom
boundary right to left.
"""
height, width = shape
page = project.pages[index]
above, below = page.bounds(slot)
polygon = _boundary(above, width, height, bottom=False)
polygon += _boundary(below, width, height, bottom=True)[::-1]
mask = np.zeros(shape, np.uint8)
cv2.fillPoly(mask, [np.array(polygon, np.int32)], 255)
# The content rectangle is applied here rather than as a separate crop, so
# margin junk can never enter a slice in the first place.
x0, y0, x1, y1 = project.page_content_rect(index)
box = np.zeros(shape, np.uint8)
box[int(y0 * height) : int(y1 * height), int(x0 * width) : int(x1 * width)] = 255
return cv2.bitwise_and(mask, box)
def _ink_bbox(gray: np.ndarray) -> tuple[int, int, int, int] | None:
"""Tight bounds of the ink, ignoring specks.
One scan fleck at the far left would otherwise anchor the trim and shift
that slice relative to every other one.
"""
ink = (gray < 200).astype(np.uint8)
count, _, stats, _ = cv2.connectedComponentsWithStats(ink, 8)
boxes = [
(
stats[i, cv2.CC_STAT_LEFT],
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 (
min(b[0] for b in boxes),
min(b[1] for b in boxes),
max(b[2] for b in boxes),
max(b[3] for b in boxes),
)
def cut_slice(page: np.ndarray, mask: np.ndarray) -> np.ndarray | None:
"""Extract one slice: everything outside its region becomes paper.
Paper here means white, which the ink→alpha step turns into full
transparency — so a stepped slice's notch composites invisibly on the
viewer's sheet rather than covering the neighbouring system.
"""
isolated = np.where(mask > 0, page, np.uint8(255))
box = _ink_bbox(isolated)
if box is None:
return None
x0, y0, x1, y1 = box
return isolated[y0:y1, x0:x1]
def render_slices(project: Project, source: Source) -> list[SliceImage]:
"""Every kept slice, trimmed but not yet scaled."""
out: list[SliceImage] = []
for index in range(len(project.pages)):
page = page_pixels(project, source, index)
for slot in range(project.pages[index].slice_count):
if project.pages[index].discards[slot]:
continue
gray = cut_slice(page, slice_mask(project, index, slot, page.shape))
if gray is None:
continue # a kept slice that turned out to hold no ink
out.append(SliceImage(index, slot, gray, staff_height(gray, 0, gray.shape[0])))
return out
def scale_song(slices: list[SliceImage], cap: int = MAX_WIDTH) -> list[np.ndarray]:
"""Normalise every slice to one staff height, then fit the song to the cap.
Two steps, both per song. Staff-height normalisation is what makes a
rescanned page — or a re-engraved system — sit at the same note size as its
neighbours; width-based scaling cannot, because width depends on how much
music is in a system rather than on how big it is drawn.
The cap is a ceiling, never a target: a song that comes out narrower stays
narrower, since enlarging a scan past its own resolution buys softness and
bytes and no detail.
"""
if not slices:
return []
measured = [s.staff for s in slices if s.staff]
target = float(np.median(measured)) if measured else 0.0
factors = [target / s.staff if (target and s.staff) else 1.0 for s in slices]
widest = max(s.width * f for s, f in zip(slices, factors))
song = min(1.0, cap / widest) if widest else 1.0
out = []
for s, f in zip(slices, factors):
k = f * song
if abs(k - 1.0) < 1e-3:
out.append(s.gray)
continue
interp = cv2.INTER_AREA if k < 1 else cv2.INTER_CUBIC
out.append(cv2.resize(s.gray, None, fx=k, fy=k, interpolation=interp))
return out
def pad_right(images: list[np.ndarray]) -> list[np.ndarray]:
"""Bring every slice to the song's width, flush left.
A short system simply ends earlier; the padding is paper, so it disappears
when ink becomes alpha.
"""
if not images:
return []
width = max(i.shape[1] for i in images)
return [
i
if i.shape[1] == width
else cv2.copyMakeBorder(i, 0, 0, 0, width - i.shape[1], cv2.BORDER_CONSTANT, value=255)
for i in images
]
def encode(gray: np.ndarray) -> bytes:
"""Ink black, paper transparent, lossless WebP.
Lossless rather than lossy not because lossy looks bad — measured, it
doesn't — but because it is 58% *larger* on line art (ADR 0003).
"""
alpha = 255 - gray
if ALPHA_LEVELS < 256:
# Round to the nearest of ALPHA_LEVELS values spanning 0255 inclusive.
# Flooring instead would cap full ink at 240 and leave every note
# slightly transparent.
step = 255 / (ALPHA_LEVELS - 1)
alpha = (np.round(alpha / step) * step).astype(np.uint8)
rgba = np.zeros((*gray.shape, 4), np.uint8)
rgba[:, :, 3] = alpha
ok, buf = cv2.imencode(".webp", rgba, [cv2.IMWRITE_WEBP_QUALITY, 101])
if not ok:
raise RuntimeError("WebP encoding failed")
return buf.tobytes()
def render_song(project: Project, source: Source) -> list[bytes]:
"""The whole raster pipeline: project + PDF in, finished slice images out."""
slices = render_slices(project, source)
return [encode(image) for image in pad_right(scale_song(slices))]
+162
View File
@@ -0,0 +1,162 @@
"""Runnable check for the render pipeline and bundle export."""
from __future__ import annotations
import json
import sys
import zipfile
from pathlib import Path
import cv2
import numpy as np
import pymupdf
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from noteman_slicer import bundle # noqa: E402
from noteman_slicer.detect import detect_page # noqa: E402
from noteman_slicer.pdf import open_source, page_raster # noqa: E402
from noteman_slicer.project import Cut, Project # noqa: E402
from noteman_slicer.render import ( # noqa: E402
ALPHA_LEVELS,
apply_levels,
encode,
pad_right,
render_slices,
scale_song,
)
W, H = 1200, 1600
GAP = 15
def _system(page: np.ndarray, top: int, right: int) -> None:
"""A bracket plus two staves, with a lyric line under each."""
page[top : top + 200, 100:104] = 0
for staff in (top, top + 140):
for i in range(5):
page[staff + i * GAP : staff + i * GAP + 2, 110:right] = 0
page[staff + 90 : staff + 105, 200 : right - 100] = 0
def _scan_pdf(path: Path) -> None:
art = np.full((H, W), 255, np.uint8)
art[40:60, 400:800] = 0 # title, far from any system
_system(art, 300, 1100)
_system(art, 800, 900) # narrower: exercises the right pad
pix = pymupdf.Pixmap(pymupdf.csGRAY, W, H, bytearray(art.tobytes()), False)
doc = pymupdf.open()
page = doc.new_page(width=595, height=842)
page.insert_image(page.rect, pixmap=pix)
doc.save(path)
def main() -> int:
tmp = Path(__file__).with_name("_tmp")
tmp.mkdir(exist_ok=True)
pdf = tmp / "scan.pdf"
_scan_pdf(pdf)
source = open_source(pdf)
gray = page_raster(source, 0)
project = Project.from_detection(pdf, [detect_page(gray)], [gray.shape[0]])
slices = render_slices(project, source)
assert len(slices) == 2, f"expected 2 kept slices, got {len(slices)}"
# The title is far from any bracket, so it is not in a kept slice: both
# slices must be shorter than the gap between the systems.
assert all(s.gray.shape[0] < 400 for s in slices), [s.gray.shape for s in slices]
# System 2 is drawn narrower, so before padding the widths differ.
assert slices[0].width != slices[1].width, "the fixture should differ in width"
scaled = scale_song(slices, cap=4000) # a cap far above the fixture
assert all(abs(a.shape[1] - b.width) <= 2 for a, b in zip(scaled, slices)), (
"never upscale: a song narrower than the cap must be left alone"
)
padded = pad_right(scale_song(slices))
assert len({p.shape[1] for p in padded}) == 1, "slices must share one width"
assert max(p.shape[1] for p in padded) <= 1920
rgba = cv2.imdecode(np.frombuffer(encode(padded[0]), np.uint8), cv2.IMREAD_UNCHANGED)
assert rgba.shape[2] == 4
assert rgba[:, :, :3].max() == 0, "ink must be pure black"
assert rgba[:, :, 3].max() == 255, "full ink must be fully opaque"
assert rgba[:, :, 3].min() == 0, "paper must be fully transparent"
assert len(np.unique(rgba[:, :, 3])) <= ALPHA_LEVELS
# Levels: a white point below the paper value wipes the paper out entirely.
faint = np.full((10, 10), 200, np.uint8)
assert apply_levels(faint, 0, 180).max() == 255
# The Engel case: a section label printed in the left margin at a height
# that belongs to the *next* system. A straight cut cannot separate it from
# the previous system's lyrics; a stepped one can.
label_top, label_bottom = 620, 680
labelled = tmp / "labelled.pdf"
art = np.full((H, W), 255, np.uint8)
_system(art, 300, 1100)
_system(art, 800, 900)
art[label_top:label_bottom, 120:300] = 0 # the label
art[label_top:label_bottom, 500:1000] = 0 # system 1's trailing lyrics, same rows
pix = pymupdf.Pixmap(pymupdf.csGRAY, W, H, bytearray(art.tobytes()), False)
doc = pymupdf.open()
doc.new_page(width=595, height=842).insert_image(pymupdf.Rect(0, 0, 595, 842), pixmap=pix)
doc.save(labelled)
src2 = open_source(labelled)
g2 = page_raster(src2, 0)
proj2 = Project.from_detection(labelled, [detect_page(g2)], [g2.shape[0]])
page = proj2.pages[0]
scale = g2.shape[0] / H
def ink(images: list) -> list[int]:
"""Ink in the left margin of each slice — where the label sits."""
return [int((i.gray[:, : int(i.width * 0.3)] < 128).sum()) for i in images]
# Straight cut through the middle of that band: the label goes with
# whichever side the line falls on, and cannot be separated.
band_mid = (label_top + label_bottom) / 2 * scale / g2.shape[0]
page.cuts[1] = Cut.straight(band_mid)
straight_ink = ink(render_slices(proj2, src2))
# Stepped: above the label on the left, below the lyrics on the right.
above = (label_top - 10) * scale / g2.shape[0]
below = (label_bottom + 10) * scale / g2.shape[0]
page.cuts[1] = Cut([(0.0, above), (0.35, above), (0.35, below), (1.0, below)])
stepped_ink = ink(render_slices(proj2, src2))
# The straight cut splits the label down the middle; the stepped cut gives
# all of it to the lower slice and none to the upper.
assert stepped_ink[1] > straight_ink[1], (
f"the label must move into the lower slice: {straight_ink}{stepped_ink}"
)
assert stepped_ink[0] < straight_ink[0], (
f"and out of the upper one: {straight_ink}{stepped_ink}"
)
src2.close()
labelled.unlink()
# Bundle.
out = bundle.write(project, source, tmp / "song.zip")
with zipfile.ZipFile(out) as zf:
names = zf.namelist()
assert "song.json" in names and "original.pdf" in names, names
meta = json.loads(zf.read("song.json"))
assert meta["v"] == 1
files = [s["file"] for s in meta["slices"]]
assert files == ["001.webp", "002.webp"], files
assert all(f in names for f in files)
source.close()
for f in (pdf, out):
f.unlink()
tmp.rmdir()
print("ok")
return 0
if __name__ == "__main__":
sys.exit(main())