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)
|
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
|
- `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`;
|
(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
|
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.
|
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:
|
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):
|
class OcrThread(QThread):
|
||||||
done = Signal(object, float) # lines, seconds
|
done = Signal(object, bool, float) # lines, page upside down, seconds
|
||||||
failed = Signal(str)
|
failed = Signal(str)
|
||||||
|
|
||||||
def __init__(self, ocr, img):
|
def __init__(self, ocr, img):
|
||||||
@@ -146,7 +146,7 @@ class OcrThread(QThread):
|
|||||||
def run(self):
|
def run(self):
|
||||||
t = time.time()
|
t = time.time()
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
log_error(f"OCR failed: {e}")
|
log_error(f"OCR failed: {e}")
|
||||||
self.failed.emit(str(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.failed.connect(self.on_ocr_failed)
|
||||||
self.ocr_thread.start()
|
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)
|
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.lines = lines
|
||||||
self.text, spans = layout(lines)
|
self.text, spans = layout(lines)
|
||||||
self.view.set_lines(lines, self.text, spans)
|
self.view.set_lines(lines, self.text, spans)
|
||||||
conf = f", mean confidence {np.mean([ln.score for ln in lines]):.0%}" if lines else ""
|
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}")
|
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.set_message(" · ".join([f"read {len(lines)} lines in {secs:.1f} s", *notes]))
|
||||||
self.act_copy.setEnabled(bool(self.text))
|
self.act_copy.setEnabled(bool(self.text))
|
||||||
self.act_savetxt.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.
|
Independent of the scanner hardware, like myscan.stitching.
|
||||||
|
|
||||||
lines = Ocr()(bgr) # [Line(box, text, score, edges)], box = 4x2 corner points
|
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
|
text, spans = layout(lines) # plain text; spans[i] = (start, end) of lines[i] in it
|
||||||
joins = paragraph_joins(lines, text, spans)
|
joins = paragraph_joins(lines, text, spans)
|
||||||
reflow(text, joins) # the text with paragraphs on one line each
|
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)
|
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:
|
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):
|
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()
|
self._engine, self._lock = None, threading.Lock()
|
||||||
|
|
||||||
def engine(self):
|
def engine(self):
|
||||||
@@ -119,6 +134,19 @@ class Ocr:
|
|||||||
return self._engine
|
return self._engine
|
||||||
|
|
||||||
def __call__(self, bgr):
|
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:
|
with self._lock:
|
||||||
r = self.engine()(bgr, return_word_box=True, return_single_char_box=True)
|
r = self.engine()(bgr, return_word_box=True, return_single_char_box=True)
|
||||||
if r.boxes is None:
|
if r.boxes is None:
|
||||||
|
|||||||
Reference in New Issue
Block a user