Files
myscan/myscan/app.py
T
erikandClaude Opus 5.5 2ef567580a Decide text orientation per page instead of per line
RapidOCR's angle classifier judges each cut-out line on its own and now
and then turns a perfectly upright line upside down, which then reads as
garbage ("Jo a ds ne t i d s t d no" for a line ending in "or" on the
latest scan). It is off now. All lines on a page face the same way, so a
page that reads poorly (upright text scores 0.97-1.0 per character,
upside-down text 0.55-0.8) is read again rotated 180 degrees and the
better reading kept; that also covers pages turned 90 degrees. The app
shows an upside-down page turned the right way up.

Upright pages read about 25% faster without the per-line classifier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:44:40 +00:00

724 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, QGuiApplication, QImage, QKeySequence, QPainter,
QPainterPath, QPen, QPixmap, QPolygonF)
from PySide6.QtWidgets import (QApplication, QFileDialog, QGraphicsPathItem, QGraphicsPixmapItem,
QGraphicsPolygonItem, QGraphicsScene, QGraphicsView, QLabel, QMainWindow, QMenu,
QMessageBox, QSizePolicy, QSplitter, QToolBar, QVBoxLayout, QWidget)
from .ocr import Ocr, layout, paragraph_joins, reflow
IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.tif *.tiff *.bmp *.webp)"
def to_qimage(img):
"""BGR or grayscale uint8 array -> QImage (a copy)."""
img = np.ascontiguousarray(img)
h, w = img.shape[:2]
fmt = QImage.Format_Grayscale8 if img.ndim == 2 else QImage.Format_BGR888
return QImage(img.data, w, h, img.strides[0], fmt).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 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))
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."""
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.pixmap = QGraphicsPixmapItem()
self.pixmap.setTransformationMode(Qt.SmoothTransformation)
self.scene().addItem(self.pixmap)
self.sel_item = QGraphicsPathItem()
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.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))
self.scene().setSceneRect(QRectF(self.pixmap.pixmap().rect()))
if not keep_view:
self.auto_fit = True
if self.auto_fit:
self.fit()
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)
self.scene().addItem(it)
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."""
p = (scene_pos.x(), scene_pos.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.pixmap, 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))
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.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.camera_pane.hide()
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_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_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 "
"hyphenation (off: keep the lines as on the page)", True)
self.act_para.setChecked(True)
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.setChecked(True)
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):
a.setEnabled(False)
self.camera = ImageLabel("camera")
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)
self.camera_pane, _ = pane("Camera", self.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.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.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):
a.setEnabled(False)
self.page_head.setText("Page (scanning)")
elif state in ("idle", "done"):
self.scanning = False
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, auto_ocr=self.act_auto.isChecked())
# ---- images ----
def load(self, path, auto_ocr=True):
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)
if auto_ocr:
self.run_ocr()
def set_page(self, img, path=None):
self.image, self.path, self.lines, self.text = img, path, [], ""
self.view.set_lines([])
self.view.set_image(to_qimage(img))
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)
self.act_ocr.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:
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}")
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.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.done.connect(self.on_ocr)
self.ocr_thread.failed.connect(self.on_ocr_failed)
self.ocr_thread.start()
def on_ocr(self, lines, flipped, secs):
self.act_ocr.setEnabled(self.image is not None)
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))
self.lines = lines
self.text, spans = layout(lines)
self.view.set_lines(lines, self.text, spans)
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 += ["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 on_ocr_failed(self, msg):
self.act_ocr.setEnabled(self.image is not None)
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 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…")
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()