Scanned scores are black ink on white paper stored as 8-bit greyscale or RGB, which costs several times what the same page costs as a bilevel image. Across an 11-song corpus this is 27.6 MB to 7.7 MB; Engel's bundle goes from 5997 KB to 2092 KB with byte-identical slices, since only the archived copy changes. Three approaches were measured and discarded first, which is worth recording because two of them are the obvious ones. Converting RGB to greyscale and re-encoding makes these files 20-86% LARGER: the source JPEGs are already near 0.7 bits per pixel, so re-encoding adds generation loss and spends more bits than the original did, and dropping chroma recovers nothing because JPEG already subsamples it. Lossless structural optimisation gains 0.1%, because images are 99% of every file and there are no duplicates. Downsampling works but 300 DPI is print resolution, and the PDF exists to be printed. Two failure modes were found by looking at output rather than at byte counts, and both are now refused: - A scan at ~115 DPI came back with broken staff lines. Guarded on resolution as the image is *placed on the page*, so a tiled scan with 126 small images still qualifies where a pixel count would reject it. - Cover artwork was flattened to grey. Guarded on chroma: artwork measures 44% off-grey against 3% for sensor tint on a greyscale scan. The first threshold of 2% was a false positive that cost 685 KB on one song for nothing; 10% sits in the gap with room either side. Exposed as a button rather than a checkbox. It reports what it skipped and why, and shows a before/after crop, because the failure it can produce is obvious at a glance and invisible in a size figure. Off by default: this is lossy on the copy kept for printing.
889 lines
34 KiB
Python
889 lines
34 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,
|
|
QIntValidator,
|
|
QKeySequence,
|
|
QPainter,
|
|
QPen,
|
|
QPixmap,
|
|
QPolygonF,
|
|
)
|
|
from PySide6.QtWidgets import (
|
|
QApplication,
|
|
QDoubleSpinBox,
|
|
QFileDialog,
|
|
QFormLayout,
|
|
QGraphicsScene,
|
|
QGraphicsView,
|
|
QComboBox,
|
|
QDialog,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QLineEdit,
|
|
QListWidget,
|
|
QMainWindow,
|
|
QMessageBox,
|
|
QPushButton,
|
|
QScrollArea,
|
|
QSizePolicy,
|
|
QSlider,
|
|
QSplitter,
|
|
QToolButton,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from . import bundle, lilypond
|
|
from .bundle import METADATA_FIELDS, NUMERIC_FIELDS
|
|
from .detect import deskew, detect_page
|
|
from .pdf import Source, open_source, page_raster
|
|
from .project import (
|
|
JUMP_TYPES,
|
|
LABELLED_TYPES,
|
|
MARKER_TYPES,
|
|
Cut,
|
|
Marker,
|
|
Project,
|
|
open_project,
|
|
)
|
|
from .render import apply_levels
|
|
|
|
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
|
|
PANEL_WIDTH = 340 # starting width only; the splitter takes over from there
|
|
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)
|
|
_MARKER = QColor(150, 60, 190)
|
|
_ENGRAVED = QColor(200, 120, 0)
|
|
_ENGRAVED_WASH = QColor(230, 160, 30, 55)
|
|
_BADGE_Z = 10
|
|
|
|
|
|
class PageView(QGraphicsView):
|
|
"""Pan, zoom, and direct manipulation of cuts and the content rectangle."""
|
|
|
|
changed = Signal()
|
|
selection_changed = Signal()
|
|
picked = Signal(int, int) # page, slot — a jump target chosen by clicking
|
|
engrave_requested = 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.picking = False
|
|
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 slot in range(self.page.slice_count):
|
|
above, _ = self.page.bounds(slot)
|
|
top = 0 if above is None else int(above.lowest * h)
|
|
x = w * 0.015
|
|
|
|
if self.page.replacements[slot]:
|
|
# A wash over the whole slice, not just a label: this slice
|
|
# will not ship the pixels underneath it, which is worth
|
|
# noticing without hunting for small text.
|
|
scene.addPolygon(
|
|
self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_ENGRAVED_WASH)
|
|
)
|
|
x = self._badge(scene, x, top + h * 0.004, "ENGRAVED", _ENGRAVED, w)
|
|
|
|
markers = self.page.markers[slot]
|
|
if markers:
|
|
self._badge(
|
|
scene, x, top + h * 0.004, " · ".join(m.describe() for m in markers), _MARKER, w
|
|
)
|
|
|
|
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(px * w, py * h) for px, py in cut.points]
|
|
for a, b in zip(points, points[1:]):
|
|
line = scene.addLine(a.x(), a.y(), b.x(), b.y(), pen)
|
|
line.setZValue(_BADGE_Z)
|
|
if i == self.selected_cut:
|
|
r = HIT * 1.5 / max(self.transform().m11(), 1e-6)
|
|
for p in points:
|
|
handle = scene.addEllipse(
|
|
p.x() - r, p.y() - r, r * 2, r * 2, QPen(Qt.NoPen), QBrush(_VERTEX)
|
|
)
|
|
handle.setZValue(_BADGE_Z)
|
|
|
|
def _badge(self, scene, x: float, y: float, label: str, colour: QColor, w: int) -> float:
|
|
"""A filled chip with light text. Returns the x to place the next one."""
|
|
text = scene.addText(label)
|
|
text.setDefaultTextColor(QColor(255, 255, 255))
|
|
scale = max(1.0, w / 900)
|
|
text.setScale(scale)
|
|
box = text.boundingRect()
|
|
pad = 4 * scale
|
|
plate = scene.addRect(
|
|
x - pad,
|
|
y - pad / 2,
|
|
box.width() * scale + pad * 2,
|
|
box.height() * scale + pad,
|
|
QPen(Qt.NoPen),
|
|
QBrush(colour),
|
|
)
|
|
# Above the page pixmap, which sits at z 0: a negative z would put the
|
|
# plate behind the scan and the white text with it.
|
|
plate.setZValue(_BADGE_Z - 1)
|
|
text.setZValue(_BADGE_Z)
|
|
text.setPos(x, y)
|
|
return x + box.width() * scale + pad * 3
|
|
|
|
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 self.picking:
|
|
# Choosing a jump's target: click the slice it lands on. Cheaper
|
|
# than a thumbnail picker and it reads the score rather than a list.
|
|
if event.button() == Qt.LeftButton:
|
|
self.picked.emit(self.page_index, self._slice_at(x, y))
|
|
self.picking = False
|
|
self.setCursor(Qt.ArrowCursor)
|
|
return
|
|
|
|
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 not None:
|
|
return
|
|
if event.modifiers() & Qt.ShiftModifier:
|
|
# Shift-double-click opens the engrave window on this slice; a
|
|
# plain double-click adds a cut, which is by far the commoner one.
|
|
self.selected_slice = self._slice_at(x, y)
|
|
self.selection_changed.emit()
|
|
self.engrave_requested.emit()
|
|
return
|
|
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._targeting = 0
|
|
self.view = PageView()
|
|
self.view.changed.connect(self._touched)
|
|
self.view.selection_changed.connect(self._sync)
|
|
self.view.picked.connect(self._target_picked)
|
|
self.view.engrave_requested.connect(self._open_engrave)
|
|
|
|
self.autosave = QTimer(self)
|
|
self.autosave.setSingleShot(True)
|
|
self.autosave.setInterval(AUTOSAVE_MS)
|
|
self.autosave.timeout.connect(self._save)
|
|
|
|
splitter = QSplitter(Qt.Horizontal)
|
|
splitter.addWidget(self.view)
|
|
splitter.addWidget(self._panel())
|
|
splitter.setStretchFactor(0, 1) # the page takes the slack when resized
|
|
splitter.setStretchFactor(1, 0)
|
|
splitter.setSizes([1100, PANEL_WIDTH])
|
|
splitter.setCollapsible(0, False)
|
|
self.setCentralWidget(splitter)
|
|
self._shortcuts()
|
|
self._load_page(0)
|
|
|
|
# -- ui ---------------------------------------------------------------
|
|
|
|
def _section(self, title: str, box: QVBoxLayout, *, expanded: bool = True) -> QVBoxLayout:
|
|
"""A collapsible section. Returns the layout its contents go into.
|
|
|
|
A disclosure arrow, not a checkable QGroupBox: a checkbox in a group
|
|
header reads as "enable this feature" rather than "expand this", and a
|
|
column of framed boxes with checkboxes is hard to scan.
|
|
"""
|
|
header = QToolButton()
|
|
header.setText(title)
|
|
header.setCheckable(True)
|
|
header.setChecked(expanded)
|
|
header.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
|
|
header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
|
header.setAutoRaise(True)
|
|
header.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
# Bold and greyed: a mid grey reads as a heading against both light and
|
|
# dark palettes without needing a second stylesheet.
|
|
header.setStyleSheet(
|
|
"QToolButton {"
|
|
" border: none;"
|
|
" font-weight: 700;"
|
|
" color: #808080;"
|
|
" padding: 7px 0 4px 0;"
|
|
" text-align: left;"
|
|
"}"
|
|
"QToolButton:hover { color: #a0a0a0; }"
|
|
)
|
|
|
|
body = QWidget()
|
|
layout = QVBoxLayout(body)
|
|
layout.setContentsMargins(10, 6, 0, 10)
|
|
body.setVisible(expanded)
|
|
|
|
def toggled(open_: bool) -> None:
|
|
body.setVisible(open_)
|
|
header.setArrowType(Qt.DownArrow if open_ else Qt.RightArrow)
|
|
|
|
header.toggled.connect(toggled)
|
|
box.addWidget(header)
|
|
box.addWidget(body)
|
|
return layout
|
|
|
|
def _panel(self) -> QWidget:
|
|
inner = QWidget()
|
|
box = QVBoxLayout(inner)
|
|
panel = QScrollArea()
|
|
panel.setWidget(inner)
|
|
panel.setWidgetResizable(True)
|
|
panel.setMinimumWidth(260)
|
|
|
|
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_section = self._section("Page", box)
|
|
form = QFormLayout()
|
|
page_section.addLayout(form)
|
|
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)
|
|
|
|
marker_layout = self._section("Markers on this slice", box)
|
|
self.marker_list = QListWidget()
|
|
self.marker_list.setMaximumHeight(110)
|
|
marker_layout.addWidget(self.marker_list)
|
|
|
|
add_row = QHBoxLayout()
|
|
self.marker_type = QComboBox()
|
|
self.marker_type.addItems(MARKER_TYPES)
|
|
self.marker_type.currentTextChanged.connect(self._marker_type_changed)
|
|
add_row.addWidget(self.marker_type, 1)
|
|
self.marker_label = QLineEdit()
|
|
self.marker_label.setPlaceholderText("label")
|
|
self.marker_label.setFixedWidth(70)
|
|
add_row.addWidget(self.marker_label)
|
|
marker_layout.addLayout(add_row)
|
|
|
|
button_row = QHBoxLayout()
|
|
add = QPushButton("Add")
|
|
add.clicked.connect(self._add_marker)
|
|
remove = QPushButton("Remove")
|
|
remove.clicked.connect(self._remove_marker)
|
|
self.retarget = QPushButton("Set target…")
|
|
self.retarget.clicked.connect(self._pick_target)
|
|
for button in (add, remove, self.retarget):
|
|
button_row.addWidget(button)
|
|
marker_layout.addLayout(button_row)
|
|
self._marker_type_changed(self.marker_type.currentText())
|
|
|
|
# Optional feature: without LilyPond installed the pane never appears,
|
|
# and nothing else about the tool changes. Collapsed by default — most
|
|
# slices are never re-engraved, and it is the tallest block here.
|
|
self.ly_status = None
|
|
if lilypond.available():
|
|
ly_layout = self._section("Re-engrave this slice", box, expanded=False)
|
|
open_engrave = QPushButton("Open engrave window…")
|
|
open_engrave.setToolTip("Or double-click the slice on the page")
|
|
open_engrave.clicked.connect(self._open_engrave)
|
|
ly_layout.addWidget(open_engrave)
|
|
self.ly_status = QLabel()
|
|
self.ly_status.setWordWrap(True)
|
|
self.ly_status.setStyleSheet("color: #808080;")
|
|
ly_layout.addWidget(self.ly_status)
|
|
|
|
meta_layout = self._section("Song", box)
|
|
meta_form = QFormLayout()
|
|
meta_layout.addLayout(meta_form)
|
|
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
|
|
required = field == "title"
|
|
if required:
|
|
edit.setPlaceholderText("required")
|
|
if field in NUMERIC_FIELDS:
|
|
# Beats per minute, and only that: a number can drive a
|
|
# metronome where "Andante" cannot.
|
|
edit.setValidator(QIntValidator(20, 400, edit))
|
|
edit.setPlaceholderText("BPM")
|
|
edit.setFixedWidth(90)
|
|
meta_form.addRow(f"{field.replace('_', ' ').title()}{' *' if required else ''}", edit)
|
|
|
|
self.optimise = QPushButton("Shrink the original PDF…")
|
|
self.optimise.setToolTip("Convert scanned pages to bilevel in the archived PDF")
|
|
self.optimise.clicked.connect(self._optimise_pdf)
|
|
meta_layout.addWidget(self.optimise)
|
|
|
|
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\n"
|
|
"Jump markers: add, then click the target slice\n"
|
|
"Shift-double-click a slice: re-engrave it"
|
|
)
|
|
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)
|
|
self._sync_markers()
|
|
self._sync_replacement()
|
|
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()
|
|
|
|
# -- markers ----------------------------------------------------------
|
|
|
|
def _slot_markers(self) -> list[Marker]:
|
|
return self.project.pages[self.index].markers[self.view.selected_slice]
|
|
|
|
def _marker_type_changed(self, kind: str) -> None:
|
|
self.marker_label.setEnabled(kind in LABELLED_TYPES)
|
|
self.retarget.setEnabled(kind in JUMP_TYPES)
|
|
|
|
def _add_marker(self) -> None:
|
|
kind = self.marker_type.currentText()
|
|
label = self.marker_label.text().strip() or None
|
|
marker = Marker(type=kind, label=label if kind in LABELLED_TYPES else None)
|
|
self._slot_markers().append(marker)
|
|
self.marker_label.clear()
|
|
self.view.redraw()
|
|
self._touched()
|
|
if marker.is_jump:
|
|
# A jump is useless without a target, so ask for it immediately
|
|
# rather than leaving it to be noticed at export.
|
|
self._pick_target()
|
|
|
|
def _remove_marker(self) -> None:
|
|
row = self.marker_list.currentRow()
|
|
markers = self._slot_markers()
|
|
if 0 <= row < len(markers):
|
|
markers.pop(row)
|
|
self.view.redraw()
|
|
self._touched()
|
|
|
|
def _pick_target(self) -> None:
|
|
"""Arm click-to-pick for the selected jump marker."""
|
|
markers = self._slot_markers()
|
|
row = self.marker_list.currentRow()
|
|
candidates = [i for i, m in enumerate(markers) if m.is_jump]
|
|
if not candidates:
|
|
return
|
|
self._targeting = row if row in candidates else candidates[-1]
|
|
self.view.picking = True
|
|
self.view.setCursor(Qt.CrossCursor)
|
|
self.statusBar().showMessage(
|
|
"Click the slice this jump goes to — any page, PageUp/PageDown to move"
|
|
)
|
|
|
|
def _target_picked(self, page: int, slot: int) -> None:
|
|
markers = self._slot_markers()
|
|
if 0 <= self._targeting < len(markers):
|
|
markers[self._targeting].destination = (page, slot)
|
|
self.view.redraw()
|
|
self._touched()
|
|
self.statusBar().showMessage(f"target set to p{page + 1} slice {slot + 1}", 2000)
|
|
|
|
def _sync_markers(self) -> None:
|
|
self.marker_list.clear()
|
|
for marker in self._slot_markers():
|
|
self.marker_list.addItem(marker.describe())
|
|
|
|
# -- re-engraving -----------------------------------------------------
|
|
|
|
def _open_engrave(self) -> None:
|
|
"""Open the engrave window on the selected slice, showing its pixels."""
|
|
from .engrave import EngraveWindow
|
|
from .render import cut_slice, slice_mask
|
|
|
|
slot = self.view.selected_slice
|
|
page = self._preview(self.index)
|
|
original = cut_slice(page, slice_mask(self.project, self.index, slot, page.shape))
|
|
if original is None:
|
|
self.statusBar().showMessage("this slice has no ink to replace", 3000)
|
|
return
|
|
|
|
window = EngraveWindow(self.project, self.index, slot, original, self)
|
|
window.finished.connect(lambda _: (self.view.redraw(), self._touched()))
|
|
window.show()
|
|
|
|
def _sync_replacement(self) -> None:
|
|
if self.ly_status is None:
|
|
return
|
|
replacement = self.project.pages[self.index].replacements[self.view.selected_slice]
|
|
if replacement is None:
|
|
self.ly_status.setText("scanned — not re-engraved")
|
|
else:
|
|
voices = len(replacement.voices)
|
|
self.ly_status.setText(f"re-engraved · {voices} voice{'s' if voices != 1 else ''}")
|
|
|
|
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 _optimise_pdf(self) -> None:
|
|
"""Offer to shrink the archived PDF, showing the result before agreeing.
|
|
|
|
A before/after crop rather than a checkbox: the failure this can produce
|
|
— broken staff lines on a coarse scan — is obvious at a glance and
|
|
invisible in a byte count.
|
|
"""
|
|
import pymupdf
|
|
|
|
from .pdfopt import Report, optimise, preview
|
|
|
|
self.statusBar().showMessage("examining the PDF…")
|
|
QApplication.processEvents()
|
|
source = self.project.source
|
|
data, report = optimise(pymupdf.open(source), source.stat().st_size)
|
|
self.statusBar().clearMessage()
|
|
|
|
if not data:
|
|
QMessageBox.information(self, "Nothing to shrink", report.summary())
|
|
self.project.optimise_pdf = False
|
|
return
|
|
|
|
dialog = QDialog(self)
|
|
dialog.setWindowTitle("Shrink the original PDF")
|
|
layout = QVBoxLayout(dialog)
|
|
text = QLabel(report.summary() + "\n\nThe slices are unaffected — only the archived PDF.")
|
|
text.setWordWrap(True)
|
|
layout.addWidget(text)
|
|
|
|
crop = preview(pymupdf.open(source), pymupdf.open(stream=data, filetype="pdf"))
|
|
crop = np.ascontiguousarray(crop)
|
|
h, w, _ = crop.shape
|
|
image = QImage(crop.data, w, h, w * 3, QImage.Format_BGR888).copy()
|
|
label = QLabel()
|
|
label.setPixmap(QPixmap.fromImage(image))
|
|
area = QScrollArea()
|
|
area.setWidget(label)
|
|
area.setWidgetResizable(True)
|
|
area.setMinimumHeight(420)
|
|
layout.addWidget(area)
|
|
layout.addWidget(QLabel("Original above, shrunk below. Check the staff lines."))
|
|
|
|
buttons = QHBoxLayout()
|
|
use = QPushButton("Use the smaller PDF")
|
|
use.clicked.connect(dialog.accept)
|
|
keep = QPushButton("Keep the original")
|
|
keep.clicked.connect(dialog.reject)
|
|
buttons.addWidget(use)
|
|
buttons.addWidget(keep)
|
|
layout.addLayout(buttons)
|
|
dialog.resize(1100, 700)
|
|
|
|
self.project.optimise_pdf = dialog.exec() == QDialog.Accepted
|
|
self._touched()
|
|
self.statusBar().showMessage(
|
|
"the bundle will carry the shrunk PDF"
|
|
if self.project.optimise_pdf
|
|
else "the bundle will carry the original PDF",
|
|
4000,
|
|
)
|
|
|
|
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()
|
|
if not self.project.metadata.get("title", "").strip():
|
|
QMessageBox.warning(
|
|
self, "Title required", "A song needs a title before it can be exported."
|
|
)
|
|
self.metadata["title"].setFocus()
|
|
return
|
|
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()
|