Select and copy text directly on the page

The text pane is gone: recognised text is selected on the page image like
in a PDF viewer (drag in reading order, double-click a word, triple-click a
line, shift-click to extend, Ctrl+A), copied with Ctrl+C or the context
menu, and previewed in the status bar; on X11/Wayland it also goes to the
primary selection. Middle-drag or Space+drag pans.

OCR asks RapidOCR for per-character boxes and keeps each line's character
boundaries (Line.edges), with Line.locate/quad/word for hit testing and
drawing; lines whose boxes don't match the text fall back to even spacing.

Errors are logged to the terminal with their traceback, and Ctrl+C in the
terminal closes the app (restoring mouse mode), dismissing any open dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erik
2026-10-09 12:25:11 +00:00
co-authored by Claude Opus 5.5
parent 909baec1e7
commit 67333528ca
3 changed files with 315 additions and 108 deletions
+3 -2
View File
@@ -22,8 +22,9 @@ Linux driver tools for the Hama myScan (00052343) scanner mouse, a Dacuda design
- `tools/app.py` — desktop app (Qt/PySide6; Linux, Windows, macOS): live camera and page view while scanning - `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), then OCR of the finished page with RapidOCR (`myscan/ocr.py`;
multilingual PP-OCRv6 models bundled in the wheel, works offline). Recognised lines are outlined on the page by multilingual PP-OCRv6 models bundled in the wheel, works offline). Recognised lines are outlined on the page by
confidence; click one to select its text, and the cursor in the text highlights its line. The text is editable confidence, and the text is selected right on the page like in a PDF viewer: drag (in reading order across
and can be copied or saved as .txt. `python tools/app.py [image]` also opens and reads existing images (Open, or lines), double-click a word, triple-click a line, shift-click to extend, Ctrl+A; Ctrl+C or right-click copies.
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
drag and drop), which is all it does on machines without the mouse 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/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) - `myscan/live.py`, `myscan/webview.py` — the live preview and its browser viewer (standard library only)
+232 -101
View File
@@ -2,26 +2,28 @@
Left, the camera and the page as it is being stitched (the same preview as the browser 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). 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; clicking one selects Recognised lines are outlined on the page, coloured by confidence, and their text can be
its text on the right, and moving the cursor in the text highlights its line on the selected right on the page (drag, double-click a word, triple-click a line) and copied;
page. The text can be edited, copied and saved. Images can also be opened or dropped all of it can be copied or saved. Images can also be opened or dropped onto the window,
onto the window, so OCR works on any platform; scanning needs Linux and the mouse. so OCR works on any platform; scanning needs Linux and the mouse.
Run: python tools/app.py [image] Run: python tools/app.py [image]
""" """
import os import os
import signal
import sys import sys
import threading import threading
import time import time
import traceback
import cv2 import cv2
import numpy as np import numpy as np
from PySide6.QtCore import QObject, QPointF, QRectF, Qt, QThread, Signal from PySide6.QtCore import QObject, QPointF, QRectF, Qt, QThread, QTimer, Signal
from PySide6.QtGui import (QAction, QBrush, QColor, QGuiApplication, QImage, QKeySequence, QPainter, QPen, from PySide6.QtGui import (QAction, QBrush, QClipboard, QColor, QGuiApplication, QImage, QKeySequence, QPainter,
QPixmap, QPolygonF, QTextCursor) QPainterPath, QPen, QPixmap, QPolygonF)
from PySide6.QtWidgets import (QApplication, QFileDialog, QGraphicsPixmapItem, QGraphicsPolygonItem, from PySide6.QtWidgets import (QApplication, QFileDialog, QGraphicsPathItem, QGraphicsPixmapItem,
QGraphicsScene, QGraphicsView, QLabel, QMainWindow, QMessageBox, QPlainTextEdit, QGraphicsPolygonItem, QGraphicsScene, QGraphicsView, QLabel, QMainWindow, QMenu,
QSizePolicy, QSplitter, QToolBar, QVBoxLayout, QWidget) QMessageBox, QSizePolicy, QSplitter, QToolBar, QVBoxLayout, QWidget)
from .ocr import Ocr, layout from .ocr import Ocr, layout
@@ -48,6 +50,13 @@ def imwrite(path, img):
buf.tofile(path) buf.tofile(path)
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(): def scanner_device():
"""The myScan's capture device, or None (always None off Linux).""" """The myScan's capture device, or None (always None off Linux)."""
if not sys.platform.startswith("linux"): if not sys.platform.startswith("linux"):
@@ -108,12 +117,14 @@ class ScanThread(QThread):
self.bridge.set_status(state="starting", message="reading calibration") self.bridge.set_status(state="starting", message="reading calibration")
scanner = Scanner(self.opts, self.bridge) scanner = Scanner(self.opts, self.bridge)
except Exception as e: except Exception as e:
log_error(f"cannot open the scanner: {e}")
self.failed.emit(f"cannot open the scanner: {e}") self.failed.emit(f"cannot open the scanner: {e}")
return return
try: try:
while scanner.wait_press(self.quit): while scanner.wait_press(self.quit):
scanner.scan() scanner.scan()
except Exception as e: except Exception as e:
log_error(f"scan failed: {e}")
self.failed.emit(f"scan failed: {e}") self.failed.emit(f"scan failed: {e}")
finally: finally:
scanner.close() scanner.close()
@@ -137,48 +148,50 @@ class OcrThread(QThread):
try: try:
self.done.emit(self.ocr(self.img), time.time() - t) self.done.emit(self.ocr(self.img), time.time() - t)
except Exception as e: except Exception as e:
log_error(f"OCR failed: {e}")
self.failed.emit(str(e)) self.failed.emit(str(e))
class LineItem(QGraphicsPolygonItem): class LineItem(QGraphicsPolygonItem):
def __init__(self, index, box, score, on_click): """Outline of a recognised line, coloured by confidence (mouse events go to the view)."""
super().__init__(QPolygonF([QPointF(*p) for p in box]))
self.index, self.on_click = index, on_click
c = QColor(46, 160, 67) if score >= 0.9 else QColor(219, 140, 0) if score >= 0.7 else QColor(214, 40, 40)
self.color = c
self.setAcceptHoverEvents(True)
self.setCursor(Qt.PointingHandCursor)
self.setToolTip(f"{score:.0%}")
self.set_active(False)
def set_active(self, on): def __init__(self, box, score):
pen = QPen(QColor(30, 110, 230) if on else self.color, 3 if on else 1.5) 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) pen.setCosmetic(True)
self.setPen(pen) self.setPen(pen)
fill = QColor(30, 110, 230, 70) if on else QColor(self.color.red(), self.color.green(), self.color.blue(), 25) self.setBrush(QBrush(QColor(c.red(), c.green(), c.blue(), 25)))
self.setBrush(QBrush(fill)) self.setAcceptedMouseButtons(Qt.NoButton)
self.setToolTip(f"{score:.0%}")
def mousePressEvent(self, ev):
self.on_click(self.index)
class PageView(QGraphicsView): class PageView(QGraphicsView):
"""Zoomable, pannable page with the OCR line boxes on top. Keeps the page fitted to """Zoomable page whose recognised text can be selected like in a PDF viewer: drag to
the window until the user zooms.""" select in reading order, double-click for a word, triple-click for a line, shift-click
lineClicked = Signal(int) to extend. Middle-drag (or Space + drag, or any drag before there is text) pans. Keeps
the page fitted to the window until the user zooms."""
selectionChanged = Signal(str)
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setScene(QGraphicsScene(self)) self.setScene(QGraphicsScene(self))
self.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform) self.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform)
self.setDragMode(QGraphicsView.ScrollHandDrag)
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
self.setBackgroundBrush(QColor(30, 32, 36)) self.setBackgroundBrush(QColor(30, 32, 36))
self.setMouseTracking(True)
self.pixmap = QGraphicsPixmapItem() self.pixmap = QGraphicsPixmapItem()
self.pixmap.setTransformationMode(Qt.SmoothTransformation) self.pixmap.setTransformationMode(Qt.SmoothTransformation)
self.scene().addItem(self.pixmap) self.scene().addItem(self.pixmap)
self.items_, self.active, self.auto_fit = [], None, True self.sel_item = QGraphicsPathItem()
self.boxes_visible = True self.sel_item.setPen(QPen(Qt.NoPen))
self.sel_item.setBrush(QColor(30, 110, 230, 90))
self.sel_item.setZValue(2)
self.scene().addItem(self.sel_item)
self.items_, self.auto_fit, self.boxes_visible = [], True, True
self.lines, self.spans, self.text = [], [], ""
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): def set_image(self, qimg, keep_view=False):
self.pixmap.setPixmap(QPixmap.fromImage(qimg)) self.pixmap.setPixmap(QPixmap.fromImage(qimg))
@@ -188,28 +201,150 @@ class PageView(QGraphicsView):
if self.auto_fit: if self.auto_fit:
self.fit() self.fit()
def set_lines(self, lines): def set_lines(self, lines, text="", spans=()):
for it in self.items_: for it in self.items_:
self.scene().removeItem(it) self.scene().removeItem(it)
self.items_ = [LineItem(i, ln.box, ln.score, self.lineClicked.emit) for i, ln in enumerate(lines)] self.items_ = [LineItem(ln.box, ln.score) for ln in lines]
for it in self.items_: for it in self.items_:
it.setVisible(self.boxes_visible) it.setVisible(self.boxes_visible)
self.scene().addItem(it) self.scene().addItem(it)
self.active = None self.lines, self.text, self.spans = lines, text, list(spans)
self.sel, self.anchor = None, None
self.select(0, 0)
def show_boxes(self, on): def show_boxes(self, on):
self.boxes_visible = on self.boxes_visible = on
for it in self.items_: for it in self.items_:
it.setVisible(on) it.setVisible(on)
def highlight(self, i): # ---- selection ----
if self.active is not None and self.active < len(self.items_):
self.items_[self.active].set_active(False) def hit(self, scene_pos):
self.active = i """(line index, distance in pixels, text position) of the line nearest a scene point."""
if i is not None and i < len(self.items_): p = (scene_pos.x(), scene_pos.y())
self.items_[i].set_active(True) return min(((i, *ln.locate(p)) for i, ln in enumerate(self.lines)), key=lambda h: h[1])
if not self.auto_fit:
self.ensureVisible(self.items_[i], 40, 40) 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 selected_text(self):
a, b = self.sel
return self.text[a:b]
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)
m.addAction("Copy", self.copy).setEnabled(self.sel[0] != self.sel[1])
m.addAction("Copy all text", lambda: QGuiApplication.clipboard().setText(self.text))
m.addSeparator()
m.addAction("Select all", self.select_all)
m.exec(ev.globalPos())
def copy(self):
if self.selected_text():
QGuiApplication.clipboard().setText(self.selected_text())
# ---- zoom ----
def fit(self): def fit(self):
self.auto_fit = True self.auto_fit = True
@@ -274,7 +409,8 @@ class MainWindow(QMainWindow):
self.setAcceptDrops(True) self.setAcceptDrops(True)
self.opts = opts self.opts = opts
self.ocr = Ocr() self.ocr = Ocr()
self.image, self.path, self.lines, self.spans = None, None, [], [] 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.ocr_thread, self.scan_thread, self.scanning = None, None, False
self.bridge = Bridge() self.bridge = Bridge()
self.bridge.frame.connect(self.on_frame) self.bridge.frame.connect(self.on_frame)
@@ -318,7 +454,7 @@ class MainWindow(QMainWindow):
self.act_ocr = act("Read text", self.run_ocr, "Ctrl+R", "Run OCR on the page") 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 = act("Auto", lambda: None, tip="Read the text after every scan", checkable=True)
self.act_auto.setChecked(True) self.act_auto.setChecked(True)
self.act_copy = act("Copy text", self.copy_text, "Ctrl+Shift+C", "Copy all text to the clipboard") 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_savetxt = act("Save text…", self.save_text, "Ctrl+Shift+S", "Save the text as a .txt file")
tb.addSeparator() tb.addSeparator()
self.act_boxes = act("Boxes", lambda on: self.view.show_boxes(on), "Ctrl+B", "Show recognised lines on the page", True) self.act_boxes = act("Boxes", lambda on: self.view.show_boxes(on), "Ctrl+B", "Show recognised lines on the page", True)
@@ -331,22 +467,19 @@ class MainWindow(QMainWindow):
self.camera = ImageLabel("camera") self.camera = ImageLabel("camera")
self.view = PageView() self.view = PageView()
self.view.lineClicked.connect(self.select_line) self.view.selectionChanged.connect(self.on_selection)
self.text = QPlainTextEdit() for key, slot in ((QKeySequence.Copy, self.view.copy), (QKeySequence.SelectAll, self.view.select_all)):
self.text.setPlaceholderText("Recognised text appears here; scan a page, or open or drop an image.") a = QAction(self)
self.text.setStyleSheet("font-size: 14px;") a.setShortcut(key)
self.text.setLineWrapMode(QPlainTextEdit.NoWrap) # one OCR line per text line a.triggered.connect(slot)
self.text.cursorPositionChanged.connect(self.on_cursor) self.addAction(a)
self.text.textChanged.connect(self.on_text_edited)
self._setting_text = False
self.camera_pane, _ = pane("Camera", self.camera) self.camera_pane, _ = pane("Camera", self.camera)
page_pane, self.page_head = pane("Page", self.view) page_pane, self.page_head = pane("Page", self.view)
text_pane, self.text_head = pane("Text", self.text)
main = QSplitter(Qt.Horizontal) main = QSplitter(Qt.Horizontal)
for w in (self.camera_pane, page_pane, text_pane): for w in (self.camera_pane, page_pane):
main.addWidget(w) main.addWidget(w)
main.setSizes([170, 750, 480]) main.setSizes([220, 1180])
self.setCentralWidget(main) self.setCentralWidget(main)
self.status_label = QLabel() self.status_label = QLabel()
self.statusBar().addWidget(self.status_label, 1) self.statusBar().addWidget(self.status_label, 1)
@@ -385,8 +518,7 @@ class MainWindow(QMainWindow):
self.scanning = True self.scanning = True
self.view.set_lines([]) self.view.set_lines([])
self.view.set_image(QImage()) self.view.set_image(QImage())
self.set_text("", []) self.lines, self.text, self.image, self.path = [], "", None, None
self.lines, self.image, self.path = [], None, None
for a in (self.act_save, self.act_ocr, self.act_copy, self.act_savetxt): for a in (self.act_save, self.act_ocr, self.act_copy, self.act_savetxt):
a.setEnabled(False) a.setEnabled(False)
self.page_head.setText("Page (scanning)") self.page_head.setText("Page (scanning)")
@@ -415,8 +547,12 @@ class MainWindow(QMainWindow):
# ---- images ---- # ---- images ----
def load(self, path, auto_ocr=True): def load(self, path, auto_ocr=True):
try:
img = imread(path) img = imread(path)
except OSError:
img = None
if img is None: if img is None:
log_error(f"cannot read {path}")
QMessageBox.warning(self, "myScan", f"cannot read {path}") QMessageBox.warning(self, "myScan", f"cannot read {path}")
return return
self.set_page(img, path) self.set_page(img, path)
@@ -424,12 +560,12 @@ class MainWindow(QMainWindow):
self.run_ocr() self.run_ocr()
def set_page(self, img, path=None): def set_page(self, img, path=None):
self.image, self.path, self.lines = img, path, [] self.image, self.path, self.lines, self.text = img, path, [], ""
self.view.set_lines([]) self.view.set_lines([])
self.view.set_image(to_qimage(img)) self.view.set_image(to_qimage(img))
self.set_text("", [])
name = os.path.basename(path) if path else "page" name = os.path.basename(path) if path else "page"
self.page_head.setText(f"Page — {name} ({img.shape[1]}×{img.shape[0]})") 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.setWindowTitle(f"myScan — {name}")
self.act_save.setEnabled(True) self.act_save.setEnabled(True)
self.act_ocr.setEnabled(True) self.act_ocr.setEnabled(True)
@@ -445,7 +581,12 @@ class MainWindow(QMainWindow):
base = os.path.splitext(self.path)[0] if self.path else os.path.join(self.default_dir(), "page") 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) path, _ = QFileDialog.getSaveFileName(self, "Save image", base + ".png", IMAGE_FILTER)
if path: if path:
try:
imwrite(path, self.image) imwrite(path, self.image)
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}") self.set_message(f"saved {path}")
def default_dir(self): def default_dir(self):
@@ -469,7 +610,7 @@ class MainWindow(QMainWindow):
if self.image is None or (self.ocr_thread and self.ocr_thread.isRunning()): if self.image is None or (self.ocr_thread and self.ocr_thread.isRunning()):
return return
self.act_ocr.setEnabled(False) self.act_ocr.setEnabled(False)
self.text_head.setText("Text — reading…") self.page_head.setText(f"{self.page_title} · reading…")
self.set_message("reading text…") self.set_message("reading text…")
self.ocr_thread = OcrThread(self.ocr, self.image) self.ocr_thread = OcrThread(self.ocr, self.image)
self.ocr_thread.done.connect(self.on_ocr) self.ocr_thread.done.connect(self.on_ocr)
@@ -479,52 +620,32 @@ class MainWindow(QMainWindow):
def on_ocr(self, lines, secs): def on_ocr(self, lines, secs):
self.act_ocr.setEnabled(self.image is not None) self.act_ocr.setEnabled(self.image is not None)
self.lines = lines self.lines = lines
text, spans = layout(lines) self.text, spans = layout(lines)
self.view.set_lines(lines) self.view.set_lines(lines, self.text, spans)
self.set_text(text, spans)
conf = f", mean confidence {np.mean([ln.score for ln in lines]):.0%}" if lines else "" conf = f", mean confidence {np.mean([ln.score for ln in lines]):.0%}" if lines else ""
self.text_head.setText(f"Text — {len(lines)} lines{conf}") self.page_head.setText(f"{self.page_title} · {len(lines)} lines{conf}")
self.set_message(f"read {len(lines)} lines in {secs:.1f} s") self.set_message(f"read {len(lines)} lines in {secs:.1f} s" + (" · drag over the text to select it"
self.act_copy.setEnabled(bool(text)) if lines else ""))
self.act_savetxt.setEnabled(bool(text)) self.act_copy.setEnabled(bool(self.text))
self.act_savetxt.setEnabled(bool(self.text))
def on_ocr_failed(self, msg): def on_ocr_failed(self, msg):
self.act_ocr.setEnabled(self.image is not None) self.act_ocr.setEnabled(self.image is not None)
self.text_head.setText("Text") self.page_head.setText(self.page_title)
self.set_message(f"OCR failed: {msg}") self.set_message(f"OCR failed: {msg}")
def set_text(self, text, spans): def on_selection(self, text):
self._setting_text = True if not text:
self.text.setPlainText(text)
self._setting_text = False
self.spans = spans
def on_text_edited(self):
if not self._setting_text and self.spans: # positions no longer match the boxes
self.spans = []
self.view.highlight(None)
self.act_copy.setEnabled(bool(self.text.toPlainText()))
self.act_savetxt.setEnabled(bool(self.text.toPlainText()))
def on_cursor(self):
pos = self.text.textCursor().position()
hit = next((i for i, (a, b) in enumerate(self.spans) if a <= pos <= b), None)
if hit is not None or not self.spans:
self.view.highlight(hit)
def select_line(self, i):
if i >= len(self.spans):
return return
a, b = self.spans[i] cb = QGuiApplication.clipboard()
c = self.text.textCursor() if cb.supportsSelection(): # X11/Wayland: middle-click pastes it elsewhere
c.setPosition(a) cb.setText(text, QClipboard.Selection)
c.setPosition(b, QTextCursor.KeepAnchor) preview = " ⏎ ".join(t for t in text.split("\n") if t)
self.text.setTextCursor(c) preview = preview if len(preview) <= 90 else preview[:87] + "…"
self.text.setFocus() self.set_message(f"{len(text)} characters selected (Ctrl+C to copy): {preview}")
self.view.highlight(i)
def copy_text(self): def copy_text(self):
QGuiApplication.clipboard().setText(self.text.toPlainText()) QGuiApplication.clipboard().setText(self.text)
self.set_message("text copied to the clipboard") self.set_message("text copied to the clipboard")
def save_text(self): def save_text(self):
@@ -532,7 +653,7 @@ class MainWindow(QMainWindow):
path, _ = QFileDialog.getSaveFileName(self, "Save text", base + ".txt", "Text (*.txt)") path, _ = QFileDialog.getSaveFileName(self, "Save text", base + ".txt", "Text (*.txt)")
if path: if path:
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
f.write(self.text.toPlainText() + "\n") f.write(self.text + "\n")
self.set_message(f"saved {path}") self.set_message(f"saved {path}")
def closeEvent(self, ev): def closeEvent(self, ev):
@@ -557,6 +678,16 @@ def main(argv=None, opts=None):
app.setApplicationName("myScan") app.setApplicationName("myScan")
w = MainWindow(a) w = MainWindow(a)
w.show() 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: if a.image:
w.load(a.image) QTimer.singleShot(0, lambda: w.load(a.image))
return app.exec() return app.exec()
+78 -3
View File
@@ -5,8 +5,12 @@ multilingual PP-OCRv6 models ship inside the wheel, so it works offline. They re
Latin script (English, Swedish, German, French, ...) as well as Chinese and Japanese. Latin script (English, Swedish, German, French, ...) as well as Chinese and Japanese.
Independent of the scanner hardware, like myscan.stitching. Independent of the scanner hardware, like myscan.stitching.
lines = Ocr()(bgr) # [Line(box, text, score)], box = 4x2 corner points lines = Ocr()(bgr) # [Line(box, text, score, edges)], box = 4x2 corner points
text, spans = layout(lines) # plain text; spans[i] = (start, end) of lines[i] in it text, spans = layout(lines) # plain text; spans[i] = (start, end) of lines[i] in it
Each line knows where its characters are (from the recogniser's per-character boxes),
so text can be selected on the image: Line.locate maps a point to a character
position, Line.quad gives the outline of a range of characters.
""" """
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
@@ -19,6 +23,11 @@ class Line:
box: np.ndarray # 4x2 float corners (clockwise from top left), image pixels box: np.ndarray # 4x2 float corners (clockwise from top left), image pixels
text: str text: str
score: float score: float
edges: np.ndarray = None # len(text) + 1 character boundaries, 0..1 along the line
def __post_init__(self):
if self.edges is None: # no character boxes: assume evenly spaced characters
self.edges = np.linspace(0.0, 1.0, len(self.text) + 1)
@property @property
def cy(self): def cy(self):
@@ -34,6 +43,67 @@ class Line:
grows with the slant of a skewed page).""" grows with the slant of a skewed page)."""
return float(np.hypot(*(self.box[3] - self.box[0]))) return float(np.hypot(*(self.box[3] - self.box[0])))
def local(self, p):
"""Image point -> (u, v): u runs 0..1 along the line, v 0..1 from its top to
its bottom edge."""
b = self.box
m = np.array([b[1] - b[0], b[3] - b[0]]).T
return np.linalg.solve(m, np.asarray(p, float) - b[0])
def locate(self, p):
"""(distance in pixels from the line's box, character position nearest p)."""
u, v = self.local(p)
b = self.box
du = max(-u, u - 1, 0) * np.hypot(*(b[1] - b[0]))
dv = max(-v, v - 1, 0) * self.height
return float(np.hypot(du, dv)), int(np.abs(self.edges - u).argmin())
def quad(self, a, b):
"""4x2 outline of characters a..b (positions in text)."""
top, bot = self.box[[0, 1]], self.box[[3, 2]]
ta, tb = self.edges[a], self.edges[b]
lerp = lambda e, t: e[0] + t * (e[1] - e[0])
return np.array([lerp(top, ta), lerp(top, tb), lerp(bot, tb), lerp(bot, ta)])
def word(self, k):
"""(start, end) of the word around character position k."""
a = b = min(k, len(self.text))
while a > 0 and not self.text[a - 1].isspace():
a -= 1
while b < len(self.text) and not self.text[b].isspace():
b += 1
return a, b
def char_edges(box, text, chars):
"""Character boundaries along a line (0..1) from the recogniser's boxes for its
non-space characters (4x2 corners each); a space spans the gap between its
neighbours. None if the boxes don't match the text."""
if not chars or len(chars) != len(text) - text.count(" "):
return None
m0, m1 = (box[0] + box[3]) / 2, (box[1] + box[2]) / 2
d = m1 - m0
t = lambda p: float(np.dot(p - m0, d) / np.dot(d, d))
ext = iter([(t((c[0] + c[3]) / 2), t((c[1] + c[2]) / 2)) for c in (np.asarray(c, float) for c in chars)])
lo, hi = [None] * len(text), [None] * len(text)
for i, ch in enumerate(text):
if ch != " ":
lo[i], hi[i] = next(ext)
end = 0.0
for i in range(len(text)): # spaces start where the previous character ends
if lo[i] is None:
lo[i] = end
else:
end = hi[i]
start = 1.0
for i in reversed(range(len(text))): # ... and end where the next one starts
if hi[i] is None:
hi[i] = start
else:
start = lo[i]
edges = [0.0] + [(hi[i - 1] + lo[i]) / 2 for i in range(1, len(text))] + [1.0]
return np.clip(np.maximum.accumulate(edges), 0.0, 1.0)
class Ocr: class Ocr:
"""Lazily loads the engine (about 0.3 s) on first use; calls are serialised.""" """Lazily loads the engine (about 0.3 s) on first use; calls are serialised."""
@@ -50,10 +120,15 @@ class Ocr:
def __call__(self, bgr): def __call__(self, bgr):
with self._lock: with self._lock:
r = self.engine()(bgr) r = self.engine()(bgr, return_word_box=True, return_single_char_box=True)
if r.boxes is None: if r.boxes is None:
return [] return []
return [Line(np.asarray(b, float), t, float(s)) for b, t, s in zip(r.boxes, r.txts, r.scores)] words = r.word_results or [None] * len(r.txts)
out = []
for b, t, s, w in zip(r.boxes, r.txts, r.scores, words):
b = np.asarray(b, float)
out.append(Line(b, t, float(s), char_edges(b, t, [c[2] for c in w]) if w else None))
return out
def rows(lines): def rows(lines):