"""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()