Merge dev: content rectangle detection, spent projects, ADR 0007

This commit is contained in:
Esa Kataja
2026-07-28 23:55:07 +03:00
13 changed files with 299 additions and 81 deletions
+6 -3
View File
@@ -51,9 +51,12 @@ two repos.
**Project**: **Project**:
The persistent state of slicing one song: the source PDF it points at, its cuts, The persistent state of slicing one song: the source PDF it points at, its cuts,
discards, content rectangle, levels, staff-height overrides, markers and discards, content rectangle, levels, staff-height overrides, markers and
metadata. Autosaved beside the PDF; the bundle is generated from it, so any metadata. Autosaved beside the PDF; the bundle is generated from it. One PDF,
export can be regenerated without repeating human work. One PDF, one song, one one song, one project, one bundle — never a many-to-one in any direction.
project, one bundle — never a many-to-one in any direction.
**Spent** once its song has been exported: opening the PDF again begins a fresh
session from detection rather than resuming, so a re-cut never inherits
decisions that have already shipped.
_Avoid_: session, document, edit list _Avoid_: session, document, edit list
**Bundle**: **Bundle**:
+1
View File
@@ -59,3 +59,4 @@ Decisions that were expensive to reach, each with the evidence behind it:
| [ADR 0004](docs/adr/0004-detection-proposes-the-human-disposes.md) | No unattended mode: detection suggests, a human confirms. | | [ADR 0004](docs/adr/0004-detection-proposes-the-human-disposes.md) | No unattended mode: detection suggests, a human confirms. |
| [ADR 0005](docs/adr/0005-pymupdf-for-all-pdf-access.md) | PyMuPDF for all PDF access, accepting AGPL. | | [ADR 0005](docs/adr/0005-pymupdf-for-all-pdf-access.md) | PyMuPDF for all PDF access, accepting AGPL. |
| [ADR 0006](docs/adr/0006-systems-are-found-by-brackets-not-row-gaps.md) | Systems are found by vertical brackets; row-darkness gaps get it wrong. | | [ADR 0006](docs/adr/0006-systems-are-found-by-brackets-not-row-gaps.md) | Systems are found by vertical brackets; row-darkness gaps get it wrong. |
| [ADR 0007](docs/adr/0007-a-project-is-spent-once-exported.md) | A project is spent once exported; reopening starts fresh. Reverses an earlier decision. |
@@ -54,8 +54,11 @@ degraded fallback.
- The geometry model stays **renderer-agnostic**, in normalised page coordinates, - The geometry model stays **renderer-agnostic**, in normalised page coordinates,
so adding the SVG renderer later is an output stage rather than a redesign. so adding the SVG renderer later is an output stage rather than a redesign.
- **Re-export from the project file** regenerates every song's bundle without - ~~**Re-export from the project file** regenerates every song's bundle without
repeating human work, so songs cut before the SVG renderer exists are not repeating human work, so songs cut before the SVG renderer exists are not
stranded. stranded.~~ **No longer true** — see
[ADR 0007](0007-a-project-is-spent-once-exported.md). A project is spent once
its song has been exported, so songs cut before the SVG renderer ships stay
WebP unless they are cut again by hand.
- noteman needs no SVG support (`image/svg+xml`, `.svg` content type, CSP header - noteman needs no SVG support (`image/svg+xml`, `.svg` content type, CSP header
on SVG responses) until the renderer ships. on SVG responses) until the renderer ships.
@@ -0,0 +1,45 @@
# A project is spent once its song has been exported
Exporting a song marks its project file spent. Opening the PDF again starts a
**fresh session from detection** — no cuts, no discards, no metadata carried
over — rather than resuming. `--resume` on `edit`, `export` and `project`
overrides it when the old state really is wanted.
This **reverses an earlier decision**, which is the reason it needs recording:
the project file was introduced specifically so that state would persist, and
`docs/spec.md` and `CONTEXT.md` promised resume-across-sessions and re-export
until this ADR was written.
## What was decided before, and why it changed
The project file was chosen over "bundle only" for three benefits: crash safety,
resume across sessions, and re-export. The third was the strongest argument —
change the width cap, fix one cut, or add the SVG renderer later, and every
song's bundle regenerates without repeating any human work. ADR 0002 leans on it
explicitly when deferring the SVG renderer: "re-export from the project file
regenerates every song's bundle without repeating human work, so songs cut
before the SVG renderer exists are not stranded."
In use, persistence was the wrong default. Re-opening an exported song silently
resurrected old decisions, so a deliberate re-cut began from stale state instead
of a clean page — and because autosave writes that state straight back, closing
the window did not clear it either. An export is a natural end of a unit of
work; carrying its decisions past that point makes "start over" impossible to
express.
Crash safety and resume within a session are untouched, and those are what the
day-to-day authoring loop actually depends on: a session interrupted halfway
through a 12-page scan still picks up where it stopped.
## Consequences
- **Re-export is no longer free.** Changing the 1920px cap, changing the encoder,
or adding the SVG renderer means re-cutting each song by hand. ADR 0002's
"not stranded" reasoning no longer holds; if the SVG renderer ships, already
exported songs stay WebP unless they are cut again.
- The flag is written in `bundle.write`, not in its callers, so no export path
can forget it.
- The project file is kept rather than deleted, so `--resume` remains possible
and the state is still there to inspect after the fact.
- A CLI export from a PDF with no project file now writes one, marked spent.
That is the record that this PDF has already been exported.
+34 -3
View File
@@ -194,6 +194,27 @@ fall back to row-profile runs, which is correct there.
**Staff height** — peak-to-peak spacing in the row profile. **Staff height** — peak-to-peak spacing in the row profile.
**Content rectangle** — proposed per page from the staff lines. Staff lines are
long *horizontal* runs, while a scan-edge shadow, a spine darkening and the
streak a dirty scanner glass leaves are all *vertical*, so opening with a wide
flat kernel keeps the music and erases the artefacts. Three details make it
work:
- Only rows inside detected systems are searched. Otherwise a horizontal scan
artefact above or below the music is itself a long horizontal run, and it
reaches the paper edge.
- The horizontal bounds come from a *percentile* of the staff-line extents, not
their maximum. Where an artefact touches the end of a staff line the two merge
into one component; a page has dozens of staff lines and only a few are
contaminated.
- The left bound also considers the **brackets**, which sit left of every staff
line. A bound taken from staff lines alone crops the bracket off, and a
bracket is notation.
Only the horizontal bounds are proposed. Vertically the cuts and discard flags
already isolate the header and footer, and cropping the top would risk clipping
a tempo mark or a section label above the first staff.
**Source type**`get_images(full=True)` / `get_drawings()` proposes bitmap or **Source type**`get_images(full=True)` / `get_drawings()` proposes bitmap or
vector per PDF; the tool asks the user to confirm before routing. (`full=True` is vector per PDF; the tool asks the user to confirm before routing. (`full=True` is
required, or `get_image_bbox` rejects the item.) required, or `get_image_bbox` rejects the item.)
@@ -242,9 +263,19 @@ discards, content rectangle, skew angles, levels, staff-height overrides, marker
and metadata. The bundle is *generated* from it, so export is a pure function of and metadata. The bundle is *generated* from it, so export is a pure function of
the project file plus the PDF. the project file plus the PDF.
It buys crash safety, resume across sessions (authoring is trickle-in), and It buys crash safety and resume across sessions, since authoring is trickle-in:
**re-export** — change the 1920 cap, fix one cut, or add the SVG renderer later, a session interrupted halfway through a 12-page scan picks up exactly where it
and every song's bundle regenerates without repeating any human work. stopped.
**A project is spent once its song has been exported.** Export records that in
the file, and opening the PDF again starts a *fresh session from detection*
rather than resuming. A re-cut therefore never inherits decisions that have
already shipped. `--resume` overrides it on the `edit`, `export` and `project`
commands when the old state really is wanted.
The cost is deliberate: re-export is no longer free. Changing the width cap or
adding the SVG renderer later means re-cutting each song by hand rather than
regenerating every bundle from its project file.
The project file references the PDF and never contains it; the hash lets the The project file references the PDF and never contains it; the hash lets the
editor warn if the PDF changed underneath. editor warn if the PDF changed underneath.
+6
View File
@@ -62,4 +62,10 @@ def write(project: Project, source: Source, path: Path) -> Path:
zf.write(project.source, "original.pdf") zf.write(project.source, "original.pdf")
for name, data in zip(names, images): for name, data in zip(names, images):
zf.writestr(name, data, zipfile.ZIP_STORED) zf.writestr(name, data, zipfile.ZIP_STORED)
# The project is spent once its song has been exported: the next edit
# session starts fresh from detection rather than resuming these decisions.
# Recorded here so no caller can forget it.
project.exported = True
project.save()
return path return path
+25 -27
View File
@@ -51,24 +51,18 @@ def _detect(args: argparse.Namespace) -> int:
def _project(args: argparse.Namespace) -> int: def _project(args: argparse.Namespace) -> int:
from .project import Project, default_path from .project import default_path, open_project
source = open_source(args.pdf, SourceType(args.type) if args.type else None) source = open_source(args.pdf, SourceType(args.type) if args.type else None)
path = default_path(source.path) path = default_path(source.path)
if path.exists() and not args.force: project = open_project(source, resume=args.resume and not args.force)
project = Project.load(path) if project.path is None:
print(f"{path.name}: loaded") print(f"{path.name}: fresh session from detection")
else:
print(f"{path.name}: resumed")
if project.source_changed(): if project.source_changed():
print(" WARNING: the PDF has changed since these cuts were made") print(" WARNING: the PDF has changed since these cuts were made")
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)
print(f"{path.name}: created from detection")
kept = project.kept_slices() kept = project.kept_slices()
for i, page in enumerate(project.pages): for i, page in enumerate(project.pages):
@@ -84,23 +78,14 @@ def _project(args: argparse.Namespace) -> int:
def _export(args: argparse.Namespace) -> int: def _export(args: argparse.Namespace) -> int:
from . import bundle from . import bundle
from .project import Project, default_path from .project import open_project
source = open_source(args.pdf, SourceType(args.type) if args.type else None) source = open_source(args.pdf, SourceType(args.type) if args.type else None)
path = default_path(source.path) project = open_project(source, resume=args.resume)
if project.path is None:
if path.exists(): print("no unspent project state; exporting straight from detection")
project = Project.load(path) elif project.source_changed():
if project.source_changed():
print("WARNING: the PDF has changed since these cuts were made") print("WARNING: the PDF has changed since these cuts were made")
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)
print("no project file; exporting straight from detection")
out = Path(args.out) if args.out else source.path.with_suffix(".zip") out = Path(args.out) if args.out else source.path.with_suffix(".zip")
bundle.write(project, source, out) bundle.write(project, source, out)
@@ -114,7 +99,9 @@ def _export(args: argparse.Namespace) -> int:
def _edit(args: argparse.Namespace) -> int: def _edit(args: argparse.Namespace) -> int:
from .editor import launch from .editor import launch
return launch(Path(args.pdf), SourceType(args.type) if args.type else None) return launch(
Path(args.pdf), SourceType(args.type) if args.type else None, resume=args.resume
)
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
@@ -145,17 +132,28 @@ def main(argv: list[str] | None = None) -> int:
proj.add_argument("pdf") proj.add_argument("pdf")
proj.add_argument("--save", action="store_true", help="write the project file") proj.add_argument("--save", action="store_true", help="write the project file")
proj.add_argument("--force", action="store_true", help="re-detect, discarding existing state") proj.add_argument("--force", action="store_true", help="re-detect, discarding existing state")
proj.add_argument(
"--resume",
action="store_true",
help="reopen an already-exported project instead of starting fresh",
)
proj.add_argument("--type", choices=[t.value for t in SourceType]) proj.add_argument("--type", choices=[t.value for t in SourceType])
proj.set_defaults(func=_project) proj.set_defaults(func=_project)
exp = sub.add_parser("export", help="render the song and write a bundle") exp = sub.add_parser("export", help="render the song and write a bundle")
exp.add_argument("pdf") exp.add_argument("pdf")
exp.add_argument("--out", help="output zip (default: alongside the PDF)") exp.add_argument("--out", help="output zip (default: alongside the PDF)")
exp.add_argument("--resume", action="store_true", help="use already-exported project state")
exp.add_argument("--type", choices=[t.value for t in SourceType]) exp.add_argument("--type", choices=[t.value for t in SourceType])
exp.set_defaults(func=_export) exp.set_defaults(func=_export)
ed = sub.add_parser("edit", help="open the editor") ed = sub.add_parser("edit", help="open the editor")
ed.add_argument("pdf") ed.add_argument("pdf")
ed.add_argument(
"--resume",
action="store_true",
help="reopen an already-exported project instead of starting fresh",
)
ed.add_argument("--type", choices=[t.value for t in SourceType]) ed.add_argument("--type", choices=[t.value for t in SourceType])
ed.set_defaults(func=_edit) ed.set_defaults(func=_edit)
+101 -19
View File
@@ -28,6 +28,19 @@ _ANCHOR_KERNEL = 0.03 # vertical open kernel, as a fraction of page height
_ANCHOR_MIN = 0.04 # a bracket is at least this tall, as a fraction of page _ANCHOR_MIN = 0.04 # a bracket is at least this tall, as a fraction of page
_PROFILE_FLOOR = 0.02 # ink-run threshold, as a fraction of the profile peak _PROFILE_FLOOR = 0.02 # ink-run threshold, as a fraction of the profile peak
_EXPAND_REACH = 1.5 # how far past the bracket a system's ink reaches, in staff heights _EXPAND_REACH = 1.5 # how far past the bracket a system's ink reaches, in staff heights
_STAFF_KERNEL = 0.05 # horizontal open kernel, as a fraction of page width
_STAFF_MIN_WIDTH = 0.2 # a staff line spans at least this share of the page
_CONTENT_MARGIN = 0.01 # slack past the staff ends, for ledger lines and lyrics
_EDGE_PERCENTILE = 15 # tolerate this share of staff lines merged into scan artefacts
@dataclass
class Anchor:
"""A system's vertical bracket: where it is, and how far left it reaches."""
top: int
bottom: int
left: int
@dataclass @dataclass
@@ -48,6 +61,7 @@ class PageDetection:
skew: float skew: float
systems: list[System] = field(default_factory=list) systems: list[System] = field(default_factory=list)
cuts: list[int] = field(default_factory=list) cuts: list[int] = field(default_factory=list)
content: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0)
@property @property
def bracketless(self) -> bool: def bracketless(self) -> bool:
@@ -89,8 +103,8 @@ def deskew(gray: np.ndarray, angle: float) -> np.ndarray:
return _rotate(gray, angle) return _rotate(gray, angle)
def system_anchors(gray: np.ndarray) -> list[tuple[int, int]]: def system_anchors(gray: np.ndarray) -> list[Anchor]:
"""y-extents of the vertical brackets, one per system.""" """The vertical brackets, one per system."""
h = gray.shape[0] h = gray.shape[0]
binary = (gray < _INK).astype(np.uint8) binary = (gray < _INK).astype(np.uint8)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(3, int(h * _ANCHOR_KERNEL)))) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(3, int(h * _ANCHOR_KERNEL))))
@@ -98,19 +112,82 @@ def system_anchors(gray: np.ndarray) -> list[tuple[int, int]]:
count, _, stats, _ = cv2.connectedComponentsWithStats(strokes, 8) count, _, stats, _ = cv2.connectedComponentsWithStats(strokes, 8)
tall = [ tall = [
(stats[i, cv2.CC_STAT_TOP], stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT]) Anchor(
int(stats[i, cv2.CC_STAT_TOP]),
int(stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT]),
int(stats[i, cv2.CC_STAT_LEFT]),
)
for i in range(1, count) for i in range(1, count)
if stats[i, cv2.CC_STAT_HEIGHT] > h * _ANCHOR_MIN if stats[i, cv2.CC_STAT_HEIGHT] > h * _ANCHOR_MIN
] ]
# Tallest first, keeping only strokes that don't overlap one already kept: # Tallest first, keeping only strokes that don't overlap one already kept:
# a system's barlines all overlap its bracket, so each system yields one. # a system's barlines all overlap its bracket, so each system yields one.
anchors: list[tuple[int, int]] = [] # The kept stroke is the tallest, which is the bracket rather than a barline.
for top, bottom in sorted(tall, key=lambda s: s[1] - s[0], reverse=True): anchors: list[Anchor] = []
if any(not (bottom < a[0] or top > a[1]) for a in anchors): for candidate in sorted(tall, key=lambda a: a.bottom - a.top, reverse=True):
if any(not (candidate.bottom < a.top or candidate.top > a.bottom) for a in anchors):
continue continue
anchors.append((top, bottom)) anchors.append(candidate)
return sorted(anchors) return sorted(anchors, key=lambda a: a.top)
def content_columns(
gray: np.ndarray, anchors: list[Anchor] | None = None
) -> tuple[float, float]:
"""Where the music is horizontally, as normalised x bounds.
Staff lines are long *horizontal* runs; a scan-edge shadow, a spine
darkening and the vertical line a dirty scanner glass leaves are all
*vertical*. Opening with a wide flat kernel keeps the first and erases the
others, so the staff lines' own bounding box is the music area.
`anchors` does two jobs. It restricts the search to rows known to hold
systems without that, a horizontal scan artefact above or below the music
is itself a long horizontal run reaching the paper edge, which is exactly
the measurement being avoided. And its brackets give the true left bound:
a bracket sits *left of every staff line*, so a bound taken from staff
lines alone crops it off, and a bracket is notation, not artefact.
This matters more than it looks: trim is tight and per slice, so one dark
band down the margin sets that slice's width, which sets the song's widest
slice, which scales the whole song down.
"""
height, width = gray.shape
binary = (gray < _INK).astype(np.uint8)
if anchors:
keep = np.zeros(height, bool)
for anchor in anchors:
keep[max(0, anchor.top) : min(height, anchor.bottom)] = True
binary[~keep] = 0
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (max(3, int(width * _STAFF_KERNEL)), 1))
lines = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)
count, _, stats, _ = cv2.connectedComponentsWithStats(lines, 8)
runs = [
(stats[i, cv2.CC_STAT_LEFT], stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH])
for i in range(1, count)
if stats[i, cv2.CC_STAT_WIDTH] > width * _STAFF_MIN_WIDTH
]
if not runs:
return 0.0, 1.0
# Percentiles, not the extremes. Where a scan-edge band happens to touch
# the end of a staff line the two merge into one component, and that
# component then reaches into the artefact — on Ketun joululaulu p2 the
# merged line ends at 1575px against 1544px on the clean page. A page has
# dozens of staff lines and only a few are contaminated, so a percentile
# lands on the true edge while the extreme lands on the worst artefact.
lefts = np.array([r[0] for r in runs], float)
rights = np.array([r[1] for r in runs], float)
margin = width * _CONTENT_MARGIN
left = float(np.percentile(lefts, _EDGE_PERCENTILE))
if anchors:
left = min(left, min(a.left for a in anchors))
right = float(np.percentile(rights, 100 - _EDGE_PERCENTILE))
return max(0.0, left - margin) / width, min(float(width), right + margin) / width
def ink_runs(gray: np.ndarray) -> list[tuple[int, int]]: def ink_runs(gray: np.ndarray) -> list[tuple[int, int]]:
@@ -161,18 +238,17 @@ def staff_height(gray: np.ndarray, top: int, bottom: int) -> float | None:
return float(np.median(intra) * 4) # 5 lines, 4 spaces return float(np.median(intra) * 4) # 5 lines, 4 spaces
def _gap(run: tuple[int, int], span: tuple[int, int]) -> int: def _gap(run: tuple[int, int], anchor: Anchor) -> int:
"""Vertical distance between an ink run and a bracket span; 0 if they overlap.""" """Vertical distance between an ink run and a bracket; 0 if they overlap."""
start, end = run start, end = run
top, bottom = span if end > anchor.top and start < anchor.bottom:
if end > top and start < bottom:
return 0 return 0
return top - end if end <= top else start - bottom return anchor.top - end if end <= anchor.top else start - anchor.bottom
def _assign( def _assign(
runs: list[tuple[int, int]], runs: list[tuple[int, int]],
anchors: list[tuple[int, int]], anchors: list[Anchor],
reaches: list[float], reaches: list[float],
) -> list[tuple[int, int]]: ) -> list[tuple[int, int]]:
"""Give every ink run to one system, and return each system's extent. """Give every ink run to one system, and return each system's extent.
@@ -196,7 +272,7 @@ def _assign(
to the system below and the cut lands high. Dragging the cut is the fix; to the system below and the cut lands high. Dragging the cut is the fix;
separating them needs a signal this pass doesn't have. separating them needs a signal this pass doesn't have.
""" """
bounds = [list(a) for a in anchors] bounds = [[a.top, a.bottom] for a in anchors]
def claim(index: int, run: tuple[int, int]) -> None: def claim(index: int, run: tuple[int, int]) -> None:
bounds[index][0] = min(bounds[index][0], run[0]) bounds[index][0] = min(bounds[index][0], run[0])
@@ -208,7 +284,7 @@ def _assign(
# Ink overlapping a bracket belongs to it — to the one it overlaps most, # Ink overlapping a bracket belongs to it — to the one it overlaps most,
# whatever else is in reach. # whatever else is in reach.
inside = [ inside = [
(min(run[1], anchors[i][1]) - max(run[0], anchors[i][0]), i) (min(run[1], anchors[i].bottom) - max(run[0], anchors[i].top), i)
for i, g in enumerate(gaps) for i, g in enumerate(gaps)
if g == 0 if g == 0
] ]
@@ -221,7 +297,7 @@ def _assign(
continue # a title block or a footer: too far from any system continue # a title block or a footer: too far from any system
# Otherwise the system above wins, and only failing that the one below. # Otherwise the system above wins, and only failing that the one below.
above = [i for i in within if anchors[i][1] <= run[0]] above = [i for i in within if anchors[i].bottom <= run[0]]
claim(above[-1] if above else within[0], run) claim(above[-1] if above else within[0], run)
return [(lo, hi) for lo, hi in bounds] return [(lo, hi) for lo, hi in bounds]
@@ -238,7 +314,7 @@ def detect_page(gray: np.ndarray, skew: float | None = None) -> PageDetection:
if anchors: if anchors:
# Staff height is measured on the bracket span, before expansion, so a # Staff height is measured on the bracket span, before expansion, so a
# swallowed title block can't distort it. # swallowed title block can't distort it.
heights = [staff_height(straight, top, bottom) for top, bottom in anchors] heights = [staff_height(straight, a.top, a.bottom) for a in anchors]
reaches = [(h or gray.shape[0] * 0.02) * _EXPAND_REACH for h in heights] reaches = [(h or gray.shape[0] * 0.02) * _EXPAND_REACH for h in heights]
systems = [ systems = [
System(top=lo, bottom=hi, staff_height=h) System(top=lo, bottom=hi, staff_height=h)
@@ -252,4 +328,10 @@ def detect_page(gray: np.ndarray, skew: float | None = None) -> PageDetection:
cuts = [ cuts = [
(systems[i].bottom + systems[i + 1].top) // 2 for i in range(len(systems) - 1) (systems[i].bottom + systems[i + 1].top) // 2 for i in range(len(systems) - 1)
] ]
return PageDetection(skew=angle, systems=systems, cuts=cuts) # Only the horizontal bounds are proposed. Vertically the cuts and the
# discard flags already isolate the header and footer, and cropping the top
# would risk clipping a tempo mark or a section label above the first staff.
left, right = content_columns(straight, anchors)
return PageDetection(
skew=angle, systems=systems, cuts=cuts, content=(left, 0.0, right, 1.0)
)
+19 -15
View File
@@ -48,7 +48,7 @@ from . import bundle
from .bundle import METADATA_FIELDS from .bundle import METADATA_FIELDS
from .detect import deskew, detect_page from .detect import deskew, detect_page
from .pdf import Source, open_source, page_raster from .pdf import Source, open_source, page_raster
from .project import Cut, Project, default_path from .project import Cut, Project, open_project
from .render import apply_levels from .render import apply_levels
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
@@ -357,6 +357,7 @@ class Editor(QMainWindow):
discard.clicked.connect(self.view.toggle_discard) discard.clicked.connect(self.view.toggle_discard)
form.addRow(discard) form.addRow(discard)
reset = QPushButton("Reset content rectangle") reset = QPushButton("Reset content rectangle")
reset.setToolTip("Back to the rectangle detection proposed for this page")
reset.clicked.connect(self._reset_rect) reset.clicked.connect(self._reset_rect)
form.addRow(reset) form.addRow(reset)
box.addWidget(page_box) box.addWidget(page_box)
@@ -475,7 +476,15 @@ class Editor(QMainWindow):
self.autosave.start() self.autosave.start()
def _reset_rect(self) -> None: def _reset_rect(self) -> None:
self.project.pages[self.index].content_rect = 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.view.redraw()
self._touched() self._touched()
@@ -499,7 +508,9 @@ class Editor(QMainWindow):
QMessageBox.information( QMessageBox.information(
self, self,
"Exported", "Exported",
f"{out.name}\n{len(self.project.kept_slices())} slices, {size:.0f} KB", 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: def closeEvent(self, event) -> None:
@@ -507,27 +518,20 @@ class Editor(QMainWindow):
super().closeEvent(event) super().closeEvent(event)
def launch(pdf: Path, source_type=None) -> int: def launch(pdf: Path, source_type=None, resume: bool = False) -> int:
app = QApplication(sys.argv[:1]) app = QApplication(sys.argv[:1])
source = open_source(pdf, source_type) source = open_source(pdf, source_type)
path = default_path(source.path)
if path.exists(): # An exported project is spent: this opens a fresh session from detection
project = Project.load(path) # rather than resuming decisions that have already been shipped.
if project.source_changed(): project = open_project(source, resume=resume)
if project.path is not None and project.source_changed():
QMessageBox.warning( QMessageBox.warning(
None, None,
"Source changed", "Source changed",
"The PDF has changed since these cuts were made.\n" "The PDF has changed since these cuts were made.\n"
"Cuts may no longer line up with the music.", "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 = Editor(source, project)
window.resize(1500, 950) window.resize(1500, 950)
+34
View File
@@ -113,6 +113,11 @@ class Project:
levels: tuple[int, int] = (0, 255) levels: tuple[int, int] = (0, 255)
metadata: dict[str, str] = field(default_factory=dict) metadata: dict[str, str] = field(default_factory=dict)
path: Path | None = None path: Path | None = None
# Set once the song has been exported. A project is spent at that point:
# opening the PDF again starts a fresh session from detection rather than
# resuming, so a re-cut never begins from stale decisions. `--resume`
# overrides it when the old state really is wanted.
exported: bool = False
# -- geometry helpers ------------------------------------------------- # -- geometry helpers -------------------------------------------------
@@ -170,6 +175,9 @@ class Project:
skew=detection.skew, skew=detection.skew,
cuts=[Cut.straight(y / height) for y in ys], cuts=[Cut.straight(y / height) for y in ys],
discards=discards, discards=discards,
# Per page, not per song: scans drift, so the margin junk
# sits in a different place on each one.
content_rect=detection.content,
) )
) )
return cls(source=source, source_hash=hash_file(source), pages=pages) return cls(source=source, source_hash=hash_file(source), pages=pages)
@@ -181,6 +189,7 @@ class Project:
"v": FORMAT_VERSION, "v": FORMAT_VERSION,
"source": self.source.name, "source": self.source.name,
"source_hash": self.source_hash, "source_hash": self.source_hash,
"exported": self.exported,
"content_rect": list(self.content_rect), "content_rect": list(self.content_rect),
"levels": list(self.levels), "levels": list(self.levels),
"metadata": self.metadata, "metadata": self.metadata,
@@ -226,6 +235,7 @@ class Project:
levels=tuple(data["levels"]), levels=tuple(data["levels"]),
metadata=data.get("metadata", {}), metadata=data.get("metadata", {}),
path=path, path=path,
exported=data.get("exported", False),
) )
def source_changed(self) -> bool: def source_changed(self) -> bool:
@@ -233,6 +243,30 @@ class Project:
return self.source.exists() and hash_file(self.source) != self.source_hash return self.source.exists() and hash_file(self.source) != self.source_hash
def open_project(source, *, resume: bool = False) -> Project:
"""The project for a PDF: resumed, or a fresh session from detection.
A project that has been exported is spent. Opening the PDF again starts
over from detection rather than resuming, so a re-cut never inherits stale
decisions. `resume` overrides that when the old state really is wanted.
"""
from .detect import detect_page
from .pdf import page_raster
path = default_path(source.path)
if path.exists():
existing = Project.load(path)
if resume or not existing.exported:
return existing
detections, heights = [], []
for i in range(len(source)):
gray = page_raster(source, i)
detections.append(detect_page(gray))
heights.append(gray.shape[0])
return Project.from_detection(source.path, detections, heights)
def default_path(source: Path) -> Path: def default_path(source: Path) -> Path:
return Path(source).with_suffix(SUFFIX) return Path(source).with_suffix(SUFFIX)
+6 -2
View File
@@ -85,11 +85,15 @@ def main() -> int:
editor.metadata["composer"].setText("trad.") editor.metadata["composer"].setText("trad.")
assert project.metadata["title"] == "Ketun joululaulu" assert project.metadata["title"] == "Ketun joululaulu"
# Content rectangle edits and reset. # Content rectangle edits, and reset going back to detection's proposal for
# the page as it now stands — not to the whole page, which would undo the
# thing the rectangle exists for.
expected = detect_page(editor._preview(0), skew=0.0).content
page.content_rect = (0.05, 0.02, 0.95, 0.98) page.content_rect = (0.05, 0.02, 0.95, 0.98)
assert project.page_content_rect(0) == (0.05, 0.02, 0.95, 0.98) assert project.page_content_rect(0) == (0.05, 0.02, 0.95, 0.98)
editor._reset_rect() editor._reset_rect()
assert project.page_content_rect(0) == project.content_rect assert project.page_content_rect(0) == expected, (project.page_content_rect(0), expected)
assert project.page_content_rect(0) != (0.0, 0.0, 1.0, 1.0)
# Autosave target, then a round-trip through disk. # Autosave target, then a round-trip through disk.
editor._save() editor._save()
+6
View File
@@ -67,6 +67,12 @@ def main() -> int:
assert reloaded.source_hash == project.source_hash assert reloaded.source_hash == project.source_hash
assert not reloaded.source_changed() assert not reloaded.source_changed()
# A project is spent once exported: reopening starts fresh.
assert not reloaded.exported
reloaded.exported = True
reloaded.save()
assert Project.load(saved).exported
# A PDF edited underneath must be reported, not silently re-cut. # A PDF edited underneath must be reported, not silently re-cut.
pdf.write_bytes(b"%PDF-1.7 different bytes entirely") pdf.write_bytes(b"%PDF-1.7 different bytes entirely")
assert reloaded.source_changed() assert reloaded.source_changed()
+4 -3
View File
@@ -16,7 +16,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from noteman_slicer import bundle # noqa: E402 from noteman_slicer import bundle # noqa: E402
from noteman_slicer.detect import detect_page # noqa: E402 from noteman_slicer.detect import detect_page # noqa: E402
from noteman_slicer.pdf import open_source, page_raster # noqa: E402 from noteman_slicer.pdf import open_source, page_raster # noqa: E402
from noteman_slicer.project import Cut, Project # noqa: E402 from noteman_slicer.project import Cut, Project, default_path # noqa: E402
from noteman_slicer.render import ( # noqa: E402 from noteman_slicer.render import ( # noqa: E402
ALPHA_LEVELS, ALPHA_LEVELS,
apply_levels, apply_levels,
@@ -151,8 +151,9 @@ def main() -> int:
assert all(f in names for f in files) assert all(f in names for f in files)
source.close() source.close()
for f in (pdf, out): # Exporting marks the project spent, which writes the project file.
f.unlink() for f in (pdf, out, default_path(pdf)):
f.unlink(missing_ok=True)
tmp.rmdir() tmp.rmdir()
print("ok") print("ok")
return 0 return 0