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>
This commit is contained in:
erik
2026-10-09 12:44:40 +00:00
co-authored by Claude Opus 5.5
parent a1942faa41
commit 2ef567580a
3 changed files with 39 additions and 7 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ Linux driver tools for the Hama myScan (00052343) scanner mouse, a Dacuda design
page and a Screenshot button (camera + page + status as one PNG)
- `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`;
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; an upside-down page is detected and turned). Recognised lines are outlined on the page by
confidence, and the text is selected right on the page like in a PDF viewer: drag (in reading order across
lines), double-click a word, triple-click a line, shift-click to extend, Ctrl+A; Ctrl+C or right-click copies.
With Paragraphs on (Ctrl+J, the default), copied and saved text has each paragraph on one line:
+8 -4
View File
@@ -136,7 +136,7 @@ class ScanThread(QThread):
class OcrThread(QThread):
done = Signal(object, float) # lines, seconds
done = Signal(object, bool, float) # lines, page upside down, seconds
failed = Signal(str)
def __init__(self, ocr, img):
@@ -146,7 +146,7 @@ class OcrThread(QThread):
def run(self):
t = time.time()
try:
self.done.emit(self.ocr(self.img), time.time() - t)
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))
@@ -638,14 +638,18 @@ class MainWindow(QMainWindow):
self.ocr_thread.failed.connect(self.on_ocr_failed)
self.ocr_thread.start()
def on_ocr(self, lines, secs):
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 = ["drag over the text to select it"] if lines else []
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))
+30 -2
View File
@@ -6,6 +6,7 @@ Latin script (English, Swedish, German, French, ...) as well as Chinese and Japa
Independent of the scanner hardware, like myscan.stitching.
lines = Ocr()(bgr) # [Line(box, text, score, edges)], box = 4x2 corner points
lines, flipped = Ocr().read(bgr) # ... and whether the page was upside down
text, spans = layout(lines) # plain text; spans[i] = (start, end) of lines[i] in it
joins = paragraph_joins(lines, text, spans)
reflow(text, joins) # the text with paragraphs on one line each
@@ -105,11 +106,25 @@ def char_edges(box, text, chars):
return np.clip(np.maximum.accumulate(edges), 0.0, 1.0)
def quality(lines):
"""Mean confidence per character."""
n = sum(len(ln.text) for ln in lines)
return sum(ln.score * len(ln.text) for ln in lines) / n if n else 0.0
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.
RapidOCR's angle classifier, which flips each line it thinks is upside down, is off:
it judges lines one by one and now and then turns a perfectly upright line over,
reading it as garbage. All lines on a page face the same way, so the page is turned
as a whole instead: upright text reads at 0.97-1.0 confidence and upside-down text
at 0.55-0.8, so a page reading poorly is read again rotated 180 degrees, and the
better reading kept. That also covers pages turned 90 degrees, whose lines are cut
out turned one way, which is either right or upside down."""
def __init__(self, **params):
self.params = {"Global.log_level": "warning", **params}
self.params = {"Global.log_level": "warning", "Global.use_cls": False, **params}
self._engine, self._lock = None, threading.Lock()
def engine(self):
@@ -119,6 +134,19 @@ class Ocr:
return self._engine
def __call__(self, bgr):
return self.read(bgr)[0]
def read(self, bgr):
"""(lines, flipped): flipped if the page read better rotated 180 degrees, in
which case the boxes are in the coordinates of the rotated image."""
lines = self.lines(bgr)
if quality(lines) < 0.9:
turned = self.lines(np.ascontiguousarray(bgr[::-1, ::-1]))
if quality(turned) >= max(0.85, quality(lines)):
return turned, True
return lines, False
def lines(self, bgr):
with self._lock:
r = self.engine()(bgr, return_word_box=True, return_single_char_box=True)
if r.boxes is None: