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:
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user