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:
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user