Compare commits
2
Commits
9ed38323ef
...
bc19eae111
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc19eae111 | ||
|
|
974c91a727 |
@@ -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
|
||||||
@@ -82,6 +82,41 @@ def _project(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
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:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="noteman-slicer",
|
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.add_argument("--type", choices=[t.value for t in SourceType])
|
||||||
proj.set_defaults(func=_project)
|
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)
|
args = parser.parse_args(argv)
|
||||||
return args.func(args)
|
return args.func(args)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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))]
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user