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:
+38
-18
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user