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:
erik
2026-10-02 13:51:59 +00:00
co-authored by Claude Opus 5.5
parent a0ae1ffc0d
commit f47df93c97
5 changed files with 573 additions and 56 deletions
+5 -1
View File
@@ -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://<host>: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
+201
View File
@@ -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()
+60 -43
View File
@@ -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)
+242
View File
@@ -0,0 +1,242 @@
"""Tiny web viewer for live scanning: open http://<host>:<port>/ in a browser.
The latest frame and the page preview are served as MJPEG streams (multipart/
x-mixed-replace, shown natively by <img>), 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 = """<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>myScan live</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; background: #16181c; color: #d8dade; font: 14px/1.4 system-ui, sans-serif;
display: flex; flex-direction: column; height: 100vh; }
header { display: flex; gap: 12px; align-items: center; padding: 8px 14px; background: #1f2228; flex-wrap: wrap; }
header b { font-size: 15px; }
#state { padding: 2px 8px; border-radius: 4px; background: #333; }
#state.scanning { background: #b3261e; color: #fff; }
#state.assembling { background: #8a6d00; color: #fff; }
#state.done { background: #2e7d32; color: #fff; }
#info { color: #9aa0a8; flex: 1; min-width: 200px; }
button { font: inherit; padding: 5px 14px; border-radius: 5px; border: 1px solid #555; background: #2a2e35;
color: inherit; cursor: pointer; }
button:hover { background: #353a42; }
button:disabled { opacity: .4; cursor: default; background: #2a2e35; }
.sep { width: 1px; align-self: stretch; background: #3a3f47; }
main { flex: 1; display: flex; gap: 10px; padding: 10px; min-height: 0; }
.pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
.pane h2 { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: #8b919a; text-transform: uppercase;
letter-spacing: .05em; }
.pane img { flex: 1; min-height: 0; object-fit: contain; background: #0e0f12; border-radius: 4px; width: 100%; }
#framepane { flex: 0 0 22%; }
#pagepane { flex: 1; }
@media (max-width: 700px) { main { flex-direction: column; } #framepane { flex: 0 0 30%; } }
</style></head>
<body>
<header><b>myScan</b><span id="state">idle</span><span id="info"></span>
<button id="start">Start</button><button id="stop">Stop</button><span class="sep"></span>
<button id="png" disabled title="Download the finished page, full resolution">Download PNG</button>
<button id="jpg" disabled title="Download the finished page as JPEG (smaller)">Download JPEG</button>
<button id="shot" title="Save the current view (camera, page, status) as a PNG">Screenshot</button></header>
<main>
<div class="pane" id="framepane"><h2>Camera</h2><img src="/stream/frame" alt="latest frame"></div>
<div class="pane" id="pagepane"><h2>Page</h2><img src="/stream/page" alt="page"></div>
</main>
<script>
const st = document.getElementById('state'), info = document.getElementById('info');
async function poll() {
try {
const s = await (await fetch('/status')).json();
st.textContent = s.state || 'idle'; st.className = s.state || '';
const parts = [];
if (s.t !== undefined) parts.push(s.t.toFixed(1) + ' s');
if (s.frames !== undefined) parts.push(s.frames + ' frames');
if (s.backlog) parts.push('backlog ' + s.backlog);
if (s.loops !== undefined) parts.push(s.loops + ' loop matches');
if (s.message) parts.push(s.message);
info.textContent = parts.join(' \\u00b7 ');
document.getElementById('png').disabled = document.getElementById('jpg').disabled = !s.result;
} catch (e) { st.textContent = 'offline'; st.className = ''; }
setTimeout(poll, 300);
}
poll();
document.getElementById('start').onclick = () => fetch('/start', {method: 'POST'});
document.getElementById('stop').onclick = () => fetch('/stop', {method: 'POST'});
const go = url => { const a = document.createElement('a'); a.href = url; a.download = ''; a.click(); };
document.getElementById('png').onclick = () => go('/download/page.png');
document.getElementById('jpg').onclick = () => go('/download/page.jpg');
document.getElementById('shot').onclick = () => go('/screenshot.png');
</script>
</body></html>
"""
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()
+65 -12
View File
@@ -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: