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.
561 lines
20 KiB
Python
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()
|