The wheel (and Fit, Ctrl+0) remain. The stitched page has white in areas that were never scanned; when the view rotates it to level the text, those triangles stuck out white against the dark window. The page is now shown with its exact-white pixels transparent, so the window colour shows through there instead. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
891 lines
35 KiB
Python
891 lines
35 KiB
Python
"""Desktop app (Qt, via PySide6): live view while scanning, and OCR of the page.
|
||
|
||
Left, the camera and the page as it is being stitched (the same preview as the browser
|
||
viewer); after a scan the finished page is loaded and read with RapidOCR (myscan/ocr.py).
|
||
Recognised lines are outlined on the page, coloured by confidence, and their text can be
|
||
selected right on the page (drag, double-click a word, triple-click a line) and copied;
|
||
all of it can be copied or saved. Images can also be opened or dropped onto the window,
|
||
so OCR works on any platform; scanning needs Linux and the mouse.
|
||
|
||
Run: python tools/app.py [image]
|
||
"""
|
||
import os
|
||
import signal
|
||
import sys
|
||
import threading
|
||
import time
|
||
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, 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)
|
||
|
||
from .ocr import Ocr, layout, paragraph_joins, reflow, text_direction
|
||
|
||
IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.tif *.tiff *.bmp *.webp)"
|
||
|
||
|
||
def to_qimage(img, page=False):
|
||
"""BGR or grayscale uint8 array -> QImage (a copy). With `page`, exact-white pixels
|
||
(the uncovered corners of a stitched page) become transparent, so when the view
|
||
rotates the page to level it those corners show the window background through."""
|
||
img = np.ascontiguousarray(img)
|
||
h, w = img.shape[:2]
|
||
if not page:
|
||
fmt = QImage.Format_Grayscale8 if img.ndim == 2 else QImage.Format_BGR888
|
||
return QImage(img.data, w, h, img.strides[0], fmt).copy()
|
||
bgra = cv2.cvtColor(img, cv2.COLOR_GRAY2BGRA if img.ndim == 2 else cv2.COLOR_BGR2BGRA)
|
||
uncovered = (img == 255).all(axis=-1) if img.ndim == 3 else (img == 255)
|
||
bgra[uncovered, 3] = 0
|
||
bgra = np.ascontiguousarray(bgra)
|
||
return QImage(bgra.data, w, h, bgra.strides[0], QImage.Format_ARGB32).copy()
|
||
|
||
|
||
def imread(path):
|
||
"""cv2.imread that also takes non-ASCII paths on Windows."""
|
||
return cv2.imdecode(np.fromfile(path, np.uint8), cv2.IMREAD_COLOR)
|
||
|
||
|
||
def imwrite(path, img):
|
||
ok, buf = cv2.imencode(os.path.splitext(path)[1] or ".png", img)
|
||
if not ok:
|
||
raise OSError(f"cannot encode {path}")
|
||
buf.tofile(path)
|
||
|
||
|
||
def level_image(img, angle):
|
||
"""The image rotated by -angle degrees (clockwise in image coordinates, y down), as
|
||
PageView shows it levelled, on a white canvas big enough to hold all of it."""
|
||
h, w = img.shape[:2]
|
||
m = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0)
|
||
c, s = abs(m[0, 0]), abs(m[0, 1])
|
||
W, H = int(np.ceil(w * c + h * s)), int(np.ceil(w * s + h * c))
|
||
m[:, 2] += ((W - w) / 2, (H - h) / 2)
|
||
return cv2.warpAffine(img, m, (W, H), flags=cv2.INTER_CUBIC, borderValue=(255, 255, 255))
|
||
|
||
|
||
def log_error(msg):
|
||
"""Print an error, with the traceback of the exception being handled if any."""
|
||
print(f"myScan: {msg}", file=sys.stderr, flush=True)
|
||
if sys.exc_info()[0] is not None:
|
||
traceback.print_exc()
|
||
|
||
|
||
def scanner_device():
|
||
"""The myScan's capture device, or None (always None off Linux)."""
|
||
if not sys.platform.startswith("linux"):
|
||
return None
|
||
from .scanner import find_video
|
||
return find_video()
|
||
|
||
|
||
class Bridge(QObject):
|
||
"""The viewer interface the scan loop talks to (see myscan/scanner.py), turned into
|
||
Qt signals; it is called from the scan threads, the slots run on the GUI thread."""
|
||
frame = Signal(QImage)
|
||
page = Signal(QImage)
|
||
status = Signal(dict)
|
||
result = Signal(str)
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.state = {"state": "idle"}
|
||
self.start_event, self.stop_event = threading.Event(), threading.Event()
|
||
|
||
def publish(self, name, bgr):
|
||
(self.frame if name == "frame" else self.page).emit(to_qimage(bgr, page=(name == "page")))
|
||
|
||
def set_status(self, **kw):
|
||
self.state = {**self.state, **kw}
|
||
self.status.emit(dict(self.state))
|
||
|
||
def set_result(self, path):
|
||
self.result.emit(path)
|
||
|
||
def take_start(self):
|
||
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
|
||
|
||
|
||
class ScanThread(QThread):
|
||
"""Opens the mouse, then scans a page per Start (or scan-button press) until quit."""
|
||
failed = Signal(str)
|
||
|
||
def __init__(self, bridge, opts):
|
||
super().__init__()
|
||
self.bridge, self.opts, self.quit = bridge, opts, threading.Event()
|
||
|
||
def run(self):
|
||
from .session import quiet_libjpeg
|
||
quiet_libjpeg()
|
||
from .scanner import Scanner
|
||
try:
|
||
self.bridge.set_status(state="starting", message="reading calibration")
|
||
scanner = Scanner(self.opts, self.bridge)
|
||
except Exception as e:
|
||
log_error(f"cannot open the scanner: {e}")
|
||
self.failed.emit(f"cannot open the scanner: {e}")
|
||
return
|
||
try:
|
||
while scanner.wait_press(self.quit):
|
||
scanner.scan()
|
||
except Exception as e:
|
||
log_error(f"scan failed: {e}")
|
||
self.failed.emit(f"scan failed: {e}")
|
||
finally:
|
||
scanner.close()
|
||
|
||
def stop(self):
|
||
self.quit.set()
|
||
self.bridge.stop_event.set()
|
||
self.wait()
|
||
|
||
|
||
class OcrThread(QThread):
|
||
done = Signal(object, bool, float) # lines, page upside down, seconds
|
||
failed = Signal(str)
|
||
|
||
def __init__(self, ocr, img):
|
||
super().__init__()
|
||
self.ocr, self.img = ocr, img
|
||
|
||
def run(self):
|
||
t = time.time()
|
||
try:
|
||
self.done.emit(*self.ocr.read(self.img), time.time() - t)
|
||
except Exception as e:
|
||
log_error(f"OCR failed: {e}")
|
||
self.failed.emit(str(e))
|
||
|
||
|
||
class LineItem(QGraphicsPolygonItem):
|
||
"""Outline of a recognised line, coloured by confidence (mouse events go to the view)."""
|
||
|
||
def __init__(self, box, score):
|
||
super().__init__(QPolygonF([QPointF(*p) for p in box]))
|
||
c = QColor(46, 160, 67) if score >= 0.9 else QColor(219, 140, 0) if score >= 0.7 else QColor(214, 40, 40)
|
||
pen = QPen(c, 1.5)
|
||
pen.setCosmetic(True)
|
||
self.setPen(pen)
|
||
self.setBrush(QBrush(QColor(c.red(), c.green(), c.blue(), 25)))
|
||
self.setAcceptedMouseButtons(Qt.NoButton)
|
||
self.setToolTip(f"{score:.0%}")
|
||
|
||
|
||
class PageView(QGraphicsView):
|
||
"""Zoomable page whose recognised text can be selected like in a PDF viewer: drag to
|
||
select in reading order, double-click for a word, triple-click for a line, shift-click
|
||
to extend. Copied text either keeps the page's line breaks or, with `paragraphs` set,
|
||
has each paragraph on one line (see ocr.paragraph_joins). Middle-drag (or Space +
|
||
drag, or any drag before there is text) pans. Keeps the page fitted to the window
|
||
until the user zooms.
|
||
|
||
The page, its line boxes and the selection are children of one item, which is
|
||
rotated to level the text (set_angle, set_level), so selection works the same either
|
||
way: everything is in image coordinates under that item."""
|
||
selectionChanged = Signal(str)
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setScene(QGraphicsScene(self))
|
||
self.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform)
|
||
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
|
||
self.setBackgroundBrush(QColor(30, 32, 36))
|
||
self.setMouseTracking(True)
|
||
self.page = QGraphicsRectItem()
|
||
self.page.setPen(QPen(Qt.NoPen))
|
||
self.scene().addItem(self.page)
|
||
self.pixmap = QGraphicsPixmapItem(self.page)
|
||
self.pixmap.setTransformationMode(Qt.SmoothTransformation)
|
||
self.sel_item = QGraphicsPathItem(self.page)
|
||
self.sel_item.setPen(QPen(Qt.NoPen))
|
||
self.sel_item.setBrush(QColor(30, 110, 230, 90))
|
||
self.sel_item.setZValue(2)
|
||
self.angle, self.level = 0.0, True
|
||
self.items_, self.auto_fit, self.boxes_visible = [], True, True
|
||
self.lines, self.spans, self.text, self.joins, self.paragraphs = [], [], "", [], True
|
||
self.sel, self.anchor, self.unit = (0, 0), None, None # unit: drag extends by "word"/"line"
|
||
self.pan_from, self.space, self.last_double = None, False, None
|
||
|
||
def set_image(self, qimg, keep_view=False):
|
||
self.pixmap.setPixmap(QPixmap.fromImage(qimg))
|
||
if not keep_view:
|
||
self.auto_fit = True
|
||
self.place()
|
||
|
||
def set_angle(self, angle):
|
||
"""The text's slant in degrees (clockwise, as in image coordinates)."""
|
||
self.angle = angle
|
||
self.place()
|
||
|
||
def set_level(self, on):
|
||
self.level = on
|
||
self.place()
|
||
|
||
def place(self):
|
||
"""Rotate the page to level it (or not), and fit the scene around it."""
|
||
r = self.pixmap.boundingRect()
|
||
self.page.setTransformOriginPoint(r.center())
|
||
self.page.setRotation(-self.angle if self.level else 0.0)
|
||
self.scene().setSceneRect(self.page_rect())
|
||
if self.auto_fit:
|
||
self.fit()
|
||
|
||
def page_rect(self):
|
||
return self.page.mapRectToScene(self.pixmap.boundingRect())
|
||
|
||
def set_lines(self, lines, text="", spans=()):
|
||
for it in self.items_:
|
||
self.scene().removeItem(it)
|
||
self.items_ = [LineItem(ln.box, ln.score) for ln in lines]
|
||
for it in self.items_:
|
||
it.setVisible(self.boxes_visible)
|
||
it.setParentItem(self.page)
|
||
self.lines, self.text, self.spans = lines, text, list(spans)
|
||
self.joins = paragraph_joins(lines, text, self.spans) if lines else []
|
||
self.sel, self.anchor = None, None
|
||
self.select(0, 0)
|
||
|
||
def show_boxes(self, on):
|
||
self.boxes_visible = on
|
||
for it in self.items_:
|
||
it.setVisible(on)
|
||
|
||
# ---- selection ----
|
||
|
||
def hit(self, scene_pos):
|
||
"""(line index, distance in pixels, text position) of the line nearest a scene point."""
|
||
q = self.page.mapFromScene(scene_pos)
|
||
p = (q.x(), q.y())
|
||
return min(((i, *ln.locate(p)) for i, ln in enumerate(self.lines)), key=lambda h: h[1])
|
||
|
||
def position(self, scene_pos, unit=None):
|
||
"""Range in the text at a scene point: empty, or the word/line around it."""
|
||
i, _, k = self.hit(scene_pos)
|
||
s = self.spans[i][0]
|
||
if unit == "word":
|
||
a, b = self.lines[i].word(k)
|
||
elif unit == "line":
|
||
a, b = 0, len(self.lines[i].text)
|
||
else:
|
||
a = b = k
|
||
return s + a, s + b
|
||
|
||
def select(self, a, b):
|
||
a, b = min(a, b), max(a, b)
|
||
if (a, b) == self.sel:
|
||
return
|
||
self.sel = (a, b)
|
||
path = QPainterPath()
|
||
for ln, (s, e) in zip(self.lines, self.spans):
|
||
lo, hi = max(a, s), min(b, e)
|
||
if lo < hi:
|
||
path.addPolygon(QPolygonF([QPointF(*p) for p in ln.quad(lo - s, hi - s)]))
|
||
path.closeSubpath()
|
||
self.sel_item.setPath(path)
|
||
self.selectionChanged.emit(self.selected_text())
|
||
|
||
def select_all(self):
|
||
self.select(0, len(self.text))
|
||
|
||
def text_range(self, a=0, b=None, paragraphs=None):
|
||
"""The text from a to b (positions in self.text), reflowed into paragraphs if
|
||
`paragraphs` (default: self.paragraphs)."""
|
||
if self.paragraphs if paragraphs is None else paragraphs:
|
||
return reflow(self.text, self.joins, a, b)
|
||
return self.text[a:b]
|
||
|
||
def selected_text(self, paragraphs=None):
|
||
return self.text_range(*self.sel, paragraphs=paragraphs)
|
||
|
||
def set_paragraphs(self, on):
|
||
self.paragraphs = on
|
||
self.selectionChanged.emit(self.selected_text())
|
||
|
||
def drag_to(self, scene_pos):
|
||
a, b = self.position(scene_pos, self.unit)
|
||
a0, b0 = self.anchor
|
||
self.select(min(a, a0), max(b, b0))
|
||
|
||
# ---- mouse ----
|
||
|
||
def mousePressEvent(self, ev):
|
||
selecting = ev.button() == Qt.LeftButton and self.lines and not self.space
|
||
if not selecting:
|
||
if ev.button() in (Qt.LeftButton, Qt.MiddleButton):
|
||
self.pan_from = ev.position()
|
||
self.viewport().setCursor(Qt.ClosedHandCursor)
|
||
return super().mousePressEvent(ev)
|
||
pos = self.mapToScene(ev.position().toPoint())
|
||
triple = (self.last_double is not None and time.monotonic() - self.last_double[0]
|
||
< QApplication.styleHints().mouseDoubleClickInterval() / 1000
|
||
and (ev.position() - self.last_double[1]).manhattanLength() < 6)
|
||
self.unit = "line" if triple else None
|
||
r = self.position(pos, self.unit)
|
||
if ev.modifiers() & Qt.ShiftModifier and self.anchor is not None:
|
||
self.drag_to(pos)
|
||
else:
|
||
self.anchor = r
|
||
self.select(*r)
|
||
|
||
def mouseDoubleClickEvent(self, ev):
|
||
if ev.button() != Qt.LeftButton or not self.lines or self.space:
|
||
return super().mouseDoubleClickEvent(ev)
|
||
self.last_double = (time.monotonic(), ev.position())
|
||
self.unit = "word"
|
||
self.anchor = self.position(self.mapToScene(ev.position().toPoint()), "word")
|
||
self.select(*self.anchor)
|
||
|
||
def mouseMoveEvent(self, ev):
|
||
if self.pan_from is not None:
|
||
d = ev.position() - self.pan_from
|
||
self.pan_from = ev.position()
|
||
self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - round(d.x()))
|
||
self.verticalScrollBar().setValue(self.verticalScrollBar().value() - round(d.y()))
|
||
return
|
||
pos = self.mapToScene(ev.position().toPoint())
|
||
if ev.buttons() & Qt.LeftButton and self.anchor is not None and self.lines:
|
||
self.drag_to(pos)
|
||
self.ensureVisible(QRectF(pos, pos), 0, 0) # scroll when dragging past the edge
|
||
elif self.lines and not self.space:
|
||
i, dist, _ = self.hit(pos)
|
||
near = dist < 0.5 * self.lines[i].height
|
||
self.viewport().setCursor(Qt.IBeamCursor if near else Qt.ArrowCursor)
|
||
else:
|
||
self.viewport().setCursor(Qt.OpenHandCursor if self.space else Qt.ArrowCursor)
|
||
super().mouseMoveEvent(ev)
|
||
|
||
def mouseReleaseEvent(self, ev):
|
||
if self.pan_from is not None:
|
||
self.pan_from = None
|
||
self.viewport().setCursor(Qt.OpenHandCursor if self.space else Qt.ArrowCursor)
|
||
super().mouseReleaseEvent(ev)
|
||
|
||
def keyPressEvent(self, ev):
|
||
if ev.key() == Qt.Key_Space and not ev.isAutoRepeat():
|
||
self.space = True
|
||
self.viewport().setCursor(Qt.OpenHandCursor)
|
||
return
|
||
super().keyPressEvent(ev)
|
||
|
||
def keyReleaseEvent(self, ev):
|
||
if ev.key() == Qt.Key_Space and not ev.isAutoRepeat():
|
||
self.space = False
|
||
self.viewport().setCursor(Qt.ArrowCursor)
|
||
return
|
||
super().keyReleaseEvent(ev)
|
||
|
||
def contextMenuEvent(self, ev):
|
||
if not self.lines:
|
||
return
|
||
m = QMenu(self)
|
||
has_sel = self.sel[0] != self.sel[1]
|
||
other = "Copy with the page's line breaks" if self.paragraphs else "Copy as paragraphs"
|
||
m.addAction("Copy", self.copy).setEnabled(has_sel)
|
||
m.addAction(other, lambda: self.copy(not self.paragraphs)).setEnabled(has_sel)
|
||
m.addAction("Copy all text", lambda: QGuiApplication.clipboard().setText(self.text_range()))
|
||
m.addSeparator()
|
||
m.addAction("Select all", self.select_all)
|
||
m.exec(ev.globalPos())
|
||
|
||
def copy(self, paragraphs=None):
|
||
if text := self.selected_text(paragraphs):
|
||
QGuiApplication.clipboard().setText(text)
|
||
|
||
# ---- zoom ----
|
||
|
||
def fit(self):
|
||
self.auto_fit = True
|
||
if not self.pixmap.pixmap().isNull():
|
||
self.fitInView(self.page_rect(), Qt.KeepAspectRatio)
|
||
|
||
def zoom(self, f):
|
||
self.auto_fit = False
|
||
self.scale(f, f)
|
||
|
||
def wheelEvent(self, ev):
|
||
self.zoom(1.25 ** (ev.angleDelta().y() / 120))
|
||
|
||
def resizeEvent(self, ev):
|
||
super().resizeEvent(ev)
|
||
if self.auto_fit:
|
||
self.fit()
|
||
|
||
|
||
class ImageLabel(QLabel):
|
||
"""A QLabel showing an image scaled to fit, keeping its aspect ratio."""
|
||
|
||
def __init__(self, placeholder):
|
||
super().__init__(placeholder)
|
||
self.setAlignment(Qt.AlignCenter)
|
||
self.setMinimumSize(80, 60)
|
||
self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
|
||
self.setStyleSheet("background: #1e2024; color: #8b919a;")
|
||
self.img = None
|
||
|
||
def set_image(self, qimg):
|
||
self.img = qimg
|
||
self._update()
|
||
|
||
def resizeEvent(self, ev):
|
||
super().resizeEvent(ev)
|
||
self._update()
|
||
|
||
def _update(self):
|
||
if self.img is not None:
|
||
self.setPixmap(QPixmap.fromImage(self.img).scaled(self.size(), Qt.KeepAspectRatio,
|
||
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)
|
||
lay.setContentsMargins(4, 4, 4, 4)
|
||
lay.setSpacing(3)
|
||
head = QLabel(title)
|
||
head.setStyleSheet("color: #8b919a; font-size: 11px; font-weight: 600; text-transform: uppercase;")
|
||
lay.addWidget(head)
|
||
lay.addWidget(widget, 1)
|
||
return w, head
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
def __init__(self, opts=None):
|
||
super().__init__()
|
||
self.setWindowTitle("myScan")
|
||
self.resize(1400, 850)
|
||
self.setAcceptDrops(True)
|
||
self.opts = opts
|
||
self.ocr = Ocr()
|
||
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)
|
||
self.bridge.status.connect(self.on_status)
|
||
self.bridge.result.connect(self.on_result)
|
||
self._build()
|
||
self.device = scanner_device()
|
||
if self.device is None:
|
||
self.act_scan.setEnabled(False)
|
||
self.act_scan.setToolTip("No myScan found (scanning needs Linux and the mouse plugged in)")
|
||
self.set_message("No scanner found: open or drop an image to read it")
|
||
else:
|
||
self.start_scanner()
|
||
|
||
# ---- layout ----
|
||
|
||
def _build(self):
|
||
tb = QToolBar("Main")
|
||
tb.setMovable(False)
|
||
tb.setToolButtonStyle(Qt.ToolButtonTextOnly)
|
||
self.addToolBar(tb)
|
||
|
||
def act(text, slot, key=None, tip=None, checkable=False):
|
||
a = QAction(text, self)
|
||
a.triggered.connect(slot)
|
||
if key:
|
||
a.setShortcut(QKeySequence(key))
|
||
if tip:
|
||
a.setToolTip(f"{tip} ({QKeySequence(key).toString(QKeySequence.NativeText)})" if key else tip)
|
||
a.setCheckable(checkable)
|
||
tb.addAction(a)
|
||
return a
|
||
|
||
self.act_camera = act("Camera", lambda on: self.camera_pane.setVisible(on), "Ctrl+K",
|
||
"Show the camera and the live sensor readings", True)
|
||
tb.addSeparator()
|
||
self.act_scan = act("Start scan", self.toggle_scan, "F5", "Start or stop a scan (or press the mouse's scan button)")
|
||
tb.addSeparator()
|
||
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_copy = act("Copy all", self.copy_text, "Ctrl+Shift+C", "Copy all text to the clipboard")
|
||
self.act_para = act("Paragraphs", self.view_paragraphs, "Ctrl+J",
|
||
"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()
|
||
self.act_level = act("Level", self.set_level, "Ctrl+L",
|
||
"Show the page rotated so its lines are horizontal (off: as stitched); "
|
||
"Save image saves what is shown", True)
|
||
self.act_level.setChecked(True)
|
||
self.act_boxes = act("Boxes", lambda on: self.view.show_boxes(on), "Ctrl+B", "Show recognised lines on the page", True)
|
||
self.act_boxes.setChecked(True)
|
||
act("Fit", lambda: self.view.fit(), "Ctrl+0", "Fit the page to the window")
|
||
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()),
|
||
(QKeySequence.SelectAll, self.view.select_all)):
|
||
a = QAction(self)
|
||
a.setShortcut(key)
|
||
a.triggered.connect(slot)
|
||
self.addAction(a)
|
||
|
||
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):
|
||
main.addWidget(w)
|
||
main.setSizes([220, 1180])
|
||
self.camera_pane.hide()
|
||
self.setCentralWidget(main)
|
||
self.status_label = QLabel()
|
||
self.statusBar().addWidget(self.status_label, 1)
|
||
|
||
def set_message(self, msg):
|
||
self.status_label.setText(msg)
|
||
|
||
# ---- scanning ----
|
||
|
||
def start_scanner(self):
|
||
self.scan_thread = ScanThread(self.bridge, self.opts)
|
||
self.scan_thread.failed.connect(self.on_scan_failed)
|
||
self.scan_thread.start()
|
||
|
||
def toggle_scan(self):
|
||
if not self.act_scan.isEnabled():
|
||
return
|
||
(self.bridge.stop_event if self.scanning else self.bridge.start_event).set()
|
||
|
||
def on_scan_failed(self, msg):
|
||
self.act_scan.setEnabled(False)
|
||
self.scanning = False
|
||
self.set_message(msg)
|
||
QMessageBox.warning(self, "myScan", msg)
|
||
|
||
def on_frame(self, qimg):
|
||
self.camera.set_image(qimg)
|
||
|
||
def on_preview(self, qimg):
|
||
if self.scanning:
|
||
self.view.set_image(qimg, keep_view=True)
|
||
|
||
def on_status(self, s):
|
||
state = s.get("state", "idle")
|
||
if state == "scanning" and not self.scanning: # a new scan: clear the old page
|
||
self.scanning = True
|
||
self.view.set_lines([])
|
||
self.view.angle = 0.0
|
||
self.view.set_image(QImage())
|
||
self.lines, self.text, self.image, self.path = [], "", None, None
|
||
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]
|
||
if state in ("scanning", "assembling"):
|
||
if "t" in s:
|
||
parts.append(f"{s['t']:.1f} s")
|
||
if "frames" in s:
|
||
parts.append(f"{s['frames']} frames")
|
||
if s.get("backlog"):
|
||
parts.append(f"backlog {s['backlog']}")
|
||
if "loops" in s:
|
||
parts.append(f"{s['loops']} loop matches")
|
||
if s.get("message"):
|
||
parts.append(s["message"])
|
||
self.set_message(" · ".join(parts))
|
||
|
||
def on_result(self, path):
|
||
self.scanning = False
|
||
self.load(path)
|
||
|
||
# ---- images ----
|
||
|
||
def load(self, path):
|
||
try:
|
||
img = imread(path)
|
||
except OSError:
|
||
img = None
|
||
if img is None:
|
||
log_error(f"cannot read {path}")
|
||
QMessageBox.warning(self, "myScan", f"cannot read {path}")
|
||
return
|
||
self.set_page(img, path)
|
||
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, page=True))
|
||
name = os.path.basename(path) if path else "page"
|
||
self.page_title = f"Page — {name} ({img.shape[1]}×{img.shape[0]})"
|
||
self.page_head.setText(self.page_title)
|
||
self.setWindowTitle(f"myScan — {name}")
|
||
self.act_save.setEnabled(True)
|
||
|
||
def open_image(self):
|
||
path, _ = QFileDialog.getOpenFileName(self, "Open image", self.default_dir(), IMAGE_FILTER)
|
||
if path:
|
||
self.load(path)
|
||
|
||
def save_image(self):
|
||
if self.image is None:
|
||
return
|
||
base = os.path.splitext(self.path)[0] if self.path else os.path.join(self.default_dir(), "page")
|
||
path, _ = QFileDialog.getSaveFileName(self, "Save image", base + ".png", IMAGE_FILTER)
|
||
if path:
|
||
try:
|
||
img = self.image
|
||
if self.view.level and abs(self.view.angle) > 0.05:
|
||
img = level_image(img, self.view.angle)
|
||
imwrite(path, img)
|
||
except OSError as e:
|
||
log_error(f"cannot save {path}: {e}")
|
||
QMessageBox.warning(self, "myScan", f"cannot save {path}: {e}")
|
||
return
|
||
self.set_message(f"saved {path}")
|
||
|
||
def default_dir(self):
|
||
if self.path:
|
||
return os.path.dirname(self.path)
|
||
out = getattr(self.opts, "outdir", None) or "scans"
|
||
return out if os.path.isdir(out) else os.getcwd()
|
||
|
||
def dragEnterEvent(self, ev):
|
||
if ev.mimeData().hasUrls():
|
||
ev.acceptProposedAction()
|
||
|
||
def dropEvent(self, ev):
|
||
urls = [u.toLocalFile() for u in ev.mimeData().urls() if u.isLocalFile()]
|
||
if urls:
|
||
self.load(urls[0])
|
||
|
||
# ---- OCR ----
|
||
|
||
def run_ocr(self):
|
||
if self.image is None or (self.ocr_thread and self.ocr_thread.isRunning()):
|
||
return
|
||
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):
|
||
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, page=True))
|
||
self.lines = lines
|
||
self.text, spans = layout(lines)
|
||
self.view.set_lines(lines, self.text, spans)
|
||
d = text_direction(lines)
|
||
self.view.set_angle(float(np.degrees(np.arctan2(d[1], d[0]))) if lines else 0.0)
|
||
conf = f", mean confidence {np.mean([ln.score for ln in lines]):.0%}" if lines else ""
|
||
self.page_head.setText(f"{self.page_title} · {len(lines)} lines{conf}")
|
||
notes = ["page was upside down, turned"] if flipped else []
|
||
notes += [f"text slanted {self.view.angle:+.1f}°"] if abs(self.view.angle) >= 0.1 else []
|
||
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))
|
||
|
||
def set_level(self, on):
|
||
self.view.set_level(on)
|
||
if self.lines:
|
||
what = f"levelled ({-self.view.angle:+.1f}°)" if on else "as stitched"
|
||
self.set_message(f"showing the page {what}")
|
||
|
||
def on_ocr_failed(self, msg):
|
||
if self.sender().page_id != self.page_id:
|
||
return
|
||
self.page_head.setText(self.page_title)
|
||
self.set_message(f"OCR failed: {msg}")
|
||
|
||
def on_selection(self, text):
|
||
if not text:
|
||
return
|
||
cb = QGuiApplication.clipboard()
|
||
if cb.supportsSelection(): # X11/Wayland: middle-click pastes it elsewhere
|
||
cb.setText(text, QClipboard.Selection)
|
||
preview = " ⏎ ".join(t for t in text.replace("\t", " ").split("\n") if t)
|
||
preview = preview if len(preview) <= 90 else preview[:87] + "…"
|
||
self.set_message(f"{len(text)} characters selected (Ctrl+C to copy): {preview}")
|
||
|
||
def view_paragraphs(self, on):
|
||
self.view.set_paragraphs(on)
|
||
if not self.view.selected_text():
|
||
self.set_message("copying paragraphs as single lines" if on else "copying lines as on the page")
|
||
|
||
def copy_text(self):
|
||
QGuiApplication.clipboard().setText(self.view.text_range())
|
||
self.set_message("text copied to the clipboard")
|
||
|
||
def closeEvent(self, ev):
|
||
if self.scan_thread and self.scan_thread.isRunning():
|
||
self.set_message("finishing the scan and restoring mouse mode…")
|
||
QApplication.processEvents()
|
||
self.scan_thread.stop() # always leave the mouse in mouse mode
|
||
if self.ocr_thread:
|
||
self.ocr_thread.wait()
|
||
super().closeEvent(ev)
|
||
|
||
|
||
def main(argv=None, opts=None):
|
||
import argparse
|
||
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||
ap.add_argument("image", nargs="?", help="image to open and read")
|
||
ap.add_argument("-o", "--outdir", default="scans", help="where scans are saved")
|
||
ap.add_argument("--video", help="capture device (default: find the myScan)")
|
||
ap.add_argument("--gray", action="store_true", help="scan in grayscale")
|
||
a = ap.parse_args(argv)
|
||
app = QApplication(sys.argv[:1])
|
||
app.setApplicationName("myScan")
|
||
w = MainWindow(a)
|
||
w.show()
|
||
# Ctrl+C in the terminal closes the window (which puts the mouse back in mouse mode).
|
||
# Python only runs signal handlers between bytecodes, so wake it up now and then.
|
||
def interrupt(*_):
|
||
while (dialog := QApplication.activeModalWidget()) is not None:
|
||
dialog.reject() # a dialog's event loop would keep the app alive
|
||
dialog.hide()
|
||
w.close()
|
||
signal.signal(signal.SIGINT, interrupt)
|
||
tick = QTimer(timeout=lambda: None, interval=200)
|
||
tick.start()
|
||
if a.image:
|
||
QTimer.singleShot(0, lambda: w.load(a.image))
|
||
return app.exec()
|