Make only a scan's unscanned area transparent, not every white pixel

The page view made every pure-white pixel transparent. The white point
puts the paper's 97th percentile at 245, so the brightest paper clips
to 255 and showed as dark speckles; opened images with a white
background turned dark entirely; and the live preview (unscanned area
already dark) only got the speckles. Now only fresh scans are masked,
and only the white regions connected to the border, which is where the
stitcher's uncovered area is after cropping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erik
2026-10-11 22:55:29 +00:00
co-authored by Claude Opus 5.5
parent e9ab616161
commit 5f9ecca302
+29 -15
View File
@@ -30,22 +30,30 @@ from .ocr import Ocr, layout, paragraph_joins, reflow, text_direction
IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.tif *.tiff *.bmp *.webp)"
def to_qimage(img, page=False):
"""BGR or grayscale uint8 array -> QImage (a copy). With `page`, exact-white pixels
(the uncovered corners of a stitched page) become transparent, so when the view
rotates the page to level it those corners show the window background through."""
def to_qimage(img, transparent=None):
"""BGR or grayscale uint8 array -> QImage (a copy); pixels where the bool mask
`transparent` is set get alpha 0."""
img = np.ascontiguousarray(img)
h, w = img.shape[:2]
if not page:
if transparent is None:
fmt = QImage.Format_Grayscale8 if img.ndim == 2 else QImage.Format_BGR888
return QImage(img.data, w, h, img.strides[0], fmt).copy()
bgra = cv2.cvtColor(img, cv2.COLOR_GRAY2BGRA if img.ndim == 2 else cv2.COLOR_BGR2BGRA)
uncovered = (img == 255).all(axis=-1) if img.ndim == 3 else (img == 255)
bgra[uncovered, 3] = 0
bgra = np.ascontiguousarray(bgra)
bgra[transparent, 3] = 0
return QImage(bgra.data, w, h, bgra.strides[0], QImage.Format_ARGB32).copy()
def unscanned(img):
"""Mask of a stitched page's unscanned area: the pure-white regions touching the
border (the stitcher fills uncovered pixels with 255 and crops to the scanned area).
Paper that clips to 255 inside the page is not connected to the border and stays."""
white = ((img == 255).all(axis=-1) if img.ndim == 3 else img == 255).astype(np.uint8)
_, labels = cv2.connectedComponents(white, connectivity=4)
edge = np.concatenate([labels[0], labels[-1], labels[:, 0], labels[:, -1]])
edge = np.unique(edge[edge > 0])
return np.isin(labels, edge) if len(edge) else None
def imread(path):
"""cv2.imread that also takes non-ASCII paths on Windows."""
return cv2.imdecode(np.fromfile(path, np.uint8), cv2.IMREAD_COLOR)
@@ -98,7 +106,7 @@ class Bridge(QObject):
self.start_event, self.stop_event = threading.Event(), threading.Event()
def publish(self, name, bgr):
(self.frame if name == "frame" else self.page).emit(to_qimage(bgr, page=(name == "page")))
(self.frame if name == "frame" else self.page).emit(to_qimage(bgr))
def set_status(self, **kw):
self.state = {**self.state, **kw}
@@ -567,6 +575,7 @@ class MainWindow(QMainWindow):
self.opts = opts
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.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
@@ -720,11 +729,11 @@ class MainWindow(QMainWindow):
def on_result(self, path):
self.scanning = False
self.load(path)
self.load(path, scanned=True)
# ---- images ----
def load(self, path):
def load(self, path, scanned=False):
try:
img = imread(path)
except OSError:
@@ -733,15 +742,18 @@ class MainWindow(QMainWindow):
log_error(f"cannot read {path}")
QMessageBox.warning(self, "myScan", f"cannot read {path}")
return
self.set_page(img, path)
self.set_page(img, path, scanned)
self.run_ocr()
def set_page(self, img, path=None):
def set_page(self, img, path=None, scanned=False):
"""`scanned`: a fresh stitched page, whose unscanned corners are shown transparent
(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.page_id += 1
self.view.set_lines([])
self.view.angle = 0.0
self.view.set_image(to_qimage(img, page=True))
self.view.set_image(to_qimage(img, 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)
@@ -808,7 +820,9 @@ 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])
self.view.set_image(to_qimage(self.image, page=True))
if self.blank is not None:
self.blank = self.blank[::-1, ::-1]
self.view.set_image(to_qimage(self.image, self.blank))
self.lines = lines
self.text, spans = layout(lines)
self.view.set_lines(lines, self.text, spans)