Files
noteman-slicer/noteman_slicer/editor.py
T
Esa Kataja c00a00bb2a Propose the content rectangle from the staff lines
Both scan problems reported from testing came from the same place: the
content rectangle defaulted to the whole page, so the mechanism meant
to handle margin junk never engaged. Ketun joululaulu has a vertical
scan streak down the right margin and Engel a shadow, and because trim
is tight and per slice, either one sets that slice's width, which sets
the song's widest slice, which scales the whole song down.

Detection can propose it. Staff lines are long horizontal runs; scan
shadows, spine darkening and glass streaks are vertical, so a wide flat
opening keeps one and erases the other. Three corrections were needed
against the corpus:

- Search only rows inside detected systems. A horizontal artefact above
  or below the music is itself a long horizontal run reaching the paper
  edge, which put Engel's left bound at 0.
- Take a percentile of the staff-line extents, not the maximum. Where
  an artefact touches a staff line the two merge into one component: on
  Ketun p2 the merged line ends at 1575px against 1544px on the clean
  page.
- Take the left bound from the brackets too. A bracket sits left of
  every staff line, so a staff-line bound crops it off — visible
  immediately when comparing exported slices.

Anchors are now a dataclass carrying their left edge rather than a
(top, bottom) tuple.

Engel now drops 12-14% of page width and its music fills 1920px instead
of leaving the shadow's dead space; Ketun drops 12%.

Existing projects keep their saved rectangle; the editor's new Auto-fit
and Auto-fit all buttons re-propose it without disturbing cuts.
2026-07-28 23:28:45 +03:00

561 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, 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)
rect_row = QHBoxLayout()
fit = QPushButton("Auto-fit")
fit.setToolTip("Propose the content rectangle from the staff lines on this page")
fit.clicked.connect(lambda: self._autofit_rect(False))
fit_all = QPushButton("Auto-fit all")
fit_all.clicked.connect(lambda: self._autofit_rect(True))
reset = QPushButton("Reset")
reset.clicked.connect(self._reset_rect)
for button in (fit, fit_all, reset):
rect_row.addWidget(button)
form.addRow("Content rect", rect_row)
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 _autofit_rect(self, every_page: bool) -> None:
"""Re-propose the content rectangle from the staff lines.
Detection does this for new projects; this is how a project made before
it existed, or one whose rectangle was dragged wrong, gets it back.
"""
pages = range(len(self.project.pages)) if every_page else [self.index]
for i in pages:
# The preview is already deskewed, so the sweep is skipped.
proposal = detect_page(self._preview(i), skew=0.0)
self.project.pages[i].content_rect = proposal.content
self.view.redraw()
self._touched()
self.statusBar().showMessage(
f"content rectangle fitted on {len(list(pages))} page(s)", 2000
)
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()