Opening an exported song starts a fresh session from detection instead of resuming: cuts, discards and metadata do not carry over, so a re-cut never inherits decisions that have already shipped. --resume overrides it on edit, export and project. This reverses what was agreed in planning and written into docs/spec.md and CONTEXT.md, which promised resume-across-sessions and re-export. Both are corrected. The cost is deliberate and worth stating: changing the width cap or adding the SVG renderer later now means re-cutting each song by hand rather than regenerating every bundle from its project file. Export records the flag in bundle.write, so no caller can forget it. Also removed --refit and the Auto-fit buttons, which were added without being asked for and whose only purpose - migrating projects made before the content rectangle was proposed - disappears once exported projects start fresh. Reset now restores detection's proposal rather than the whole page: clearing to full width would undo the thing the rectangle exists for, so one button covers it. open_project() replaces four copies of load-or-detect across the CLI and the editor.
540 lines
20 KiB
Python
540 lines
20 KiB
Python
"""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, open_project
|
|
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.setToolTip("Back to the rectangle detection proposed for this page")
|
|
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:
|
|
"""Back to what detection proposed for this page.
|
|
|
|
Not to the whole page: the proposal is what excludes the scan-edge
|
|
junk, so clearing to full width would undo the thing the rectangle
|
|
exists for. The preview is already deskewed, so the sweep is skipped.
|
|
"""
|
|
self.project.pages[self.index].content_rect = detect_page(
|
|
self._preview(self.index), skew=0.0
|
|
).content
|
|
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\n\n"
|
|
"This project is now spent — opening the PDF again starts a fresh "
|
|
"session from detection.",
|
|
)
|
|
|
|
def closeEvent(self, event) -> None:
|
|
self._save()
|
|
super().closeEvent(event)
|
|
|
|
|
|
def launch(pdf: Path, source_type=None, resume: bool = False) -> int:
|
|
app = QApplication(sys.argv[:1])
|
|
source = open_source(pdf, source_type)
|
|
|
|
# An exported project is spent: this opens a fresh session from detection
|
|
# rather than resuming decisions that have already been shipped.
|
|
project = open_project(source, resume=resume)
|
|
if project.path is not None and 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.",
|
|
)
|
|
|
|
window = Editor(source, project)
|
|
window.resize(1500, 950)
|
|
window.show()
|
|
return app.exec()
|