Add a sensor dial under the camera with live scan readings

Shows the mouse's rotation on the page since the scan started (a big "A",
coloured by tracking), direction and speed (arrow), and heading, speed,
distance, offset, area, tracking, laser quality, exposure, paper level
and fps. Also drops the "Read text", "Auto" and "Save text" toolbar
actions (OCR runs automatically on every finished page or opened image).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
erik
2026-10-11 21:44:03 +00:00
co-authored by Claude Opus 4.7
parent 61b6cf70a7
commit 0dd807a4e7
4 changed files with 219 additions and 39 deletions
+6 -3
View File
@@ -20,17 +20,20 @@ Linux driver tools for the Hama myScan (00052343) scanner mouse, a Dacuda design
(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)
- `tools/app.py` — desktop app (Qt/PySide6; Linux, Windows, macOS): live camera and page view while scanning
(Start/Stop or the scan button, F5), then OCR of the finished page with RapidOCR (`myscan/ocr.py`;
(Start/Stop or the scan button, F5). Under the camera a dial shows how the mouse is turned on the page since the
scan started (a big "A", coloured by tracking), its direction and speed (arrow), and the live readings: speed,
distance, offset from the start, area covered, tracking (share of recent frames matched by image rather than
the lasers alone), the lasers' surface quality, exposure, paper brightness and frame rate. Then OCR of the finished page with RapidOCR (`myscan/ocr.py`;
multilingual PP-OCRv6 models bundled in the wheel, works offline; an upside-down page is detected and turned). Level (Ctrl+L, on by default) shows the page rotated so its lines
are horizontal, using the text direction found by OCR; switch it off to see the page as stitched. Save image saves
what is shown. Recognised lines are outlined on the page by
confidence, and the text is selected right on the page like in a PDF viewer: drag (in reading order across
lines), double-click a word, triple-click a line, shift-click to extend, Ctrl+A; Ctrl+C or right-click copies.
With Paragraphs on (Ctrl+J, the default), copied and saved text has each paragraph on one line:
With Paragraphs on (Ctrl+J, the default), copied text has each paragraph on one line:
line breaks inside a paragraph become spaces and words hyphenated across lines are rejoined; a line ends a
paragraph if the next line is indented or its first word would have fitted on it (works for justified and
ragged text), and rows with several columns are left alone. Right-click offers the other form. Middle-drag or
Space+drag pans, the wheel zooms. Copy all and Save text (.txt) take the whole page. `python tools/app.py [image]` also opens and reads existing images (Open, or
Space+drag pans, the wheel zooms. Copy all takes the whole page. `python tools/app.py [image]` also opens and reads existing images (Open, or
drag and drop), which is all it does on machines without the mouse
- `myscan/scanner.py` — the scan loop shared by `scan.py` and the app (finds the mouse's `/dev/videoN` itself)
- `myscan/live.py`, `myscan/webview.py` — the live preview and its browser viewer (standard library only)
+129 -26
View File
@@ -19,8 +19,8 @@ import traceback
import cv2
import numpy as np
from PySide6.QtCore import QObject, QPointF, QRectF, Qt, QThread, QTimer, Signal
from PySide6.QtGui import (QAction, QBrush, QClipboard, QColor, QGuiApplication, QImage, QKeySequence, QPainter,
QPainterPath, QPen, QPixmap, QPolygonF)
from PySide6.QtGui import (QAction, QBrush, QClipboard, QColor, QFont, QGuiApplication, QImage, QKeySequence,
QPainter, QPainterPath, QPen, QPixmap, QPolygonF)
from PySide6.QtWidgets import (QApplication, QFileDialog, QGraphicsPathItem, QGraphicsPixmapItem,
QGraphicsPolygonItem, QGraphicsRectItem, QGraphicsScene, QGraphicsView, QLabel, QMainWindow, QMenu,
QMessageBox, QSizePolicy, QSplitter, QToolBar, QVBoxLayout, QWidget)
@@ -442,6 +442,102 @@ class ImageLabel(QLabel):
Qt.SmoothTransformation))
class SensorDial(QWidget):
"""Live readings while scanning (LiveSession.sensors): a dial with a big "A" turned as
the mouse is turned on the page (upright: as at the start of the scan; the page preview
is drawn in that orientation), an arrow for the direction and speed of motion, and the
other readings listed below it."""
FULL_SPEED = 50.0 # mm/s for a full-length arrow; the lasers start to miscount near this
ROWS = 10
def __init__(self):
super().__init__()
self.s = None
self.setMinimumSize(120, 160)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.setFixedHeight(200 + 17 * self.ROWS)
def set_sensors(self, s):
self.s = s
self.update()
def rows(self):
s = self.s
quality = lambda f: "#2ea043" if f >= 0.8 else "#db8c00" if f >= 0.5 else "#d62828"
fast = s["speed"] > 0.8 * self.FULL_SPEED
return [
("Heading", f"{s['heading']:+.1f}°", None),
("Speed", f"{s['speed']:.0f} mm/s", "#db8c00" if fast else None),
("Distance", f"{s['distance'] / 10:.1f} cm", None),
("Offset", "{:+.0f}, {:+.0f} mm".format(*s["offset"]), None),
("Area", f"{s['area']:.1f} cm²", None),
("Tracking", f"{s['tracking']:.0%}", quality(s["tracking"])),
("Lasers", "{} / {}".format(*s["lasers"]), None),
("Exposure", f"{s['exposure']:.2f} ms", None),
("Paper level", f"{s['level']:.0f} / 255", None),
("Frames", f"{s['fps']:.0f}/s", None),
]
def paintEvent(self, ev):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.fillRect(self.rect(), QColor(30, 32, 36))
grey, light = QColor(139, 145, 154), QColor(220, 222, 226)
s = self.s
r = min(self.width() - 16, 184) / 2
cx, cy = self.width() / 2, 8 + r
p.save()
p.translate(cx, cy)
p.setPen(QPen(QColor(70, 74, 82), 1.5))
p.setBrush(QColor(24, 26, 30))
p.drawEllipse(QPointF(0, 0), r, r)
for deg in range(0, 360, 15): # ticks; long ones every 90°, page up at the top
p.save()
p.rotate(deg)
p.setPen(QPen(light if deg == 0 else grey, 2 if deg % 90 == 0 else 1))
p.drawLine(QPointF(0, -r), QPointF(0, -r + (9 if deg % 90 == 0 else 5)))
p.restore()
if s:
p.save()
p.rotate(s["heading"])
f = QFont(self.font())
f.setPixelSize(int(r * 1.2))
f.setBold(True)
glyph = QPainterPath()
glyph.addText(0, 0, f, "A")
glyph.translate(-glyph.boundingRect().center()) # turn about the letter's own centre
colour = QColor("#2ea043" if s["tracking"] >= 0.8 else "#db8c00" if s["tracking"] >= 0.5 else "#d62828")
p.setPen(Qt.NoPen)
p.setBrush(colour)
p.drawPath(glyph)
p.restore()
vx, vy = s["velocity"]
k = min(1.0, s["speed"] / self.FULL_SPEED) * (r - 6) / max(s["speed"], 1e-9)
if s["speed"] >= 1:
tip = QPointF(vx * k, vy * k)
p.setPen(QPen(QColor(90, 170, 255), 3, Qt.SolidLine, Qt.RoundCap))
p.drawLine(QPointF(0, 0), tip)
a = np.arctan2(vy, vx)
head = [tip + QPointF(10 * np.cos(a + d), 10 * np.sin(a + d)) for d in (np.pi * 0.85, -np.pi * 0.85)]
p.setBrush(QColor(90, 170, 255))
p.drawPolygon(QPolygonF([tip, *head]))
else:
p.setPen(grey)
p.drawText(QRectF(-r, -r, 2 * r, 2 * r), Qt.AlignCenter, "scan to see\nthe mouse's angle")
p.restore()
y = 16 + 2 * r
f = QFont(self.font())
f.setPixelSize(12)
p.setFont(f)
for name, value, colour in (self.rows() if s else ()):
p.setPen(grey)
p.drawText(QRectF(8, y, self.width() - 16, 17), Qt.AlignLeft | Qt.AlignVCenter, name)
p.setPen(QColor(colour) if colour else light)
p.drawText(QRectF(8, y, self.width() - 16, 17), Qt.AlignRight | Qt.AlignVCenter, value)
y += 17
p.end()
def pane(title, widget):
w = QWidget()
lay = QVBoxLayout(w)
@@ -465,6 +561,7 @@ class MainWindow(QMainWindow):
self.image, self.path, self.lines, self.text = None, None, [], ""
self.page_title = "Page"
self.ocr_thread, self.scan_thread, self.scanning = None, None, False
self.page_id = 0 # bumped for every new page, so a slow OCR of an old one is not applied
self.bridge = Bridge()
self.bridge.frame.connect(self.on_frame)
self.bridge.page.connect(self.on_preview)
@@ -506,13 +603,9 @@ class MainWindow(QMainWindow):
act("Open…", self.open_image, QKeySequence.Open, "Open an image to read")
self.act_save = act("Save image…", self.save_image, QKeySequence.Save, "Save the page image")
tb.addSeparator()
self.act_ocr = act("Read text", self.run_ocr, "Ctrl+R", "Run OCR on the page")
self.act_auto = act("Auto", lambda: None, tip="Read the text after every scan", checkable=True)
self.act_auto.setChecked(True)
self.act_copy = act("Copy all", self.copy_text, "Ctrl+Shift+C", "Copy all text to the clipboard")
self.act_savetxt = act("Save text…", self.save_text, "Ctrl+Shift+S", "Save the text as a .txt file")
self.act_para = act("Paragraphs", self.view_paragraphs, "Ctrl+J",
"Copy and save each paragraph as one line, undoing the page's line breaks and "
"Copy each paragraph as one line, undoing the page's line breaks and "
"hyphenation (off: keep the lines as on the page)", True)
self.act_para.setChecked(True)
tb.addSeparator()
@@ -525,10 +618,14 @@ class MainWindow(QMainWindow):
act("Fit", lambda: self.view.fit(), "Ctrl+0", "Fit the page to the window")
act("Zoom +", lambda: self.view.zoom(1.25), QKeySequence.ZoomIn)
act("Zoom −", lambda: self.view.zoom(0.8), QKeySequence.ZoomOut)
for a in (self.act_save, self.act_ocr, self.act_copy, self.act_savetxt):
for a in (self.act_save, self.act_copy):
a.setEnabled(False)
self.camera = ImageLabel("camera")
self.dial = SensorDial()
self.dial.setToolTip("The mouse's rotation on the page since the scan started (the A), its direction "
"and speed (the arrow), and other live readings. Tracking is the share of recent "
"frames matched by image; the rest relied on the lasers alone (too fast, or lifted).")
self.view = PageView()
self.view.selectionChanged.connect(self.on_selection)
for key, slot in ((QKeySequence.Copy, lambda: self.view.copy()),
@@ -538,7 +635,13 @@ class MainWindow(QMainWindow):
a.triggered.connect(slot)
self.addAction(a)
self.camera_pane, _ = pane("Camera", self.camera)
camera = QWidget()
lay = QVBoxLayout(camera)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(4)
lay.addWidget(self.camera, 1)
lay.addWidget(self.dial)
self.camera_pane, _ = pane("Camera", camera)
page_pane, self.page_head = pane("Page", self.view)
main = QSplitter(Qt.Horizontal)
for w in (self.camera_pane, page_pane):
@@ -585,11 +688,14 @@ class MainWindow(QMainWindow):
self.view.angle = 0.0
self.view.set_image(QImage())
self.lines, self.text, self.image, self.path = [], "", None, None
for a in (self.act_save, self.act_ocr, self.act_copy, self.act_savetxt):
self.page_id += 1
for a in (self.act_save, self.act_copy):
a.setEnabled(False)
self.page_head.setText("Page (scanning)")
elif state in ("idle", "done"):
self.scanning = False
if "sensors" in s and (s["sensors"] or state == "scanning"):
self.dial.set_sensors(s["sensors"])
self.act_scan.setText("Stop scan" if state == "scanning" else "Start scan")
self.act_scan.setEnabled(state in ("idle", "scanning", "done"))
parts = [state]
@@ -608,11 +714,11 @@ class MainWindow(QMainWindow):
def on_result(self, path):
self.scanning = False
self.load(path, auto_ocr=self.act_auto.isChecked())
self.load(path)
# ---- images ----
def load(self, path, auto_ocr=True):
def load(self, path):
try:
img = imread(path)
except OSError:
@@ -622,11 +728,11 @@ class MainWindow(QMainWindow):
QMessageBox.warning(self, "myScan", f"cannot read {path}")
return
self.set_page(img, path)
if auto_ocr:
self.run_ocr()
def set_page(self, img, path=None):
self.image, self.path, self.lines, self.text = img, path, [], ""
self.page_id += 1
self.view.set_lines([])
self.view.angle = 0.0
self.view.set_image(to_qimage(img))
@@ -635,7 +741,6 @@ class MainWindow(QMainWindow):
self.page_head.setText(self.page_title)
self.setWindowTitle(f"myScan — {name}")
self.act_save.setEnabled(True)
self.act_ocr.setEnabled(True)
def open_image(self):
path, _ = QFileDialog.getOpenFileName(self, "Open image", self.default_dir(), IMAGE_FILTER)
@@ -679,16 +784,22 @@ class MainWindow(QMainWindow):
def run_ocr(self):
if self.image is None or (self.ocr_thread and self.ocr_thread.isRunning()):
return
self.act_ocr.setEnabled(False)
self.page_head.setText(f"{self.page_title} · reading…")
self.set_message("reading text…")
self.ocr_thread = OcrThread(self.ocr, self.image)
self.ocr_thread.page_id = self.page_id
self.ocr_thread.done.connect(self.on_ocr)
self.ocr_thread.failed.connect(self.on_ocr_failed)
self.ocr_thread.finished.connect(self.on_ocr_finished)
self.ocr_thread.start()
def on_ocr_finished(self):
if self.sender().page_id != self.page_id: # a new page came in while reading the old one
self.run_ocr()
def on_ocr(self, lines, flipped, secs):
self.act_ocr.setEnabled(self.image is not None)
if self.sender().page_id != self.page_id:
return
if flipped: # the boxes are on the page turned the right way up
self.image = np.ascontiguousarray(self.image[::-1, ::-1])
self.view.set_image(to_qimage(self.image))
@@ -704,7 +815,6 @@ class MainWindow(QMainWindow):
notes += ["drag over the text to select it"] if lines else []
self.set_message(" · ".join([f"read {len(lines)} lines in {secs:.1f} s", *notes]))
self.act_copy.setEnabled(bool(self.text))
self.act_savetxt.setEnabled(bool(self.text))
def set_level(self, on):
self.view.set_level(on)
@@ -713,7 +823,8 @@ class MainWindow(QMainWindow):
self.set_message(f"showing the page {what}")
def on_ocr_failed(self, msg):
self.act_ocr.setEnabled(self.image is not None)
if self.sender().page_id != self.page_id:
return
self.page_head.setText(self.page_title)
self.set_message(f"OCR failed: {msg}")
@@ -736,14 +847,6 @@ class MainWindow(QMainWindow):
QGuiApplication.clipboard().setText(self.view.text_range())
self.set_message("text copied to the clipboard")
def save_text(self):
base = os.path.splitext(self.path)[0] if self.path else os.path.join(self.default_dir(), "page")
path, _ = QFileDialog.getSaveFileName(self, "Save text", base + ".txt", "Text (*.txt)")
if path:
with open(path, "w", encoding="utf-8") as f:
f.write(self.view.text_range() + "\n")
self.set_message(f"saved {path}")
def closeEvent(self, ev):
if self.scan_thread and self.scan_thread.isRunning():
self.set_message("finishing the scan and restoring mouse mode…")
+76 -4
View File
@@ -4,8 +4,10 @@ LivePreview keeps a downscaled copy of the page: each frame is pasted at its cur
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).
the preview and a status dict to a viewer (see webview.py), including live sensor readings
under "sensors" (see LiveSession.sensors).
"""
import bisect
import threading
import time
import traceback
@@ -13,9 +15,14 @@ import traceback
import numpy as np
import cv2
from .session import bright_level
from .stitching.canvas import Canvas, bbox, normalize, page_orient
from .stitching.filters import feather
from .stitching.geometry import corners, pose_matrix
from .stitching.pagegraph import SIGMA_ECC
MM_PER_PX = 0.0613 # frame pixel on the paper (the EEPROM's pixelSize); about 414 dpi
E6_PER_MS = 49.23 # exposure register units per millisecond
class LivePreview:
@@ -30,6 +37,7 @@ class LivePreview:
self.cur = None # pose of the last pasted frame
self.seen = None # solution the canvas was drawn from
self.version = 0
self.covered = 0 # preview pixels covered by the last render
def add(self, corrected):
"""Called after PageGraph.add with the corrected frame it returned. Cheap: pastes
@@ -78,6 +86,7 @@ class LivePreview:
return None
acc, ws, off, cur = self.c.acc.copy(), self.c.ws.copy(), self.c.off.copy(), self.cur
out, covered = normalize(acc, ws, background=background)
self.covered = int(np.count_nonzero(covered))
if out.ndim == 2:
out = cv2.cvtColor(out, cv2.COLOR_GRAY2BGR)
if cur is not None:
@@ -93,10 +102,14 @@ 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):
def __init__(self, graph, viewer, every=45, fps=8, mm_per_px=MM_PER_PX):
self.g, self.viewer, self.every, self.period = graph, viewer, every, 1 / fps
self.mm = mm_per_px
self.preview = LivePreview(graph)
self.raw = None # latest captured frame (set by the capture loop)
self.raw = self.rec = None # latest captured frame and its sensor record (on_capture)
self.e6, self.times = 0, []
self.dist, self.dist_done = 0.0, 0
viewer.set_status(sensors=None)
self._solving = True
self._closed = False
self._solver = threading.Thread(target=self._solve_loop, daemon=True)
@@ -104,9 +117,63 @@ class LiveSession:
self._solver.start()
self._render.start()
def on_capture(self, t, raw, rec, e6):
"""Called by the capture loop with each frame, its sensor record and the exposure
(E6) it was taken with."""
self.times.append(t)
self.raw, self.rec, self.e6 = raw, rec, e6
def on_frame(self, corrected):
self.preview.add(corrected)
def sensors(self, window=0.3):
"""Live readings for the viewer, or None before the first registered frame:
heading: the mouse's rotation on the page since the scan started, in degrees,
clockwise as seen in the page preview;
velocity: (x, y) mm/s on the page (y down), over the last `window` seconds;
speed (mm/s), distance travelled (mm), offset (x, y) from the start (mm),
area: page covered so far (cm²);
tracking: share of the last 30 frame steps registered by image (the rest
only had the lasers, which happens when moving too fast or lifted);
lasers: the two laser sensors' surface quality (record bytes 14, 15);
exposure (ms), level: paper brightness (0-255), fps."""
g, times, rec = self.g, self.times, self.rec
i = len(g.odo) # newest frame with an odometry edge
if i < 1 or i >= len(times) or rec is None:
return None
c = np.array([g.w / 2, g.h / 2, 1.0])
def centre(k):
# Frame centre in page orientation (page_orient: transpose, then turn 180°),
# up to an offset: canvas (x, y) -> page (-y, -x).
x, y = (g.pose(k) @ c)[:2]
return np.array([-y, -x])
M = g.pose(i)
# The frame's x axis, (cos θ, sin θ) on the canvas, is (-sin θ, -cos θ) on the page:
# it points up at θ = 0 and turns by -θ.
heading = -np.degrees(np.arctan2(M[1, 0], M[0, 0]))
j = bisect.bisect_left(times, times[i] - window, 0, i)
p = centre(i)
dt = times[i] - times[j]
vel = (p - centre(j)) * self.mm / dt if j < i and dt > 0 else np.zeros(2)
for _, _, T, _ in g.odo[self.dist_done:i]:
self.dist += float(np.hypot(*(T @ c - c)[:2])) * self.mm
self.dist_done = i
recent = g.odo[max(0, i - 30):i]
t = times[-1]
fps = (len(times) - 1 - bisect.bisect_left(times, t - 1.0)) / min(1.0, t) if t > 0 else 0.0
raw = self.raw
return {
"heading": float((heading + 180) % 360 - 180),
"velocity": [float(v) for v in vel], "speed": float(np.hypot(*vel)),
"distance": self.dist, "offset": [float(v) for v in (p - centre(0)) * self.mm],
"area": self.preview.covered * (self.mm / self.preview.s) ** 2 / 100,
"tracking": sum(e[3] == SIGMA_ECC for e in recent) / len(recent),
"lasers": [int(rec.b14), int(rec.b15)],
"exposure": self.e6 / E6_PER_MS, "level": bright_level(raw) if raw is not None else 0.0,
"fps": float(fps),
}
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."""
@@ -142,7 +209,12 @@ class LiveSession:
img = self.preview.render()
if img is not None:
self.viewer.publish("page", img)
self.viewer.set_status(loops=len(self.g.loops))
try:
sensors = self.sensors()
except Exception: # a reading must never stop the preview
traceback.print_exc()
sensors = None
self.viewer.set_status(loops=len(self.g.loops), sensors=sensors)
time.sleep(max(0.0, self.period - (time.time() - t)))
def close(self):
+7 -5
View File
@@ -17,7 +17,7 @@ from types import SimpleNamespace
import cv2
from . import calib
from .live import LiveSession
from .live import MM_PER_PX, LiveSession
from .session import ScanSession
from .stitching import Corrector, PageGraph
from .xu import XU
@@ -60,7 +60,7 @@ def wait_press(x, viewer=None, quit=None, poll=0.05):
return True
def scan_page(x, a, frames_dir=None, corrector=None, gains=None, viewer=None):
def scan_page(x, a, frames_dir=None, corrector=None, gains=None, viewer=None, mm_per_px=MM_PER_PX):
"""Capture until the button is pressed again. Frames are registered (and saved) on a
worker thread so capture never waits; returns (PageGraph or None, LiveSession or None)."""
g, live, rows, work, saves = None, None, [], queue.Queue(), queue.Queue()
@@ -87,14 +87,14 @@ def scan_page(x, a, frames_dir=None, corrector=None, gains=None, viewer=None):
if g is None:
g = PageGraph(im.shape, corrector)
if viewer:
live = LiveSession(g, viewer)
live = LiveSession(g, viewer, mm_per_px=mm_per_px)
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
live.on_capture(t, im, rec, s.e6)
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()])
@@ -147,6 +147,7 @@ class Scanner:
self.corrector = Corrector(None if a.no_flat else calib.FlatField(cal),
None if a.no_undistort else calib.Undistort(cal))
self.gains = cal["gains_rgb"]
self.mm_per_px = cal["pixel_size_m"] * 1000
def wait_press(self, quit=None):
if self.viewer:
@@ -163,7 +164,8 @@ class Scanner:
frames_dir = os.path.join(a.outdir, name) if a.save_frames else None
if frames_dir:
os.makedirs(frames_dir)
g, live = scan_page(self.x, a, frames_dir, self.corrector, self.gains, viewer)
g, live = scan_page(self.x, a, frames_dir, self.corrector, self.gains, viewer,
self.mm_per_px)
if live:
live.stop_solving()
live.close()