From f47df93c9754c64ce711330af5327891dc0556b2 Mon Sep 17 00:00:00 2001 From: erik Date: Fri, 2 Oct 2026 13:51:59 +0000 Subject: [PATCH] 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 --- README.md | 6 +- myscan/live.py | 201 ++++++++++++++++++++++++++++++++++++ myscan/pagegraph.py | 103 +++++++++++-------- myscan/webview.py | 242 ++++++++++++++++++++++++++++++++++++++++++++ tools/scan.py | 77 +++++++++++--- 5 files changed, 573 insertions(+), 56 deletions(-) create mode 100644 myscan/live.py create mode 100644 myscan/webview.py diff --git a/README.md b/README.md index f494d38..8873762 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,11 @@ Linux driver tools for the Hama myScan (00052343) scanner mouse, a Dacuda design - `myscan/pagegraph.py` — assemble a page from all frames at once: frame-to-frame registration, loop closures between passes, pose-graph optimisation (used by `scan.py` and `stitch.py`) - `myscan/stitch.py` — the older live, frame-by-frame stitcher (`stitch.py --live`) and shared helpers -- `tools/scan.py` — scan with the button: `python tools/scan.py` (press to start, press again to stop) +- `tools/scan.py` — scan with the button: `python tools/scan.py` (press to start, press again to stop). + `--view [PORT]` serves a live view at `http://:8000/`: the camera, the page as it is stitched + (re-solved every 1.5 s, current position outlined), Start/Stop buttons, PNG/JPEG download of the finished + page and a Screenshot button (camera + page + status as one PNG) +- `myscan/live.py`, `myscan/webview.py` — the live preview and its browser viewer (standard library only) - `tools/record.py` — record a scan session: `python tools/record.py -d 25 -o scan_text` - `tools/stitch.py` — stitch a recording: `python tools/stitch.py scan_text` -> `scan_text/page.png` - `tools/probe.py` — log raw frames and HID reports side by side diff --git a/myscan/live.py b/myscan/live.py new file mode 100644 index 0000000..046cc3f --- /dev/null +++ b/myscan/live.py @@ -0,0 +1,201 @@ +"""Live page preview while scanning. + +LivePreview keeps a downscaled copy of the page: each frame is pasted at its current +best pose as it is registered (the last PageGraph solution, extended by odometry), and +when a newer solution arrives the whole preview is redrawn from it, so drift visibly +snaps into place. LiveSession runs the periodic re-solve and publishes the latest frame, +the preview and a status dict to a viewer (see webview.py). +""" +import threading +import time +import traceback + +import numpy as np +import cv2 + +from .stitch import _aff, _feather + + +def page_orient(img): + """Frames and canvas are transposed and mirrored relative to the page (see stitch.compose).""" + return cv2.flip(cv2.transpose(img), -1) + + +class _Canvas: + """Growable weighted canvas in scaled page coordinates.""" + + def __init__(self, channels): + self.ch = channels + self.acc = self.ws = None + self.off = np.eye(3) # scaled page coords -> canvas pixels + + def paste(self, img, fe, A): + """Add img (with feather weights fe) placed by A (img px -> scaled page coords).""" + h, w = img.shape[:2] + q = (A @ np.array([[0, 0, 1], [w, 0, 1], [0, h, 1], [w, h, 1.0]]).T)[:2] + self._fit(q.min(1), q.max(1)) + x0, y0 = np.maximum(np.floor((self.off @ np.r_[q.min(1), 1])[:2]).astype(int) - 1, 0) + x1, y1 = np.ceil((self.off @ np.r_[q.max(1), 1])[:2]).astype(int) + 2 + x1, y1 = min(x1, self.ws.shape[1]), min(y1, self.ws.shape[0]) + L = (_aff(0, -x0, -y0) @ self.off @ A)[:2] + few = fe[..., None] if img.ndim == 3 else fe + self.acc[y0:y1, x0:x1] += cv2.warpAffine(img.astype(np.float32) * few, L, (x1 - x0, y1 - y0)) + self.ws[y0:y1, x0:x1] += cv2.warpAffine(fe, L, (x1 - x0, y1 - y0)) + + def _fit(self, lo, hi, pad=150): + """Grow the canvas so scaled page coords lo..hi are inside it.""" + if self.acc is None: + self.off = _aff(0, pad - lo[0], pad - lo[1]) + W, H = int(hi[0] - lo[0]) + 2 * pad, int(hi[1] - lo[1]) + 2 * pad + self.acc = np.zeros((H, W) + self.ch, np.float32) + self.ws = np.zeros((H, W), np.float32) + return + a = (self.off @ np.r_[lo, 1])[:2] + b = (self.off @ np.r_[hi, 1])[:2] + H, W = self.ws.shape + left, top = int(max(0, pad - a[0])) if a[0] < 10 else 0, int(max(0, pad - a[1])) if a[1] < 10 else 0 + right = int(max(0, b[0] + pad - W)) if b[0] > W - 10 else 0 + bottom = int(max(0, b[1] + pad - H)) if b[1] > H - 10 else 0 + if left or top or right or bottom: + self.acc = cv2.copyMakeBorder(self.acc, top, bottom, left, right, cv2.BORDER_CONSTANT, value=0) + self.ws = cv2.copyMakeBorder(self.ws, top, bottom, left, right, cv2.BORDER_CONSTANT, value=0) + self.off = _aff(0, left, top) @ self.off + + +class LivePreview: + def __init__(self, graph, scale=0.3): + self.g, self.s = graph, scale + self.S = np.diag([scale, scale, 1.0]) + self.Si = np.diag([1 / scale, 1 / scale, 1.0]) + self.small = [] # downscaled corrected frames, uint8 + self.lock = threading.Lock() + self.c = None # current _Canvas + self.pasted = 0 # frames < pasted are on the canvas + self.cur = None # pose of the last pasted frame + self.seen = None # solution the canvas was drawn from + self.version = 0 + + def add(self, corrected): + """Called after PageGraph.add with the corrected frame it returned. Cheap: pastes + the new frame only; redrawing from a new solution is redraw()'s job.""" + small = cv2.resize(corrected, None, fx=self.s, fy=self.s, interpolation=cv2.INTER_AREA) + self.small.append(small.clip(0, 255).astype(np.uint8)) + if self.c is None: + self.fe = _feather(*small.shape[:2]) + self.c = _Canvas(small.shape[2:]) + self.refresh() + + def refresh(self): + """Paste frames not yet shown, at the latest solution's poses extended by odometry.""" + with self.lock: + self._catch_up() + + def _catch_up(self): + while self.pasted < len(self.small): + i = self.pasted + M = self.g.pose(i) if self.cur is None else self.cur @ self.g.odo[i - 1][2] + self.c.paste(self.small[i], self.fe, self.S @ M @ self.Si) + self.cur, self.pasted = M, i + 1 + self.version += 1 + + def redraw(self): + """Rebuild the preview from the latest solution (if new). The new canvas is drawn + without holding the lock, then swapped in, so add() is never kept waiting.""" + sol = self.g.solution + if sol is None or sol is self.seen or self.c is None: + return + x, use = sol + k = min(len(x), len(self.small)) + c = _Canvas(self.c.ch) + for i in use: + if i < k: + c.paste(self.small[i], self.fe, self.S @ _aff(x[i, 2], x[i, 0], x[i, 1]) @ self.Si) + with self.lock: + self.c, self.seen = c, sol + self.cur, self.pasted = _aff(x[k - 1, 2], x[k - 1, 0], x[k - 1, 1]), k + self._catch_up() + + def render(self, background=40): + """Preview in page orientation (BGR uint8) with the current frame outlined, or None.""" + with self.lock: + if self.c is None or self.c.acc is None: + return None + acc, ws, off, cur = self.c.acc.copy(), self.c.ws.copy(), self.c.off.copy(), self.cur + covered = ws > 0.05 + out = acc / np.maximum(ws, 1e-6)[..., None] if acc.ndim == 3 else acc / np.maximum(ws, 1e-6) + px = out[covered][::7] + if len(px): + out = out * (245 / np.maximum(np.percentile(px, 97, axis=0), 1)) + m = covered[..., None] if out.ndim == 3 else covered + out = np.where(m, out, background).clip(0, 255).astype(np.uint8) + if out.ndim == 2: + out = cv2.cvtColor(out, cv2.COLOR_GRAY2BGR) + if cur is not None: + h, w = self.g.h, self.g.w + q = off @ self.S @ cur @ np.array([[0, 0, 1], [w, 0, 1], [w, h, 1], [0, h, 1.0]]).T + cv2.polylines(out, [np.round(q[:2].T).astype(np.int32)], True, (40, 40, 230), 2, cv2.LINE_AA) + ys, xs = np.nonzero(covered) + if len(ys): + out = out[max(ys.min() - 20, 0):ys.max() + 21, max(xs.min() - 20, 0):xs.max() + 21] + return page_orient(out) + + +class LiveSession: + """Per-scan live view: re-solves every `every` frames on a background thread and + publishes frame, preview and status to `viewer` a few times a second.""" + + def __init__(self, graph, viewer, every=45, fps=8): + self.g, self.viewer, self.every, self.period = graph, viewer, every, 1 / fps + self.preview = LivePreview(graph) + self.raw = None # latest captured frame (set by the capture loop) + self._solving = True + self._closed = False + self._solver = threading.Thread(target=self._solve_loop, daemon=True) + self._render = threading.Thread(target=self._render_loop, daemon=True) + self._solver.start() + self._render.start() + + def on_frame(self, corrected): + self.preview.add(corrected) + + def _solve_loop(self): + """Re-solve every `every` frames, resting at least as long as the last solve took + so it never takes more than about half the CPU from registration.""" + done, rest_until = 0, 0.0 + while self._solving: + n = self.g.n + if n >= 30 and n - done >= self.every and time.time() >= rest_until: + t = time.time() + try: + self.g.solve(n) + self.preview.redraw() + except Exception: # keep previewing; the final solve reports errors + traceback.print_exc() + done, rest_until = n, 2 * time.time() - t + else: + time.sleep(0.05) + + def stop_solving(self): + """Wait for a running re-solve to finish; call before the final solve.""" + self._solving = False + self._solver.join() + + def _render_loop(self): + seen_raw, seen_ver = None, -1 + while not self._closed: + t = time.time() + raw = self.raw + if raw is not None and raw is not seen_raw: + self.viewer.publish("frame", page_orient(raw)) + seen_raw = raw + if self.preview.version != seen_ver: + seen_ver = self.preview.version + img = self.preview.render() + if img is not None: + self.viewer.publish("page", img) + self.viewer.set_status(loops=len(self.g.loops)) + time.sleep(max(0.0, self.period - (time.time() - t))) + + def close(self): + self._closed = True + self._render.join() diff --git a/myscan/pagegraph.py b/myscan/pagegraph.py index 3aa169e..3e70da1 100644 --- a/myscan/pagegraph.py +++ b/myscan/pagegraph.py @@ -78,10 +78,12 @@ class PageGraph: self.flat, self.undistort, self.rot_gain, self.min_cc = flat, undistort, rot_gain, min_cc self.center = (self.w / 2, self.h / 2) self.window = cv2.createHanningWindow((self.w, self.h), cv2.CV_32F) - self.frames, self.hp, self.anchored = [], [], [] + self.frames, self.hp, self.registered = [], [], [] # registered: has an image-verified edge self.odo, self.bridges, self.loops = [], [], [] # edges: (i, j, T, sigma) self.prev_v = None - self.stats = {"frames": 0, "odo_ecc": 0, "odo_laser": 0, "bridges": 0} + self._feat, self._tried = {}, set() # kept across solves + self.solution = None # (poses (n, 3), indices of frames to draw) + self.stats = {"frames": 0, "odo_ecc": 0, "odo_laser": 0, "bridges": 0, "loop_tries": 0, "pruned": 0} @property def n(self): @@ -92,7 +94,9 @@ class PageGraph: return f if self.undistort is None else self.undistort(f) def add(self, frame, v): - """frame: HxW or HxWx3 uint8 raw frame; v: its record's six raw values.""" + """frame: HxW or HxWx3 uint8 raw frame; v: its record's six raw values. + Returns the corrected (flat-fielded, undistorted) float frame. + Safe to call while solve() runs on another thread.""" f = self._prep(frame) gray = np.ascontiguousarray(f.mean(axis=2) if f.ndim == 3 else f, np.float32) hp = _highpass(gray) @@ -100,7 +104,7 @@ class PageGraph: i = self.n self.frames.append(frame) self.hp.append(hp) - self.anchored.append(False) + self.registered.append(False) self.stats["frames"] += 1 if i: d = laser_deltas(np.stack([self.prev_v, v]))[1] @@ -113,7 +117,7 @@ class PageGraph: T, cc = _ecc(self.hp[i - 1], hp, T0) if T is not None and cc > self.min_cc and _close(T, T0, 3.0, 15): self.odo.append((i - 1, i, T, SIGMA_ECC)) - self.anchored[i - 1] = self.anchored[i] = True + self.registered[i - 1] = self.registered[i] = True self.stats["odo_ecc"] += 1 else: self.odo.append((i - 1, i, T0, SIGMA_LASER)) @@ -123,17 +127,31 @@ class PageGraph: Tb, cc = _ecc(self.hp[i - 2], hp, Tb0) if Tb is not None and cc > self.min_cc and _close(Tb, Tb0, 6.0, 60): self.bridges.append((i - 2, i, Tb, SIGMA_ECC)) - self.anchored[i - 2] = self.anchored[i] = True + self.registered[i - 2] = self.registered[i] = True self.stats["bridges"] += 1 self.prev_v = v + return f # --- poses ------------------------------------------------------------------- - def _chain(self): - P = [np.eye(3)] - for _, _, T, _ in self.odo: + def pose(self, i): + """Current best pose (3x3) of frame i: the last solution, extended by odometry.""" + x = self.solution[0] if self.solution is not None else np.zeros((1, 3)) + k = min(i, len(x) - 1) + M = _aff(x[k, 2], x[k, 0], x[k, 1]) + for _, _, T, _ in self.odo[k:i]: + M = M @ T + return M + + def _chain(self, n): + """Initial poses for frames < n: the last solution, then odometry for newer frames.""" + x0 = self.solution[0] if self.solution is not None else np.zeros((1, 3)) + x0 = x0[:n] + P = [_aff(x0[-1, 2], x0[-1, 0], x0[-1, 1])] + for _, _, T, _ in self.odo[len(x0) - 1:n - 1]: P.append(P[-1] @ T) - return np.array([[M[0, 2], M[1, 2], np.arctan2(M[1, 0], M[0, 0])] for M in P]) + ext = np.array([[M[0, 2], M[1, 2], np.arctan2(M[1, 0], M[0, 0])] for M in P[1:]]).reshape(-1, 3) + return np.vstack([x0, ext]) def _optimise(self, x, edges, iters=20): """Robust Gauss-Newton with frame 0 held fixed. x: (N, 3) -> (x, residual per edge in sigmas).""" @@ -185,8 +203,8 @@ class PageGraph: def _keyframes(self, x, step_px=60.0): keys, last = [], None - for i in range(self.n): - if not self.anchored[i]: + for i in range(len(x)): + if not self.registered[i]: continue if last is None or np.hypot(*(x[i, :2] - x[last, :2])) > step_px or i - last > 15: keys.append(i) @@ -282,15 +300,18 @@ class PageGraph: new += 1 return new - def solve(self, rounds=4): - """Poses (N, 3) after loop closing and optimisation.""" - self._feat, self._tried, self.loops = {}, set(), [] - self.stats.update(loop_tries=0, loops=0, pruned=0) - x = self._chain() + def solve(self, n=None, rounds=4): + """Solve the poses of frames < n (default: all). Can run on another thread while + add() continues; earlier loop closures and features are reused, so repeated solves + during a scan only try the new pairs. Sets self.solution and returns the poses.""" + # add() appends a frame before its odometry edge; only take frames that have one. + n = min(self.n if n is None else n, len(self.odo) + 1) + x = self._chain(n) keys = self._keyframes(x) self._pool = ThreadPoolExecutor(os.cpu_count()) - self._feat.update(zip(keys, self._pool.map(self._detect, keys))) - base = self.odo + self.bridges + todo = [k for k in keys if k not in self._feat] + self._feat.update(zip(todo, self._pool.map(self._detect, todo))) + base = self.odo[:n - 1] + [e for e in self.bridges if e[1] < n] for r in range(rounds): # Wide search first (drift can be hundreds of px), tighter once poses are good. new = self._close_loops(x, keys, radius=700 if r == 0 else 500) @@ -303,36 +324,33 @@ class PageGraph: self.stats["pruned"] += int(bad.sum()) if r and not new: break - self.anchored = self._main_component() - self._verify(x) + use = self._verify(x, self._main_component(n, base)) self._pool.shutdown() - self._pool, self._feat = None, {} + self._pool = None self.stats["loops"] = len(self.loops) - self.stats["rendered"] = int(sum(self.anchored)) - self.residuals = nrm + self.stats["rendered"] = len(use) + self.solution = (x, use) return x - def _main_component(self): + def _main_component(self, n, base): """Frames linked to the largest group by image-verified edges. Off-page stretches register frame to frame but connect to the page only through the lasers.""" - parent = list(range(self.n)) + parent = list(range(n)) def find(k): while parent[k] != k: parent[k] = parent[parent[k]] k = parent[k] return k - for i, j, _, s in self.odo + self.bridges + self.loops: + for i, j, _, s in base + self.loops: if s != SIGMA_LASER: parent[find(i)] = find(j) - roots = np.array([find(k) for k in range(self.n)]) - main = np.bincount(roots).argmax() - return list(roots == main) + roots = np.array([find(k) for k in range(n)]) + return list(np.nonzero(roots == np.bincount(roots).argmax())[0]) - def _verify(self, x, min_ncc=0.5, scale=0.5): - """Drop frames that disagree with the mosaic of all frames (a stretch held only by a - wrong registration). Compared on high-passed images at half resolution.""" - use = [i for i in range(self.n) if self.anchored[i]] + def _verify(self, x, use, min_ncc=0.5, scale=0.5): + """Frames of `use` that agree with the mosaic of all of them (drops a stretch held + only by a wrong registration). Compared on high-passed images at half resolution.""" S = np.diag([scale, scale, 1.0]) Ms, off, W, H = self._layout(x, use, S) fe = cv2.resize(_feather(self.h, self.w), None, fx=scale, fy=scale) @@ -350,12 +368,9 @@ class PageGraph: def score(k): back = cv2.warpAffine(mos, np.linalg.inv(A[k])[:2], (w, h)) return np.corrcoef(small[k][10:-10, 10:-10].ravel(), back[10:-10, 10:-10].ravel())[0, 1] - self.ncc = np.full(self.n, np.nan) - self.ncc[use] = list(self._pool.map(score, range(len(use)))) - for i in use: - if not self.ncc[i] >= min_ncc: - self.anchored[i] = False - self.stats["inconsistent"] = int(np.sum(self.ncc < min_ncc)) + ncc = np.array(list(self._pool.map(score, range(len(use))))) + self.stats["inconsistent"] = int(np.sum(~(ncc >= min_ncc))) + return [i for i, c in zip(use, ncc) if c >= min_ncc] def _layout(self, x, use, S=np.eye(3)): """Poses of frames `use` scaled by S, the offset that puts them all on a canvas, and its size.""" @@ -368,9 +383,11 @@ class PageGraph: # --- rendering --------------------------------------------------------------- - def image(self, x=None, white_balance=True, background=255): - x = self.solve() if x is None else x - use = [i for i in range(self.n) if self.anchored[i]] + def image(self, white_balance=True, background=255): + """Full-resolution page from the latest solution (solving first if frames were added since).""" + if self.solution is None or len(self.solution[0]) < self.n: + self.solve() + x, use = self.solution Ms, off, W, H = self._layout(x, use) ch = self.frames[0].shape[2] if self.frames[0].ndim == 3 else 0 acc = np.zeros((H, W, ch) if ch else (H, W), np.float32) diff --git a/myscan/webview.py b/myscan/webview.py new file mode 100644 index 0000000..e3d98bc --- /dev/null +++ b/myscan/webview.py @@ -0,0 +1,242 @@ +"""Tiny web viewer for live scanning: open http://:/ in a browser. + +The latest frame and the page preview are served as MJPEG streams (multipart/ +x-mixed-replace, shown natively by ), the status as JSON polled by the page. +Start/Stop buttons do the same as the scan button; the finished page can be downloaded +as PNG or JPEG, and Screenshot saves the current view (camera, page and status) as one +PNG. Standard library only. +""" +import json +import os +import socket +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import cv2 +import numpy as np + +PAGE = """ + +myScan live + + +
myScanidle + + + +
+
+

