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>
This commit is contained in:
+65
-12
@@ -3,7 +3,10 @@
|
||||
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)."""
|
||||
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
|
||||
@@ -20,14 +23,17 @@ 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, poll=0.05):
|
||||
"""Block until the scan button goes down then up again."""
|
||||
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
|
||||
@@ -37,26 +43,41 @@ def wait_press(x, poll=0.05):
|
||||
time.sleep(poll)
|
||||
|
||||
|
||||
def scan_page(x, a, frames_dir=None, flat=None, undistort=None, gains=None):
|
||||
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 the PageGraph, or None."""
|
||||
g, rows, work = None, [], queue.Queue()
|
||||
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
|
||||
g.add(im, v)
|
||||
if frames_dir:
|
||||
cv2.imwrite(os.path.join(frames_dir, f"f{n:05d}.png"), im)
|
||||
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()])
|
||||
@@ -66,23 +87,32 @@ def scan_page(x, a, frames_dir=None, flat=None, undistort=None, gains=None):
|
||||
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
|
||||
return g, live
|
||||
|
||||
|
||||
def main():
|
||||
@@ -99,8 +129,15 @@ def main():
|
||||
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)
|
||||
@@ -112,23 +149,39 @@ def main():
|
||||
while True:
|
||||
if not (first and a.now):
|
||||
print("press the scan button to start (Ctrl-C to quit)", flush=True)
|
||||
wait_press(x)
|
||||
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 = scan_page(x, a, frames_dir, flat, undistort, gains)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user