Files
myscan/tools/scan.py
T
erikandClaude Opus 5.5 f47df93c97 Live browser view while scanning, with downloads and screenshots
scan.py --view serves a page (myscan/webview.py, standard library only) with
MJPEG streams of the latest camera frame and the page as it is stitched,
Start/Stop buttons, PNG/JPEG download of the finished page and a screenshot
of the current view.

myscan/live.py keeps a downscaled preview: new frames are pasted at their
current best pose, and a background thread re-solves the pose graph every
1.5 s (throttled to at most ~50% duty) and redraws the preview off the
registration thread. PageGraph.solve can now run on a prefix of the frames
while add() continues, keeping loop closures and features between solves,
so the final solve after the stop press is much shorter. Frame PNGs are
saved on their own thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:51:59 +00:00

193 lines
8.1 KiB
Python

#!/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. 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).
With --view, a browser viewer (myscan/webview.py) shows the camera and the page as it
is being stitched, and has Start/Stop buttons that work like the scan button."""
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__), ".."))
from myscan.session import ScanSession, quiet_libjpeg # noqa: E402
quiet_libjpeg()
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.xu import XU # noqa: E402
def wait_press(x, viewer=None, poll=0.05):
"""Block until the scan button goes down then up again, or Start is clicked."""
prev = 1
while True:
if viewer and viewer.take_start():
return
b = x.scan_button()
if b and not prev:
break
prev = b
time.sleep(poll)
while x.scan_button():
time.sleep(poll)
def scan_page(x, a, frames_dir=None, flat=None, undistort=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()
def worker():
while (item := work.get()) is not None:
n, im, v = item
f = g.add(im, v)
if live:
live.on_frame(f)
def saver():
while (item := saves.get()) is not None:
cv2.imwrite(os.path.join(frames_dir, f"f{item[0]:05d}.png"), item[1])
th = None
sv = threading.Thread(target=saver, daemon=True) if frames_dir else None
if sv:
sv.start()
if viewer:
viewer.take_stop() # ignore a Stop clicked before the scan started
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 g is None:
g = PageGraph(im.shape, flat=flat, undistort=undistort)
if viewer:
live = LiveSession(g, viewer)
th = threading.Thread(target=worker, daemon=True)
th.start()
work.put((n, im, rec.v))
if sv:
saves.put((n, im))
if live:
live.raw = im
if frames_dir:
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
# 0.7 s so a bouncing start press can't end the scan.
if n % 2 == 0:
down = down + 1 if x.scan_button() else 0
if down >= 2 and t > 0.7:
break
if viewer and viewer.take_stop():
break
if t >= a.max_time:
print("max scan time reached", flush=True)
break
if viewer:
viewer.set_status(t=t, frames=n + 1, backlog=work.qsize())
if t - last >= 1:
print(f"\r {t:5.1f}s {n + 1} frames ({g.n} registered) E6={s.e6}", end="", flush=True)
last = t
print()
if viewer:
viewer.set_status(state="assembling", message="finishing registration")
if th:
work.put(None)
th.join()
if sv:
saves.put(None)
sv.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 g, live
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-o", "--outdir", default="scans")
ap.add_argument("--once", action="store_true", help="exit after one page")
ap.add_argument("--now", action="store_true", help="start the first scan immediately")
ap.add_argument("--video", default="/dev/video4")
ap.add_argument("--target", type=float, default=200, help="auto-exposure target for paper (95th pct of brightest channel)")
ap.add_argument("--exposure", type=int, help="fixed E6 value instead of auto-exposure")
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-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")
ap.add_argument("--view", type=int, nargs="?", const=8000, metavar="PORT",
help="serve a live browser view on PORT (default 8000)")
a = ap.parse_args()
os.makedirs(a.outdir, exist_ok=True)
viewer = None
if a.view is not None:
from myscan.webview import Viewer
viewer = Viewer(a.view)
print("live view: " + " ".join(viewer.urls()), flush=True)
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)
gains = cal["gains_rgb"]
first = True
try:
while True:
if not (first and a.now):
print("press the scan button to start (Ctrl-C to quit)", flush=True)
if viewer:
viewer.set_status(state="idle", message="press the scan button or Start")
wait_press(x, viewer)
first = False
print("scanning - press the scan button again to stop", flush=True)
if viewer:
viewer.set_status(state="scanning", message="press the scan button or Stop to finish")
name = time.strftime("scan-%Y%m%d-%H%M%S")
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)
if live:
live.stop_solving()
live.close()
if g is None or g.n < 2:
print("no frames captured")
if viewer:
viewer.set_status(state="idle", message="no frames captured")
else:
print("assembling the page...", flush=True)
t0 = time.time()
if viewer:
viewer.set_status(message="solving")
page = g.image(white_balance=not a.no_wb)
path = os.path.join(a.outdir, name + ".png")
cv2.imwrite(path, page)
print(f"saved {path} ({time.time() - t0:.1f} s; {g.stats})", flush=True)
if viewer:
s = min(1.0, 1600 / max(page.shape[:2]))
viewer.publish("page", cv2.resize(page, None, fx=s, fy=s, interpolation=cv2.INTER_AREA))
viewer.set_result(path)
viewer.set_status(state="done", message=f"saved {path}", backlog=0)
if a.once:
break
except KeyboardInterrupt:
print("\nbye")
if __name__ == "__main__":
main()