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 <noreply@anthropic.com>
This commit is contained in:
erik
2026-10-12 08:13:44 +00:00
co-authored by Claude Opus 5.5
parent 5f9ecca302
commit eeea9c3b30
2 changed files with 130 additions and 7 deletions
+60 -7
View File
@@ -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)
+70
View File
@@ -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