From eeea9c3b30d9e4a59d23f550ba0ba91c9914f155 Mon Sep 17 00:00:00 2001 From: erik Date: Mon, 12 Oct 2026 08:13:44 +0000 Subject: [PATCH] Add an optional Enhance filter that removes bleed-through Enhance (Ctrl+E) flattens the paper to white, darkens the ink and whitens marks fainter than the ink, such as print showing through from the other side. Faint pixels next to ink keep their shading, so strokes keep their edges. Coloured ink keeps its hue. A dropdown sets the strength (Light, Normal, Strong). OCR and Save use the page as shown. Co-Authored-By: Claude Opus 5.5 --- myscan/app.py | 67 ++++++++++++++++++++++++++++++++++++++++----- myscan/enhance.py | 70 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 myscan/enhance.py diff --git a/myscan/app.py b/myscan/app.py index 4af5574..d91341f 100644 --- a/myscan/app.py +++ b/myscan/app.py @@ -4,7 +4,8 @@ Left, the camera and the page as it is being stitched (the same preview as the b viewer); after a scan the finished page is loaded and read with RapidOCR (myscan/ocr.py). Recognised lines are outlined on the page, coloured by confidence, and their text can be selected right on the page (drag, double-click a word, triple-click a line) and copied; -all of it can be copied or saved. Images can also be opened or dropped onto the window, +all of it can be copied or saved. Enhance whitens the paper, darkens the text and drops +bleed-through from the other side (myscan/enhance.py); OCR and Save use what is shown. Images can also be opened or dropped onto the window, so OCR works on any platform; scanning needs Linux and the mouse. Run: python tools/app.py [image] @@ -19,15 +20,19 @@ import traceback import cv2 import numpy as np from PySide6.QtCore import QObject, QPointF, QRectF, Qt, QThread, QTimer, Signal -from PySide6.QtGui import (QAction, QBrush, QClipboard, QColor, QFont, QGuiApplication, QImage, QKeySequence, +from PySide6.QtGui import (QAction, QActionGroup, QBrush, QClipboard, QColor, QFont, QGuiApplication, QImage, QKeySequence, QPainter, QPainterPath, QPen, QPixmap, QPolygonF) from PySide6.QtWidgets import (QApplication, QFileDialog, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsPolygonItem, QGraphicsRectItem, QGraphicsScene, QGraphicsView, QLabel, QMainWindow, QMenu, - QMessageBox, QSizePolicy, QSplitter, QToolBar, QVBoxLayout, QWidget) + QMessageBox, QSizePolicy, QSplitter, QToolBar, QToolButton, QVBoxLayout, QWidget) +from .enhance import enhance from .ocr import Ocr, layout, paragraph_joins, reflow, text_direction IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.tif *.tiff *.bmp *.webp)" +# Enhance strengths (see enhance.enhance): how much fainter than the ink a mark must be to +# be whitened as bleed-through. Stronger removes darker bleed-through and more faint lines. +ENHANCE_LEVELS = (("Light", 0.15), ("Normal", 0.25), ("Strong", 0.5)) def to_qimage(img, transparent=None): @@ -576,6 +581,8 @@ class MainWindow(QMainWindow): self.ocr = Ocr() self.image, self.path, self.lines, self.text = None, None, [], "" self.blank = None # unscanned-area mask of the shown page (set_page) + self.enhanced = None # self.image through enhance(), made when first shown + self.enhance_strength = dict(ENHANCE_LEVELS)["Normal"] self.page_title = "Page" self.ocr_thread, self.scan_thread, self.scanning = None, None, False self.page_id = 0 # bumped for every new page, so a slow OCR of an old one is not applied @@ -630,6 +637,21 @@ class MainWindow(QMainWindow): "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_enhance = act("Enhance", self.set_enhance, "Ctrl+E", + "Whiten the paper, darken the text and remove bleed-through from the " + "other side of the page; the text is read again from what is shown. The arrow " + "sets how strongly: stronger removes darker bleed-through, but also faint lines", + True) + levels = QMenu(self) + group = QActionGroup(self) + for name, strength in ENHANCE_LEVELS: + a = levels.addAction(name, lambda strength=strength: self.set_enhance_strength(strength)) + a.setCheckable(True) + a.setChecked(strength == self.enhance_strength) + group.addAction(a) + button = tb.widgetForAction(self.act_enhance) + button.setMenu(levels) + button.setPopupMode(QToolButton.MenuButtonPopup) 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") @@ -703,6 +725,7 @@ class MainWindow(QMainWindow): self.view.angle = 0.0 self.view.set_image(QImage()) self.lines, self.text, self.image, self.path = [], "", None, None + self.enhanced = None self.page_id += 1 for a in (self.act_save, self.act_copy): a.setEnabled(False) @@ -750,16 +773,44 @@ class MainWindow(QMainWindow): (opened images are shown as they are: their white may be paper).""" self.image, self.path, self.lines, self.text = img, path, [], "" self.blank = unscanned(img) if scanned else None + self.enhanced = None self.page_id += 1 self.view.set_lines([]) self.view.angle = 0.0 - self.view.set_image(to_qimage(img, self.blank)) + self.view.set_image(to_qimage(self.shown(), self.blank)) name = os.path.basename(path) if path else "page" self.page_title = f"Page — {name} ({img.shape[1]}×{img.shape[0]})" self.page_head.setText(self.page_title) self.setWindowTitle(f"myScan — {name}") self.act_save.setEnabled(True) + def shown(self): + """The page as shown, saved and read: enhanced or not.""" + if not self.act_enhance.isChecked() or self.image is None: + return self.image + if self.enhanced is None: + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + self.enhanced = enhance(self.image, self.blank, self.enhance_strength) + finally: + QApplication.restoreOverrideCursor() + return self.enhanced + + def set_enhance(self, on): + if self.image is None: + return + self.page_id += 1 # read the page again as now shown; drop a reading under way + self.view.set_image(to_qimage(self.shown(), self.blank), keep_view=True) + self.set_message("page enhanced" if on else "page as scanned") + self.run_ocr() + + def set_enhance_strength(self, strength): + self.enhance_strength, self.enhanced = strength, None + if self.act_enhance.isChecked(): + self.set_enhance(True) + else: + self.act_enhance.trigger() # picking a strength turns Enhance on + def open_image(self): path, _ = QFileDialog.getOpenFileName(self, "Open image", self.default_dir(), IMAGE_FILTER) if path: @@ -772,7 +823,7 @@ class MainWindow(QMainWindow): path, _ = QFileDialog.getSaveFileName(self, "Save image", base + ".png", IMAGE_FILTER) if path: try: - img = self.image + img = self.shown() if self.view.level and abs(self.view.angle) > 0.05: img = level_image(img, self.view.angle) imwrite(path, img) @@ -804,7 +855,7 @@ class MainWindow(QMainWindow): return self.page_head.setText(f"{self.page_title} · reading…") self.set_message("reading text…") - self.ocr_thread = OcrThread(self.ocr, self.image) + self.ocr_thread = OcrThread(self.ocr, self.shown()) self.ocr_thread.page_id = self.page_id self.ocr_thread.done.connect(self.on_ocr) self.ocr_thread.failed.connect(self.on_ocr_failed) @@ -820,9 +871,11 @@ class MainWindow(QMainWindow): return if flipped: # the boxes are on the page turned the right way up self.image = np.ascontiguousarray(self.image[::-1, ::-1]) + if self.enhanced is not None: + self.enhanced = np.ascontiguousarray(self.enhanced[::-1, ::-1]) if self.blank is not None: self.blank = self.blank[::-1, ::-1] - self.view.set_image(to_qimage(self.image, self.blank)) + self.view.set_image(to_qimage(self.shown(), self.blank)) self.lines = lines self.text, spans = layout(lines) self.view.set_lines(lines, self.text, spans) diff --git a/myscan/enhance.py b/myscan/enhance.py new file mode 100644 index 0000000..1bc7f72 --- /dev/null +++ b/myscan/enhance.py @@ -0,0 +1,70 @@ +"""Clean up a page for reading: flatten the paper to white, darken the text, and drop +bleed-through (the other side's print showing through, fainter than this side's ink). + +The paper's own brightness is estimated per channel by closing the ink away (a max +filter wider than a stroke) and smoothing; dividing by it makes the paper 1.0 all over, +whatever the lighting or tint. On that scale ink and bleed-through separate by depth: +Otsu's threshold over the page splits the ink from everything lighter, and the white +point is set part way from it to the paper, so marks lighter than that (the bleed-through, +show-through texture, faint shadows) go to white and the ink is stretched to black. Within +a few pixels of the ink the white point stays at the paper: faint pixels there are the +ink's own soft edges and thin strokes, which would otherwise be eaten away. + +Depth is the darkest channel, so coloured ink is ink too, and colour is kept by scaling +what each channel absorbs by one factor: blue ink comes out a deeper blue, grey ink grey. +""" +import cv2 +import numpy as np + + +def paper(img, blank=None, stroke=40, k=4): + """float32 estimate of the paper's colour under each pixel of a BGR or gray uint8 page. + `stroke`: a width in pixels larger than any ink stroke; `blank`: a bool mask of pixels + that are not page (left out). Done on a k-times smaller copy: the paper varies slowly.""" + f = img.astype(np.float32) + if blank is not None and blank.any(): + f[blank] = np.median(f[~blank], axis=0) # don't let the unscanned white lift the edges + small = cv2.resize(f, None, fx=1 / k, fy=1 / k, interpolation=cv2.INTER_AREA) + r = max(1, stroke // k) + small = cv2.dilate(small, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1))) + small = cv2.GaussianBlur(small, (0, 0), r) + bg = cv2.resize(small, (img.shape[1], img.shape[0]), interpolation=cv2.INTER_LINEAR) + # Large dark areas (the table beside the page) are not paper: keep them dark. + floor = 0.6 * np.percentile(bg if blank is None else bg[~blank], 90, axis=0) + return np.maximum(bg, floor.astype(np.float32)) + + +def enhance(img, blank=None, strength=0.25, edge=3): + """The page with its paper flattened to white, ink stretched to black and marks fainter + than the ink (bleed-through) whitened. `strength` (0-1): how far the white point moves + from the paper towards the ink threshold; `edge`: how many pixels around the ink keep + their shading (its soft edges). Returns uint8 of the same shape; `blank` pixels are + left as they were.""" + norm = img.astype(np.float32) / paper(img, blank) + # Lightness as the darkest channel, so coloured ink (blue absorbs red) counts as ink. + lum = norm.min(axis=2) if norm.ndim == 3 else norm + page = lum if blank is None else lum[~blank] + hist = np.clip(page * 255, 0, 255).astype(np.uint8) + t, _ = cv2.threshold(hist.reshape(-1, 1), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + t /= 255 + white = 1 - strength * (1 - t) + ink = page[page < t] + black = np.percentile(ink, 5) if ink.size else 0.0 + black = min(black, white - 0.05) + # Next to ink, faint pixels are its anti-aliased edges and fine strokes, not bleed-through: + # there the white point stays at the paper, so strokes keep their full width and shading. + core = (lum < t).astype(np.uint8) + near = cv2.dilate(core, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge + 1,) * 2)) + near = cv2.GaussianBlur(near.astype(np.float32), (0, 0), edge / 2) + white = white + (1 - white) * near + level = np.clip((lum - black) / (white - black), 0, 1) + if norm.ndim == 3: + # Scale what each channel absorbs by the same factor, the darkness's stretch: the + # ink keeps its hue (its channels' proportions) and gets deeper, never clipped. + absorbed = np.clip(1 - norm, 0, None) + k = (1 - level) / np.maximum(1 - lum, 1e-3) + level = 1 - absorbed * k[..., None] + out = (255 * np.clip(level, 0, 1)).round().astype(np.uint8) + if blank is not None: + out[blank] = img[blank] + return out