Level the page view by the text direction found by OCR
The page, its line boxes and the selection now hang under one rotated item, so selection works the same whether the page is levelled or shown as stitched. Level (Ctrl+L, on by default) toggles it, and Save image saves what is shown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,9 @@ 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; an upside-down page is detected and turned). 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). Level (Ctrl+L, on by default) shows the page rotated so its lines
|
||||||
|
are horizontal, using the text direction found by OCR; switch it off to see the page as stitched. Save image saves
|
||||||
|
what is shown. 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:
|
||||||
|
|||||||
+68
-13
@@ -22,10 +22,10 @@ from PySide6.QtCore import QObject, QPointF, QRectF, Qt, QThread, QTimer, Signal
|
|||||||
from PySide6.QtGui import (QAction, QBrush, QClipboard, QColor, QGuiApplication, QImage, QKeySequence, QPainter,
|
from PySide6.QtGui import (QAction, QBrush, QClipboard, QColor, QGuiApplication, QImage, QKeySequence, QPainter,
|
||||||
QPainterPath, QPen, QPixmap, QPolygonF)
|
QPainterPath, QPen, QPixmap, QPolygonF)
|
||||||
from PySide6.QtWidgets import (QApplication, QFileDialog, QGraphicsPathItem, QGraphicsPixmapItem,
|
from PySide6.QtWidgets import (QApplication, QFileDialog, QGraphicsPathItem, QGraphicsPixmapItem,
|
||||||
QGraphicsPolygonItem, QGraphicsScene, QGraphicsView, QLabel, QMainWindow, QMenu,
|
QGraphicsPolygonItem, QGraphicsRectItem, QGraphicsScene, QGraphicsView, QLabel, QMainWindow, QMenu,
|
||||||
QMessageBox, QSizePolicy, QSplitter, QToolBar, QVBoxLayout, QWidget)
|
QMessageBox, QSizePolicy, QSplitter, QToolBar, QVBoxLayout, QWidget)
|
||||||
|
|
||||||
from .ocr import Ocr, layout, paragraph_joins, reflow
|
from .ocr import Ocr, layout, paragraph_joins, reflow, text_direction
|
||||||
|
|
||||||
IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.tif *.tiff *.bmp *.webp)"
|
IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.tif *.tiff *.bmp *.webp)"
|
||||||
|
|
||||||
@@ -50,6 +50,17 @@ def imwrite(path, img):
|
|||||||
buf.tofile(path)
|
buf.tofile(path)
|
||||||
|
|
||||||
|
|
||||||
|
def level_image(img, angle):
|
||||||
|
"""The image rotated by -angle degrees (clockwise in image coordinates, y down), as
|
||||||
|
PageView shows it levelled, on a white canvas big enough to hold all of it."""
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
m = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0)
|
||||||
|
c, s = abs(m[0, 0]), abs(m[0, 1])
|
||||||
|
W, H = int(np.ceil(w * c + h * s)), int(np.ceil(w * s + h * c))
|
||||||
|
m[:, 2] += ((W - w) / 2, (H - h) / 2)
|
||||||
|
return cv2.warpAffine(img, m, (W, H), flags=cv2.INTER_CUBIC, borderValue=(255, 255, 255))
|
||||||
|
|
||||||
|
|
||||||
def log_error(msg):
|
def log_error(msg):
|
||||||
"""Print an error, with the traceback of the exception being handled if any."""
|
"""Print an error, with the traceback of the exception being handled if any."""
|
||||||
print(f"myScan: {msg}", file=sys.stderr, flush=True)
|
print(f"myScan: {msg}", file=sys.stderr, flush=True)
|
||||||
@@ -171,8 +182,12 @@ class PageView(QGraphicsView):
|
|||||||
select in reading order, double-click for a word, triple-click for a line, shift-click
|
select in reading order, double-click for a word, triple-click for a line, shift-click
|
||||||
to extend. Copied text either keeps the page's line breaks or, with `paragraphs` set,
|
to extend. Copied text either keeps the page's line breaks or, with `paragraphs` set,
|
||||||
has each paragraph on one line (see ocr.paragraph_joins). Middle-drag (or Space +
|
has each paragraph on one line (see ocr.paragraph_joins). Middle-drag (or Space +
|
||||||
drag, or any drag before there is text) pans. Keeps
|
drag, or any drag before there is text) pans. Keeps the page fitted to the window
|
||||||
the page fitted to the window until the user zooms."""
|
until the user zooms.
|
||||||
|
|
||||||
|
The page, its line boxes and the selection are children of one item, which is
|
||||||
|
rotated to level the text (set_angle, set_level), so selection works the same either
|
||||||
|
way: everything is in image coordinates under that item."""
|
||||||
selectionChanged = Signal(str)
|
selectionChanged = Signal(str)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -182,14 +197,16 @@ class PageView(QGraphicsView):
|
|||||||
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
|
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
|
||||||
self.setBackgroundBrush(QColor(30, 32, 36))
|
self.setBackgroundBrush(QColor(30, 32, 36))
|
||||||
self.setMouseTracking(True)
|
self.setMouseTracking(True)
|
||||||
self.pixmap = QGraphicsPixmapItem()
|
self.page = QGraphicsRectItem()
|
||||||
|
self.page.setPen(QPen(Qt.NoPen))
|
||||||
|
self.scene().addItem(self.page)
|
||||||
|
self.pixmap = QGraphicsPixmapItem(self.page)
|
||||||
self.pixmap.setTransformationMode(Qt.SmoothTransformation)
|
self.pixmap.setTransformationMode(Qt.SmoothTransformation)
|
||||||
self.scene().addItem(self.pixmap)
|
self.sel_item = QGraphicsPathItem(self.page)
|
||||||
self.sel_item = QGraphicsPathItem()
|
|
||||||
self.sel_item.setPen(QPen(Qt.NoPen))
|
self.sel_item.setPen(QPen(Qt.NoPen))
|
||||||
self.sel_item.setBrush(QColor(30, 110, 230, 90))
|
self.sel_item.setBrush(QColor(30, 110, 230, 90))
|
||||||
self.sel_item.setZValue(2)
|
self.sel_item.setZValue(2)
|
||||||
self.scene().addItem(self.sel_item)
|
self.angle, self.level = 0.0, True
|
||||||
self.items_, self.auto_fit, self.boxes_visible = [], True, True
|
self.items_, self.auto_fit, self.boxes_visible = [], True, True
|
||||||
self.lines, self.spans, self.text, self.joins, self.paragraphs = [], [], "", [], True
|
self.lines, self.spans, self.text, self.joins, self.paragraphs = [], [], "", [], True
|
||||||
self.sel, self.anchor, self.unit = (0, 0), None, None # unit: drag extends by "word"/"line"
|
self.sel, self.anchor, self.unit = (0, 0), None, None # unit: drag extends by "word"/"line"
|
||||||
@@ -197,19 +214,38 @@ class PageView(QGraphicsView):
|
|||||||
|
|
||||||
def set_image(self, qimg, keep_view=False):
|
def set_image(self, qimg, keep_view=False):
|
||||||
self.pixmap.setPixmap(QPixmap.fromImage(qimg))
|
self.pixmap.setPixmap(QPixmap.fromImage(qimg))
|
||||||
self.scene().setSceneRect(QRectF(self.pixmap.pixmap().rect()))
|
|
||||||
if not keep_view:
|
if not keep_view:
|
||||||
self.auto_fit = True
|
self.auto_fit = True
|
||||||
|
self.place()
|
||||||
|
|
||||||
|
def set_angle(self, angle):
|
||||||
|
"""The text's slant in degrees (clockwise, as in image coordinates)."""
|
||||||
|
self.angle = angle
|
||||||
|
self.place()
|
||||||
|
|
||||||
|
def set_level(self, on):
|
||||||
|
self.level = on
|
||||||
|
self.place()
|
||||||
|
|
||||||
|
def place(self):
|
||||||
|
"""Rotate the page to level it (or not), and fit the scene around it."""
|
||||||
|
r = self.pixmap.boundingRect()
|
||||||
|
self.page.setTransformOriginPoint(r.center())
|
||||||
|
self.page.setRotation(-self.angle if self.level else 0.0)
|
||||||
|
self.scene().setSceneRect(self.page_rect())
|
||||||
if self.auto_fit:
|
if self.auto_fit:
|
||||||
self.fit()
|
self.fit()
|
||||||
|
|
||||||
|
def page_rect(self):
|
||||||
|
return self.page.mapRectToScene(self.pixmap.boundingRect())
|
||||||
|
|
||||||
def set_lines(self, lines, text="", spans=()):
|
def set_lines(self, lines, text="", spans=()):
|
||||||
for it in self.items_:
|
for it in self.items_:
|
||||||
self.scene().removeItem(it)
|
self.scene().removeItem(it)
|
||||||
self.items_ = [LineItem(ln.box, ln.score) for ln in lines]
|
self.items_ = [LineItem(ln.box, ln.score) for ln in lines]
|
||||||
for it in self.items_:
|
for it in self.items_:
|
||||||
it.setVisible(self.boxes_visible)
|
it.setVisible(self.boxes_visible)
|
||||||
self.scene().addItem(it)
|
it.setParentItem(self.page)
|
||||||
self.lines, self.text, self.spans = lines, text, list(spans)
|
self.lines, self.text, self.spans = lines, text, list(spans)
|
||||||
self.joins = paragraph_joins(lines, text, self.spans) if lines else []
|
self.joins = paragraph_joins(lines, text, self.spans) if lines else []
|
||||||
self.sel, self.anchor = None, None
|
self.sel, self.anchor = None, None
|
||||||
@@ -224,7 +260,8 @@ class PageView(QGraphicsView):
|
|||||||
|
|
||||||
def hit(self, scene_pos):
|
def hit(self, scene_pos):
|
||||||
"""(line index, distance in pixels, text position) of the line nearest a scene point."""
|
"""(line index, distance in pixels, text position) of the line nearest a scene point."""
|
||||||
p = (scene_pos.x(), scene_pos.y())
|
q = self.page.mapFromScene(scene_pos)
|
||||||
|
p = (q.x(), q.y())
|
||||||
return min(((i, *ln.locate(p)) for i, ln in enumerate(self.lines)), key=lambda h: h[1])
|
return min(((i, *ln.locate(p)) for i, ln in enumerate(self.lines)), key=lambda h: h[1])
|
||||||
|
|
||||||
def position(self, scene_pos, unit=None):
|
def position(self, scene_pos, unit=None):
|
||||||
@@ -365,7 +402,7 @@ class PageView(QGraphicsView):
|
|||||||
def fit(self):
|
def fit(self):
|
||||||
self.auto_fit = True
|
self.auto_fit = True
|
||||||
if not self.pixmap.pixmap().isNull():
|
if not self.pixmap.pixmap().isNull():
|
||||||
self.fitInView(self.pixmap, Qt.KeepAspectRatio)
|
self.fitInView(self.page_rect(), Qt.KeepAspectRatio)
|
||||||
|
|
||||||
def zoom(self, f):
|
def zoom(self, f):
|
||||||
self.auto_fit = False
|
self.auto_fit = False
|
||||||
@@ -477,6 +514,10 @@ class MainWindow(QMainWindow):
|
|||||||
"hyphenation (off: keep the lines as on the page)", True)
|
"hyphenation (off: keep the lines as on the page)", True)
|
||||||
self.act_para.setChecked(True)
|
self.act_para.setChecked(True)
|
||||||
tb.addSeparator()
|
tb.addSeparator()
|
||||||
|
self.act_level = act("Level", self.set_level, "Ctrl+L",
|
||||||
|
"Show the page rotated so its lines are horizontal (off: as stitched); "
|
||||||
|
"Save image saves what is shown", True)
|
||||||
|
self.act_level.setChecked(True)
|
||||||
self.act_boxes = act("Boxes", lambda on: self.view.show_boxes(on), "Ctrl+B", "Show recognised lines on the page", True)
|
self.act_boxes = act("Boxes", lambda on: self.view.show_boxes(on), "Ctrl+B", "Show recognised lines on the page", True)
|
||||||
self.act_boxes.setChecked(True)
|
self.act_boxes.setChecked(True)
|
||||||
act("Fit", lambda: self.view.fit(), "Ctrl+0", "Fit the page to the window")
|
act("Fit", lambda: self.view.fit(), "Ctrl+0", "Fit the page to the window")
|
||||||
@@ -538,6 +579,7 @@ class MainWindow(QMainWindow):
|
|||||||
if state == "scanning" and not self.scanning: # a new scan: clear the old page
|
if state == "scanning" and not self.scanning: # a new scan: clear the old page
|
||||||
self.scanning = True
|
self.scanning = True
|
||||||
self.view.set_lines([])
|
self.view.set_lines([])
|
||||||
|
self.view.angle = 0.0
|
||||||
self.view.set_image(QImage())
|
self.view.set_image(QImage())
|
||||||
self.lines, self.text, self.image, self.path = [], "", None, None
|
self.lines, self.text, self.image, self.path = [], "", None, None
|
||||||
for a in (self.act_save, self.act_ocr, self.act_copy, self.act_savetxt):
|
for a in (self.act_save, self.act_ocr, self.act_copy, self.act_savetxt):
|
||||||
@@ -583,6 +625,7 @@ class MainWindow(QMainWindow):
|
|||||||
def set_page(self, img, path=None):
|
def set_page(self, img, path=None):
|
||||||
self.image, self.path, self.lines, self.text = img, path, [], ""
|
self.image, self.path, self.lines, self.text = img, path, [], ""
|
||||||
self.view.set_lines([])
|
self.view.set_lines([])
|
||||||
|
self.view.angle = 0.0
|
||||||
self.view.set_image(to_qimage(img))
|
self.view.set_image(to_qimage(img))
|
||||||
name = os.path.basename(path) if path else "page"
|
name = os.path.basename(path) if path else "page"
|
||||||
self.page_title = f"Page — {name} ({img.shape[1]}×{img.shape[0]})"
|
self.page_title = f"Page — {name} ({img.shape[1]}×{img.shape[0]})"
|
||||||
@@ -603,7 +646,10 @@ class MainWindow(QMainWindow):
|
|||||||
path, _ = QFileDialog.getSaveFileName(self, "Save image", base + ".png", IMAGE_FILTER)
|
path, _ = QFileDialog.getSaveFileName(self, "Save image", base + ".png", IMAGE_FILTER)
|
||||||
if path:
|
if path:
|
||||||
try:
|
try:
|
||||||
imwrite(path, self.image)
|
img = self.image
|
||||||
|
if self.view.level and abs(self.view.angle) > 0.05:
|
||||||
|
img = level_image(img, self.view.angle)
|
||||||
|
imwrite(path, img)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
log_error(f"cannot save {path}: {e}")
|
log_error(f"cannot save {path}: {e}")
|
||||||
QMessageBox.warning(self, "myScan", f"cannot save {path}: {e}")
|
QMessageBox.warning(self, "myScan", f"cannot save {path}: {e}")
|
||||||
@@ -646,14 +692,23 @@ class MainWindow(QMainWindow):
|
|||||||
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)
|
||||||
|
d = text_direction(lines)
|
||||||
|
self.view.set_angle(float(np.degrees(np.arctan2(d[1], d[0]))) if lines else 0.0)
|
||||||
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 = ["page was upside down, turned"] if flipped else []
|
notes = ["page was upside down, turned"] if flipped else []
|
||||||
|
notes += [f"text slanted {self.view.angle:+.1f}°"] if abs(self.view.angle) >= 0.1 else []
|
||||||
notes += ["drag over the text to select it"] if lines 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))
|
||||||
|
|
||||||
|
def set_level(self, on):
|
||||||
|
self.view.set_level(on)
|
||||||
|
if self.lines:
|
||||||
|
what = f"levelled ({-self.view.angle:+.1f}°)" if on else "as stitched"
|
||||||
|
self.set_message(f"showing the page {what}")
|
||||||
|
|
||||||
def on_ocr_failed(self, msg):
|
def on_ocr_failed(self, msg):
|
||||||
self.act_ocr.setEnabled(self.image is not None)
|
self.act_ocr.setEnabled(self.image is not None)
|
||||||
self.page_head.setText(self.page_title)
|
self.page_head.setText(self.page_title)
|
||||||
|
|||||||
Reference in New Issue
Block a user