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
+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)