Global page assembly with loop closures and lens undistortion

The frame-by-frame stitcher accumulated drift, so later passes landed rotated
and offset, doubling text. myscan/pagegraph.py instead solves all frame poses
together: ECC odometry between consecutive frames (bridging single corrupt
frames), SIFT+RANSAC loop closures between overlapping passes, and a robust
Gauss-Newton pose graph. Loop matches must agree over the whole overlap, which
rejects matches on repeated text. Off-page frames and frames that disagree
with the mosaic are left out of the render.

calib.Undistort applies the EEPROM lens model (OpenCV k1..k3, p1, p2 plus
the tilt homography, as decoded in EEPROM.md); up to 8.8 px at the corners.

scan.py registers frames on a worker thread so capture no longer drops
frames, then assembles the page after the stop press (~11 s for a full
page). stitch.py uses the new assembly by default; --live keeps the old one.

EEPROM.md documents the full calibration blob layout, checksum and signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erik
2026-10-02 13:06:45 +00:00
co-authored by Claude Opus 5.5
parent e70d56d957
commit 026aa6c894
8 changed files with 781 additions and 36 deletions
+10 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Stitch a directory recorded by tools/record.py into a page image."""
"""Stitch a directory recorded by tools/record.py (or scan.py --save-frames) into a page image."""
import argparse
import csv
import os
@@ -11,6 +11,7 @@ import cv2 # noqa: E402
import numpy as np # noqa: E402
from myscan import calib # noqa: E402
from myscan.pagegraph import PageGraph # noqa: E402
from myscan.stitch import ROT_GAIN, Stitcher # noqa: E402
@@ -18,9 +19,11 @@ def main():
ap = argparse.ArgumentParser()
ap.add_argument("dir")
ap.add_argument("-o", "--out", help="output image (default <dir>/page.png)")
ap.add_argument("--no-refine", action="store_true", help="lasers only, no image registration")
ap.add_argument("--live", action="store_true", help="use the live frame-by-frame stitcher instead of the global one")
ap.add_argument("--no-refine", action="store_true", help="with --live: lasers only, no image registration")
ap.add_argument("--rot-gain", type=float, default=ROT_GAIN)
ap.add_argument("--no-flat", action="store_true", help="skip the EEPROM flat-field correction")
ap.add_argument("--no-undistort", action="store_true", help="skip the EEPROM lens correction")
ap.add_argument("--no-wb", action="store_true", help="skip white-patch balancing")
ap.add_argument("--start", type=int, default=0)
ap.add_argument("--end", type=int)
@@ -31,12 +34,15 @@ def main():
frames = [int(r["frame"]) for r in rows]
rng = range(a.start, a.end if a.end is not None else len(rows))
flat = None if a.no_flat else calib.FlatField(calib.load())
cal = calib.load()
flat = None if a.no_flat else calib.FlatField(cal)
undistort = None if a.no_undistort else calib.Undistort(cal)
st = None
for i in rng:
im = cv2.imread(os.path.join(a.dir, f"f{frames[i]:05d}.png"), cv2.IMREAD_UNCHANGED)
if st is None:
st = Stitcher(im.shape, refine=not a.no_refine, rot_gain=a.rot_gain, flat=flat)
st = (Stitcher(im.shape, refine=not a.no_refine, rot_gain=a.rot_gain, flat=flat) if a.live
else PageGraph(im.shape, flat=flat, undistort=undistort, rot_gain=a.rot_gain))
st.add(im, v[i])
out = a.out or os.path.join(a.dir, "page.png")
cv2.imwrite(out, st.image(white_balance=not a.no_wb))