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)" IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.tif *.tiff *.bmp *.webp)"
def to_qimage(img, page=False): def to_qimage(img, transparent=None):
"""BGR or grayscale uint8 array -> QImage (a copy). With `page`, exact-white pixels """BGR or grayscale uint8 array -> QImage (a copy); pixels where the bool mask
(the uncovered corners of a stitched page) become transparent, so when the view `transparent` is set get alpha 0."""
rotates the page to level it those corners show the window background through."""
img = np.ascontiguousarray(img) img = np.ascontiguousarray(img)
h, w = img.shape[:2] h, w = img.shape[:2]
if not page: if transparent is None:
fmt = QImage.Format_Grayscale8 if img.ndim == 2 else QImage.Format_BGR888 fmt = QImage.Format_Grayscale8 if img.ndim == 2 else QImage.Format_BGR888
return QImage(img.data, w, h, img.strides[0], fmt).copy() 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) 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[transparent, 3] = 0
bgra[uncovered, 3] = 0
bgra = np.ascontiguousarray(bgra)
return QImage(bgra.data, w, h, bgra.strides[0], QImage.Format_ARGB32).copy() 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): def imread(path):
"""cv2.imread that also takes non-ASCII paths on Windows.""" """cv2.imread that also takes non-ASCII paths on Windows."""
return cv2.imdecode(np.fromfile(path, np.uint8), cv2.IMREAD_COLOR) 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() self.start_event, self.stop_event = threading.Event(), threading.Event()
def publish(self, name, bgr): 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): def set_status(self, **kw):
self.state = {**self.state, **kw} self.state = {**self.state, **kw}
@@ -567,6 +575,7 @@ class MainWindow(QMainWindow):
self.opts = opts self.opts = opts
self.ocr = Ocr() self.ocr = Ocr()
self.image, self.path, self.lines, self.text = None, None, [], "" 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.page_title = "Page"
self.ocr_thread, self.scan_thread, self.scanning = None, None, False 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 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): def on_result(self, path):
self.scanning = False self.scanning = False
self.load(path) self.load(path, scanned=True)
# ---- images ---- # ---- images ----
def load(self, path): def load(self, path, scanned=False):
try: try:
img = imread(path) img = imread(path)
except OSError: except OSError:
@@ -733,15 +742,18 @@ class MainWindow(QMainWindow):
log_error(f"cannot read {path}") log_error(f"cannot read {path}")
QMessageBox.warning(self, "myScan", f"cannot read {path}") QMessageBox.warning(self, "myScan", f"cannot read {path}")
return return
self.set_page(img, path) self.set_page(img, path, scanned)
self.run_ocr() 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.image, self.path, self.lines, self.text = img, path, [], ""
self.blank = unscanned(img) if scanned else None
self.page_id += 1 self.page_id += 1
self.view.set_lines([]) self.view.set_lines([])
self.view.angle = 0.0 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" 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]})"
self.page_head.setText(self.page_title) self.page_head.setText(self.page_title)
@@ -808,7 +820,9 @@ class MainWindow(QMainWindow):
return return
if flipped: # the boxes are on the page turned the right way up if flipped: # the boxes are on the page turned the right way up
self.image = np.ascontiguousarray(self.image[::-1, ::-1]) 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.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)