Files
noteman-slicer/noteman_slicer/cli.py
T
Esa Kataja 974c91a727 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
2026-07-28 23:00:53 +03:00

157 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Command line entry point."""
from __future__ import annotations
import argparse
import sys
import numpy as np
from pathlib import Path
from . import __version__, overlay
from .detect import detect_page
from .pdf import SourceType, open_source, page_raster
def _info(args: argparse.Namespace) -> int:
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
note = f" (detected {source.detected.value}, overridden)" if source.overridden else ""
print(f"{source.path.name}: {source.type.value}{note}, {len(source)} pages")
for i in range(len(source)):
h, w = page_raster(source, i).shape
print(f" p{i + 1:<3} {w}x{h}")
source.close()
return 0
def _detect(args: argparse.Namespace) -> int:
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
pages = range(len(source)) if args.page is None else [args.page - 1]
for i in pages:
gray = page_raster(source, i)
detection = detect_page(gray)
staves = [s.staff_height for s in detection.systems if s.staff_height]
note = f", staff {np.median(staves):.0f}px" if staves else ""
print(
f"p{i + 1:<3} skew {detection.skew:+.2f}° "
f"{len(detection.systems)} systems{note}"
f"{' (no bracket)' if detection.bracketless else ''}"
)
for n, system in enumerate(detection.systems, 1):
print(f" sys{n}: {system.top}{system.bottom} h={system.height}")
overlay.write(gray, detection, out / f"{source.path.stem}-p{i + 1:02}.png")
print(f"overlays written to {out}/")
source.close()
return 0
def _project(args: argparse.Namespace) -> int:
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() and not args.force:
project = Project.load(path)
print(f"{path.name}: loaded")
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(f"{path.name}: created from detection")
kept = project.kept_slices()
for i, page in enumerate(project.pages):
flags = "".join("." if d else "#" for d in page.discards)
print(f" p{i + 1:<3} skew {page.skew:+.2f}° {page.slice_count} slices [{flags}]")
print(f" {len(kept)} slices kept, {sum(p.slice_count for p in project.pages) - len(kept)} discarded")
if args.save:
print(f" saved to {project.save(path)}")
source.close()
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",
description="Cut score PDFs into noteman's slice images and markers.",
)
parser.add_argument("--version", action="version", version=__version__)
sub = parser.add_subparsers(dest="command", required=True)
info = sub.add_parser("info", help="classify a PDF and report its page rasters")
info.add_argument("pdf")
info.add_argument(
"--type",
choices=[t.value for t in SourceType],
help="override source-type detection",
)
info.set_defaults(func=_info)
det = sub.add_parser("detect", help="run detection and write debug overlays")
det.add_argument("pdf")
det.add_argument("--out", default="overlays", help="output directory")
det.add_argument("--page", type=int, help="single 1-based page instead of all")
det.add_argument("--type", choices=[t.value for t in SourceType])
det.set_defaults(func=_detect)
proj = sub.add_parser("project", help="create or inspect the project file for a PDF")
proj.add_argument("pdf")
proj.add_argument("--save", action="store_true", help="write the project file")
proj.add_argument("--force", action="store_true", help="re-detect, discarding existing state")
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)
if __name__ == "__main__":
sys.exit(main())