Split stitching into a hardware-independent myscan.stitching package

stitch.py and pagegraph.py mixed laser odometry, image registration,
frame correction, canvas handling, pose-graph maths and the two stitchers.
They are now modules of myscan/stitching, which imports nothing from the
device code:

  geometry    rigid transforms, pose vectors, warp_add
  filters     grayscale, high-pass, feather weights
  correct     Corrector: undistortion + flat field (and weighted variant)
  odometry    LaserOdometry: the laser model and the phase-correlation
              override, shared by both stitchers
  register    ECC, phase correlation, SIFT matching, overlap agreement
  canvas      growable Canvas, layout, parallel render, normalize/compose
  posegraph   robust Gauss-Newton, largest connected component
  loops       keyframes and loop-closure candidates
  pagegraph   PageGraph, now orchestration only
  incremental Stitcher, on the shared Canvas

The stitchers take a Corrector and an odometry object (anything with
predict()) instead of building them, so another device can supply its
own motion prior. live.py drops its own canvas and normalisation for the
shared ones; PageGraph's mosaic check uses the parallel renderer.

Output on scan_text is unchanged (global stitcher identical, incremental
within 1 grey level). stitch.py --live now applies lens correction too
(--no-undistort restores the old output).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erik
2026-10-09 07:55:22 +00:00
co-authored by Claude Opus 5.5
parent 36e714cd62
commit c8b9f610bf
18 changed files with 907 additions and 765 deletions
+7 -7
View File
@@ -2,7 +2,7 @@
"""Scan with the scan button, like the original app: press it to start, glide the
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
myscan/stitching/pagegraph.py) and saved as <outdir>/scan-YYYYmmdd-HHMMSS.png. The mouse goes
back to mouse mode between scans (and on Ctrl-C).
With --view, a browser viewer (myscan/webview.py) shows the camera and the page as it
@@ -24,7 +24,7 @@ import cv2 # noqa: E402
from myscan import calib # noqa: E402
from myscan.live import LiveSession # noqa: E402
from myscan.pagegraph import PageGraph # noqa: E402
from myscan.stitching import Corrector, PageGraph # noqa: E402
from myscan.xu import XU # noqa: E402
@@ -43,7 +43,7 @@ def wait_press(x, viewer=None, poll=0.05):
time.sleep(poll)
def scan_page(x, a, frames_dir=None, flat=None, undistort=None, gains=None, viewer=None):
def scan_page(x, a, frames_dir=None, corrector=None, gains=None, viewer=None):
"""Capture until the button is pressed again. Frames are registered (and saved) on a
worker thread so capture never waits; returns (PageGraph or None, LiveSession or None)."""
g, live, rows, work, saves = None, None, [], queue.Queue(), queue.Queue()
@@ -68,7 +68,7 @@ def scan_page(x, a, frames_dir=None, flat=None, undistort=None, gains=None, view
down, last = 0, 0.0
for t, n, im, rec in s.frames(color=not a.gray):
if g is None:
g = PageGraph(im.shape, flat=flat, undistort=undistort)
g = PageGraph(im.shape, corrector)
if viewer:
live = LiveSession(g, viewer)
th = threading.Thread(target=worker, daemon=True)
@@ -141,8 +141,8 @@ def main():
x = XU(a.video)
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)
corrector = Corrector(None if a.no_flat else calib.FlatField(cal),
None if a.no_undistort else calib.Undistort(cal))
gains = cal["gains_rgb"]
first = True
try:
@@ -160,7 +160,7 @@ def main():
frames_dir = os.path.join(a.outdir, name) if a.save_frames else None
if frames_dir:
os.makedirs(frames_dir)
g, live = scan_page(x, a, frames_dir, flat, undistort, gains, viewer)
g, live = scan_page(x, a, frames_dir, corrector, gains, viewer)
if live:
live.stop_solving()
live.close()
+6 -7
View File
@@ -11,15 +11,14 @@ 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
from myscan.stitching import ROT_GAIN, Corrector, LaserOdometry, PageGraph, Stitcher # noqa: E402
def main():
ap = argparse.ArgumentParser()
ap.add_argument("dir")
ap.add_argument("-o", "--out", help="output image (default <dir>/page.png)")
ap.add_argument("--live", action="store_true", help="use the live frame-by-frame stitcher instead of the global one")
ap.add_argument("--live", action="store_true", help="use the incremental frame-by-frame stitcher")
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")
@@ -35,14 +34,14 @@ def main():
rng = range(a.start, a.end if a.end is not None else len(rows))
cal = calib.load()
flat = None if a.no_flat else calib.FlatField(cal)
undistort = None if a.no_undistort else calib.Undistort(cal)
corrector = Corrector(None if a.no_flat else calib.FlatField(cal),
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) if a.live
else PageGraph(im.shape, flat=flat, undistort=undistort, rot_gain=a.rot_gain))
st = (Stitcher(im.shape, corrector, LaserOdometry(a.rot_gain, max_step_rot=None), refine=not a.no_refine)
if a.live else PageGraph(im.shape, corrector, LaserOdometry(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))