Camera

latest frame
+

Page

page
+
+ + +""" + + +class _Stream: + def __init__(self): + self.cond = threading.Condition() + self.jpeg, self.version = None, 0 + + def put(self, jpeg): + with self.cond: + self.jpeg, self.version = jpeg, self.version + 1 + self.cond.notify_all() + + +class Viewer: + def __init__(self, port=8000, host="0.0.0.0"): + self.streams = {"frame": _Stream(), "page": _Stream()} + self.status = {"state": "idle"} + self.result, self._jpg = None, None # path of the finished page; cached JPEG of it + self.start_event, self.stop_event = threading.Event(), threading.Event() + viewer = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + if self.path == "/": + self._send(200, "text/html; charset=utf-8", PAGE.encode()) + elif self.path == "/status": + self._send(200, "application/json", json.dumps(viewer.status).encode()) + elif self.path.startswith("/stream/") and self.path[8:] in viewer.streams: + self._mjpeg(viewer.streams[self.path[8:]]) + elif self.path in ("/download/page.png", "/download/page.jpg"): + got = viewer.result_file(self.path[-3:]) + if got is None: + self._send(404, "text/plain", b"no finished page yet") + else: + self._send(200, "image/" + ("png" if self.path.endswith("png") else "jpeg"), got[1], + filename=got[0]) + elif self.path == "/screenshot.png": + self._send(200, "image/png", viewer.screenshot(), + filename=time.strftime("myscan-view-%Y%m%d-%H%M%S.png")) + else: + self._send(404, "text/plain", b"not found") + + def do_POST(self): + if self.path in ("/start", "/stop"): + (viewer.start_event if self.path == "/start" else viewer.stop_event).set() + self._send(204, "text/plain", b"") + else: + self._send(404, "text/plain", b"not found") + + def _send(self, code, ctype, body, filename=None): + self.send_response(code) + self.send_header("Content-Type", ctype) + if filename: + self.send_header("Content-Disposition", f'attachment; filename="{filename}"') + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _mjpeg(self, stream): + self.send_response(200) + self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") + self.send_header("Cache-Control", "no-store") + self.end_headers() + seen = -1 + try: + while True: + with stream.cond: + stream.cond.wait_for(lambda: stream.version != seen, timeout=5) + jpeg, seen = stream.jpeg, stream.version + if jpeg is None: + continue + self.wfile.write(b"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n" + % len(jpeg) + jpeg + b"\r\n") + except (BrokenPipeError, ConnectionResetError): + pass + + self.server = ThreadingHTTPServer((host, port), Handler) + self.server.daemon_threads = True + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.port = self.server.server_address[1] + + def urls(self): + """Addresses the viewer is reachable at (best guess at the outward-facing one).""" + ips = ["127.0.0.1"] + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("10.255.255.255", 1)) + ips.insert(0, s.getsockname()[0]) + except OSError: + pass + return [f"http://{ip}:{self.port}/" for ip in dict.fromkeys(ips)] + + def publish(self, name, bgr, quality=80): + ok, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, quality]) + if ok: + self.streams[name].put(buf.tobytes()) + + def set_result(self, path): + """Offer the saved page at `path` for download.""" + self.result, self._jpg = path, None + self.set_status(result=os.path.basename(path)) + + def result_file(self, ext): + """(filename, bytes) of the finished page as 'png' or 'jpg', or None.""" + path = self.result + if not path or not os.path.exists(path): + return None + base = os.path.splitext(os.path.basename(path))[0] + if ext == "png": + return base + ".png", open(path, "rb").read() + if self._jpg is None or self._jpg[0] != path: + ok, buf = cv2.imencode(".jpg", cv2.imread(path), [cv2.IMWRITE_JPEG_QUALITY, 92]) + self._jpg = (path, buf.tobytes()) + return base + ".jpg", self._jpg[1] + + def screenshot(self): + """The current view as PNG bytes: camera and page side by side under a status line.""" + imgs = [] + for name in ("frame", "page"): + jpeg = self.streams[name].jpeg + if jpeg is not None: + imgs.append(cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR)) + h = max([im.shape[0] for im in imgs], default=200) + cam = [cv2.resize(imgs[0], (max(1, round(imgs[0].shape[1] * h / imgs[0].shape[0])), h))] if len(imgs) == 2 else [] + rest = imgs[1:] if len(imgs) == 2 else imgs + parts = [np.pad(im, ((0, h - im.shape[0]), (0, 0), (0, 0)), constant_values=22) for im in cam + rest] + gap = np.full((h, 10, 3), 22, np.uint8) + body = np.hstack([p for im in parts for p in (im, gap)][:-1]) if parts else np.full((h, 400, 3), 22, np.uint8) + s = self.status + line = " ".join(str(v) for v in [s.get("state", ""), f"{s['t']:.1f} s" if "t" in s else "", + f"{s['frames']} frames" if "frames" in s else "", + f"{s['loops']} loop matches" if "loops" in s else "", + s.get("message", ""), time.strftime("%Y-%m-%d %H:%M:%S")] if v) + head = np.full((34, body.shape[1], 3), 31, np.uint8) + cv2.putText(head, "myScan " + line, (10, 23), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (220, 222, 226), 1, cv2.LINE_AA) + return cv2.imencode(".png", np.vstack([head, body]))[1].tobytes() + + def set_status(self, **kw): + self.status = {**self.status, **kw} + + def take_start(self): + """True once per Start click.""" + if self.start_event.is_set(): + self.start_event.clear() + return True + return False + + def take_stop(self): + if self.stop_event.is_set(): + self.stop_event.clear() + return True + return False + + def close(self): + self.server.shutdown() diff --git a/tools/scan.py b/tools/scan.py index 5077b2f..943d0ad 100644 --- a/tools/scan.py +++ b/tools/scan.py @@ -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 /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: