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:
erik
2026-10-09 15:06:59 +00:00
co-authored by Claude Opus 5.5
parent 2ef567580a
commit 16df509d01
2 changed files with 71 additions and 14 deletions
+3 -1
View File
@@ -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)
- `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; 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
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:
+68 -13
View File
@@ -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,
QPainterPath, QPen, QPixmap, QPolygonF)
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)
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)"
@@ -50,6 +50,17 @@ def imwrite(path, img):
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):
"""Print an error, with the traceback of the exception being handled if any."""
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
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 +
drag, or any drag before there is text) pans. Keeps
the page fitted to the window until the user zooms."""
drag, or any drag before there is text) pans. Keeps the page fitted to the window
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)
def __init__(self):
@@ -182,14 +197,16 @@ class PageView(QGraphicsView):
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
self.setBackgroundBrush(QColor(30, 32, 36))
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.scene().addItem(self.pixmap)
self.sel_item = QGraphicsPathItem()
self.sel_item = QGraphicsPathItem(self.page)
self.sel_item.setPen(QPen(Qt.NoPen))
self.sel_item.setBrush(QColor(30, 110, 230, 90))
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.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"
@@ -197,19 +214,38 @@ class PageView(QGraphicsView):
def set_image(self, qimg, keep_view=False):
self.pixmap.setPixmap(QPixmap.fromImage(qimg))
self.scene().setSceneRect(QRectF(self.pixmap.pixmap().rect()))
if not keep_view:
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:
self.fit()
def page_rect(self):
return self.page.mapRectToScene(self.pixmap.boundingRect())
def set_lines(self, lines, text="", spans=()):
for it in self.items_:
self.scene().removeItem(it)
self.items_ = [LineItem(ln.box, ln.score) for ln in lines]
for it in self.items_:
it.setVisible(self.boxes_visible)
self.scene().addItem(it)
it.setParentItem(self.page)
self.lines, self.text, self.spans = lines, text, list(spans)
self.joins = paragraph_joins(lines, text, self.spans) if lines else []
self.sel, self.anchor = None, None
@@ -224,7 +260,8 @@ class PageView(QGraphicsView):
def hit(self, scene_pos):
"""(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])
def position(self, scene_pos, unit=None):
@@ -365,7 +402,7 @@ class PageView(QGraphicsView):
def fit(self):
self.auto_fit = True
if not self.pixmap.pixmap().isNull():
self.fitInView(self.pixmap, Qt.KeepAspectRatio)
self.fitInView(self.page_rect(), Qt.KeepAspectRatio)
def zoom(self, f):
self.auto_fit = False
@@ -477,6 +514,10 @@ class MainWindow(QMainWindow):
"hyphenation (off: keep the lines as on the page)", True)
self.act_para.setChecked(True)
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.setChecked(True)
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
self.scanning = True
self.view.set_lines([])
self.view.angle = 0.0
self.view.set_image(QImage())
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):
@@ -583,6 +625,7 @@ class MainWindow(QMainWindow):
def set_page(self, img, path=None):
self.image, self.path, self.lines, self.text = img, path, [], ""
self.view.set_lines([])
self.view.angle = 0.0
self.view.set_image(to_qimage(img))
name = os.path.basename(path) if path else "page"
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)
if path:
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:
log_error(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.text, spans = layout(lines)
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 ""
self.page_head.setText(f"{self.page_title} · {len(lines)} lines{conf}")
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 []
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))
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):
self.act_ocr.setEnabled(self.image is not None)
self.page_head.setText(self.page_title)