Add the render pipeline and bundle export
Project state plus PDF in, finished slice images out. Slices are cut as polygons rather than row ranges, so a stepped cut yields a slice with a transparent notch instead of one that covers its neighbour. Masking paints white, which the ink-to-alpha step turns into full transparency — the same outcome the spec asks for, one step earlier. Scale normalises every slice to the median staff height before fitting the song to 1920px, so a rescanned page sits at the same note size as its neighbours. The cap only ever shrinks: a song narrower than 1920 stays narrower. Alpha quantisation rounds to 16 values spanning 0-255 inclusive. Flooring, as first written, capped full ink at 240 and left every note 6% transparent — caught by decoding an exported slice rather than by reading the code. Ketun joululaulu exports 24 slices at a uniform 1489px, under the cap and correctly not upscaled from its 200 DPI source; Feliz Navidad 20; Elaman nalka 18. Closes #21 Closes #22 Closes #23 Closes #24 Closes #25 Closes #27
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"""Runnable check for the render pipeline and bundle export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from noteman_slicer import bundle # 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.project import Cut, Project # noqa: E402
|
||||
from noteman_slicer.render import ( # noqa: E402
|
||||
ALPHA_LEVELS,
|
||||
apply_levels,
|
||||
encode,
|
||||
pad_right,
|
||||
render_slices,
|
||||
scale_song,
|
||||
)
|
||||
|
||||
W, H = 1200, 1600
|
||||
GAP = 15
|
||||
|
||||
|
||||
def _system(page: np.ndarray, top: int, right: int) -> None:
|
||||
"""A bracket plus two staves, with a lyric line under each."""
|
||||
page[top : top + 200, 100:104] = 0
|
||||
for staff in (top, top + 140):
|
||||
for i in range(5):
|
||||
page[staff + i * GAP : staff + i * GAP + 2, 110:right] = 0
|
||||
page[staff + 90 : staff + 105, 200 : right - 100] = 0
|
||||
|
||||
|
||||
def _scan_pdf(path: Path) -> None:
|
||||
art = np.full((H, W), 255, np.uint8)
|
||||
art[40:60, 400:800] = 0 # title, far from any system
|
||||
_system(art, 300, 1100)
|
||||
_system(art, 800, 900) # narrower: exercises the right pad
|
||||
pix = pymupdf.Pixmap(pymupdf.csGRAY, W, H, bytearray(art.tobytes()), False)
|
||||
doc = pymupdf.open()
|
||||
page = doc.new_page(width=595, height=842)
|
||||
page.insert_image(page.rect, pixmap=pix)
|
||||
doc.save(path)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tmp = Path(__file__).with_name("_tmp")
|
||||
tmp.mkdir(exist_ok=True)
|
||||
pdf = tmp / "scan.pdf"
|
||||
_scan_pdf(pdf)
|
||||
|
||||
source = open_source(pdf)
|
||||
gray = page_raster(source, 0)
|
||||
project = Project.from_detection(pdf, [detect_page(gray)], [gray.shape[0]])
|
||||
|
||||
slices = render_slices(project, source)
|
||||
assert len(slices) == 2, f"expected 2 kept slices, got {len(slices)}"
|
||||
|
||||
# The title is far from any bracket, so it is not in a kept slice: both
|
||||
# slices must be shorter than the gap between the systems.
|
||||
assert all(s.gray.shape[0] < 400 for s in slices), [s.gray.shape for s in slices]
|
||||
|
||||
# System 2 is drawn narrower, so before padding the widths differ.
|
||||
assert slices[0].width != slices[1].width, "the fixture should differ in width"
|
||||
|
||||
scaled = scale_song(slices, cap=4000) # a cap far above the fixture
|
||||
assert all(abs(a.shape[1] - b.width) <= 2 for a, b in zip(scaled, slices)), (
|
||||
"never upscale: a song narrower than the cap must be left alone"
|
||||
)
|
||||
|
||||
padded = pad_right(scale_song(slices))
|
||||
assert len({p.shape[1] for p in padded}) == 1, "slices must share one width"
|
||||
assert max(p.shape[1] for p in padded) <= 1920
|
||||
|
||||
rgba = cv2.imdecode(np.frombuffer(encode(padded[0]), np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
assert rgba.shape[2] == 4
|
||||
assert rgba[:, :, :3].max() == 0, "ink must be pure black"
|
||||
assert rgba[:, :, 3].max() == 255, "full ink must be fully opaque"
|
||||
assert rgba[:, :, 3].min() == 0, "paper must be fully transparent"
|
||||
assert len(np.unique(rgba[:, :, 3])) <= ALPHA_LEVELS
|
||||
|
||||
# Levels: a white point below the paper value wipes the paper out entirely.
|
||||
faint = np.full((10, 10), 200, np.uint8)
|
||||
assert apply_levels(faint, 0, 180).max() == 255
|
||||
|
||||
# The Engel case: a section label printed in the left margin at a height
|
||||
# that belongs to the *next* system. A straight cut cannot separate it from
|
||||
# the previous system's lyrics; a stepped one can.
|
||||
label_top, label_bottom = 620, 680
|
||||
labelled = tmp / "labelled.pdf"
|
||||
art = np.full((H, W), 255, np.uint8)
|
||||
_system(art, 300, 1100)
|
||||
_system(art, 800, 900)
|
||||
art[label_top:label_bottom, 120:300] = 0 # the label
|
||||
art[label_top:label_bottom, 500:1000] = 0 # system 1's trailing lyrics, same rows
|
||||
pix = pymupdf.Pixmap(pymupdf.csGRAY, W, H, bytearray(art.tobytes()), False)
|
||||
doc = pymupdf.open()
|
||||
doc.new_page(width=595, height=842).insert_image(pymupdf.Rect(0, 0, 595, 842), pixmap=pix)
|
||||
doc.save(labelled)
|
||||
|
||||
src2 = open_source(labelled)
|
||||
g2 = page_raster(src2, 0)
|
||||
proj2 = Project.from_detection(labelled, [detect_page(g2)], [g2.shape[0]])
|
||||
page = proj2.pages[0]
|
||||
scale = g2.shape[0] / H
|
||||
|
||||
def ink(images: list) -> list[int]:
|
||||
"""Ink in the left margin of each slice — where the label sits."""
|
||||
return [int((i.gray[:, : int(i.width * 0.3)] < 128).sum()) for i in images]
|
||||
|
||||
# Straight cut through the middle of that band: the label goes with
|
||||
# whichever side the line falls on, and cannot be separated.
|
||||
band_mid = (label_top + label_bottom) / 2 * scale / g2.shape[0]
|
||||
page.cuts[1] = Cut.straight(band_mid)
|
||||
straight_ink = ink(render_slices(proj2, src2))
|
||||
|
||||
# Stepped: above the label on the left, below the lyrics on the right.
|
||||
above = (label_top - 10) * scale / g2.shape[0]
|
||||
below = (label_bottom + 10) * scale / g2.shape[0]
|
||||
page.cuts[1] = Cut([(0.0, above), (0.35, above), (0.35, below), (1.0, below)])
|
||||
stepped_ink = ink(render_slices(proj2, src2))
|
||||
|
||||
# The straight cut splits the label down the middle; the stepped cut gives
|
||||
# all of it to the lower slice and none to the upper.
|
||||
assert stepped_ink[1] > straight_ink[1], (
|
||||
f"the label must move into the lower slice: {straight_ink} → {stepped_ink}"
|
||||
)
|
||||
assert stepped_ink[0] < straight_ink[0], (
|
||||
f"and out of the upper one: {straight_ink} → {stepped_ink}"
|
||||
)
|
||||
src2.close()
|
||||
labelled.unlink()
|
||||
|
||||
# Bundle.
|
||||
out = bundle.write(project, source, tmp / "song.zip")
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
names = zf.namelist()
|
||||
assert "song.json" in names and "original.pdf" in names, names
|
||||
meta = json.loads(zf.read("song.json"))
|
||||
assert meta["v"] == 1
|
||||
files = [s["file"] for s in meta["slices"]]
|
||||
assert files == ["001.webp", "002.webp"], files
|
||||
assert all(f in names for f in files)
|
||||
|
||||
source.close()
|
||||
for f in (pdf, out):
|
||||
f.unlink()
|
||||
tmp.rmdir()
|
||||
print("ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user