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
+38 -18
View File
@@ -1,12 +1,15 @@
#!/usr/bin/env python3
"""Scan with the scan button, like the original app: press it to start, glide the
mouse over the page, press it again to stop. The page is stitched live and saved
as <outdir>/scan-YYYYmmdd-HHMMSS.png; the mouse goes back to mouse mode between
scans (and on Ctrl-C)."""
mouse over the page, press it again to stop. Frames are registered while you scan;
after the stop press the page is assembled from all of them at once (see
myscan/pagegraph.py) and saved as <outdir>/scan-YYYYmmdd-HHMMSS.png. The mouse goes
back to mouse mode between scans (and on Ctrl-C)."""
import argparse
import csv
import os
import queue
import sys
import threading
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@@ -17,7 +20,7 @@ quiet_libjpeg()
import cv2 # noqa: E402
from myscan import calib # noqa: E402
from myscan.stitch import Stitcher # noqa: E402
from myscan.pagegraph import PageGraph # noqa: E402
from myscan.xu import XU # noqa: E402
@@ -34,16 +37,27 @@ def wait_press(x, poll=0.05):
time.sleep(poll)
def scan_page(x, a, frames_dir=None, flat=None, gains=None):
st, rows = None, []
def scan_page(x, a, frames_dir=None, flat=None, undistort=None, gains=None):
"""Capture until the button is pressed again. Frames are registered (and saved) on a
worker thread so capture never waits; returns the PageGraph, or None."""
g, rows, work = None, [], queue.Queue()
def worker():
while (item := work.get()) is not None:
n, im, v = item
g.add(im, v)
if frames_dir:
cv2.imwrite(os.path.join(frames_dir, f"f{n:05d}.png"), im)
th = None
with ScanSession(a.video, a.target, a.exposure, xu=x, gains=gains) as s:
down, last = 0, 0.0
for t, n, im, rec in s.frames(color=not a.gray):
if st is None:
st = Stitcher(im.shape, refine=not a.no_refine, flat=flat)
st.add(im, rec.v)
if g is None:
g = PageGraph(im.shape, flat=flat, undistort=undistort)
th = threading.Thread(target=worker, daemon=True)
th.start()
work.put((n, im, rec.v))
if frames_dir:
cv2.imwrite(os.path.join(frames_dir, f"f{n:05d}.png"), im)
rows.append([round(t, 4), n, rec.counter, *rec.v, rec.b14, rec.b15, rec.b16, rec.flags, s.e6,
round(float(im.mean()), 1), rec.raw.hex()])
# Stop on a press: two "down" reads in a row (debounce), ignoring the first
@@ -56,17 +70,19 @@ def scan_page(x, a, frames_dir=None, flat=None, gains=None):
print("max scan time reached", flush=True)
break
if t - last >= 1:
print(f"\r {t:5.1f}s {st.n} frames canvas {st.size[0]}x{st.size[1]} E6={s.e6}",
end="", flush=True)
print(f"\r {t:5.1f}s {n + 1} frames ({g.n} registered) E6={s.e6}", end="", flush=True)
last = t
print()
if th:
work.put(None)
th.join()
if frames_dir: # same layout as tools/record.py, so tools/stitch.py can replay it
with open(os.path.join(frames_dir, "records.csv"), "w", newline="") as fh:
w = csv.writer(fh)
w.writerow(["t", "frame", "counter", "v0", "v1", "v2", "v3", "v4", "v5",
"b14", "b15", "b16", "flags", "e6", "brightness", "raw"])
w.writerows(rows)
return st
return g
def main():
@@ -80,7 +96,7 @@ def main():
ap.add_argument("--gray", action="store_true")
ap.add_argument("--no-wb", action="store_true", help="skip white-patch balancing")
ap.add_argument("--no-flat", action="store_true", help="skip the EEPROM flat-field correction")
ap.add_argument("--no-refine", action="store_true", help="lasers only, no image registration")
ap.add_argument("--no-undistort", action="store_true", help="skip the EEPROM lens correction")
ap.add_argument("--save-frames", action="store_true", help="also keep raw frames + records.csv per scan")
ap.add_argument("--max-time", type=float, default=180, help="stop a scan after this many seconds")
a = ap.parse_args()
@@ -89,6 +105,7 @@ def main():
print("reading calibration from the mouse's EEPROM (cached after the first time)", flush=True)
cal = calib.load(x)
flat = None if a.no_flat else calib.FlatField(cal)
undistort = None if a.no_undistort else calib.Undistort(cal)
gains = cal["gains_rgb"]
first = True
try:
@@ -102,13 +119,16 @@ def main():
frames_dir = os.path.join(a.outdir, name) if a.save_frames else None
if frames_dir:
os.makedirs(frames_dir)
st = scan_page(x, a, frames_dir, flat, gains)
if st is None or st.n < 2:
g = scan_page(x, a, frames_dir, flat, undistort, gains)
if g is None or g.n < 2:
print("no frames captured")
else:
print("assembling the page...", flush=True)
t0 = time.time()
page = g.image(white_balance=not a.no_wb)
path = os.path.join(a.outdir, name + ".png")
cv2.imwrite(path, st.image(white_balance=not a.no_wb))
print(f"saved {path} ({st.n} frames, {st.stats})", flush=True)
cv2.imwrite(path, page)
print(f"saved {path} ({time.time() - t0:.1f} s; {g.stats})", flush=True)
if a.once:
break
except KeyboardInterrupt: