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: