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:
+29
-15
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user