Desktop app with live scanning and OCR

tools/app.py (myscan/app.py, Qt/PySide6) shows the camera and the page as it
is stitched, Start/Stop or the scan button (F5), then reads the finished page
with RapidOCR (myscan/ocr.py; bundled multilingual models, offline). Lines are
outlined on the page by confidence and linked to an editable text pane that
can be copied or saved. Existing images can be opened or dropped, so the app
is useful without the mouse.

The scan loop moves from tools/scan.py to myscan/scanner.py, shared by both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erik
2026-10-09 12:05:22 +00:00
co-authored by Claude Opus 5.5
parent c8b9f610bf
commit 909baec1e7
7 changed files with 880 additions and 138 deletions
+96
View File
@@ -0,0 +1,96 @@
"""OCR of scanned pages with RapidOCR (PaddleOCR's PP-OCR models on ONNX Runtime).
pip-installable on Linux, Windows and macOS with no system packages; the default
multilingual PP-OCRv6 models ship inside the wheel, so it works offline. They read
Latin script (English, Swedish, German, French, ...) as well as Chinese and Japanese.
Independent of the scanner hardware, like myscan.stitching.
lines = Ocr()(bgr) # [Line(box, text, score)], box = 4x2 corner points
text, spans = layout(lines) # plain text; spans[i] = (start, end) of lines[i] in it
"""
import threading
from dataclasses import dataclass
import numpy as np
@dataclass
class Line:
box: np.ndarray # 4x2 float corners (clockwise from top left), image pixels
text: str
score: float
@property
def cy(self):
return float(self.box[:, 1].mean())
@property
def left(self):
return float(self.box[:, 0].min())
@property
def height(self):
"""Text height along the box's left edge (not the axis-aligned extent, which
grows with the slant of a skewed page)."""
return float(np.hypot(*(self.box[3] - self.box[0])))
class Ocr:
"""Lazily loads the engine (about 0.3 s) on first use; calls are serialised."""
def __init__(self, **params):
self.params = {"Global.log_level": "warning", **params}
self._engine, self._lock = None, threading.Lock()
def engine(self):
if self._engine is None:
from rapidocr import RapidOCR
self._engine = RapidOCR(params=self.params)
return self._engine
def __call__(self, bgr):
with self._lock:
r = self.engine()(bgr)
if r.boxes is None:
return []
return [Line(np.asarray(b, float), t, float(s)) for b, t, s in zip(r.boxes, r.txts, r.scores)]
def rows(lines):
"""Group lines into visual rows (top to bottom), each sorted left to right. A line
joins a row when its centre is within half a line height of the row's."""
out = []
for ln in sorted(lines, key=lambda ln: ln.cy):
for row in out[-3:]:
if abs(ln.cy - np.mean([r.cy for r in row])) < 0.5 * min(ln.height, row[0].height):
row.append(ln)
break
else:
out.append([ln])
return [sorted(row, key=lambda ln: ln.left) for row in out]
def layout(lines):
"""Reading-order text: rows joined by newlines, with a blank line where the step to
the next row is over 1.5 times the typical line pitch (a new paragraph); items in a
row are separated by a tab. Returns (text, spans), spans[i] = (start, end) of
lines[i] in text."""
index = {id(ln): i for i, ln in enumerate(lines)}
rs = rows(lines)
cys = [float(np.mean([ln.cy for ln in row])) for row in rs]
steps = np.diff(cys)
pitch = float(np.median(steps)) if len(steps) else 0.0
spans, parts, pos = [None] * len(lines), [], 0
for k, row in enumerate(rs):
if k:
sep = "\n\n" if steps[k - 1] > 1.5 * max(pitch, 0.6 * row[0].height) else "\n"
parts.append(sep)
pos += len(sep)
for j, ln in enumerate(row):
if j:
parts.append("\t")
pos += 1
spans[index[id(ln)]] = (pos, pos + len(ln.text))
parts.append(ln.text)
pos += len(ln.text)
return "".join(parts), spans