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>
97 lines
3.4 KiB
Python
97 lines
3.4 KiB
Python
"""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
|