"""Conservative paper masking. Keep the canvas and every pixel inside the paper.""" from pathlib import Path import cv2 import numpy as np def whiten_background(image): height, width = image.shape[:2] scale = min(1.0, 1000.0 / max(height, width)) small = cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY) border = np.concatenate((gray[0], gray[-1], gray[:, 0], gray[:, -1])) background = float(np.median(border)) # White surroundings already need no correction. Never search inside a # white document for an isolated text box or logo to keep instead. if background >= 235 or np.percentile(border, 75) - np.percentile(border, 25) > 55: return image threshold, _ = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) mask = np.uint8(gray > max(threshold, background + 20)) * 255 mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8)) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] area = gray.size for contour in contours: contour_area = cv2.contourArea(contour) if not 0.015 * area < contour_area < 0.985 * area: continue perimeter = cv2.arcLength(contour, True) polygon = cv2.approxPolyDP(contour, 0.018 * perimeter, True) if len(polygon) != 4 or not cv2.isContourConvex(polygon): continue # Use the quadrilateral only to recognize a sheet, then keep its full # contour hull so rounded or slightly uneven edges cannot lose content. polygon = cv2.convexHull(contour) paper = np.zeros_like(gray) cv2.fillPoly(paper, [polygon], 255) inside = cv2.erode(paper, np.ones((7, 7), np.uint8)) > 0 ring = (cv2.dilate(paper, np.ones((9, 9), np.uint8)) > 0) & (paper == 0) if inside.sum() < 100 or ring.sum() < 50: continue paper_light = float(np.median(gray[inside])) # A paper edge must separate a mostly light sheet from darker scanner # backing all around it. Ambiguous photos/pages are left untouched. if paper_light < background + 30 or np.mean(gray[ring] < paper_light - 20) < 0.85: continue candidates.append(polygon) if not candidates: return image keep_small = np.zeros_like(gray) cv2.fillPoly(keep_small, candidates, 255) # Do not erase another significant object that we could not classify. outside = (keep_small == 0) if np.mean(gray[outside] > background + 30) > 0.04: return image keep = np.zeros((height, width), dtype=np.uint8) polygons = [np.rint(polygon / scale).astype(np.int32) for polygon in candidates] cv2.fillPoly(keep, polygons, 255) # Rounding/downsampling uncertainty grows with the original scan DPI. padding = max(2, int(np.ceil(2 / scale))) keep = cv2.dilate(keep, np.ones((2 * padding + 1, 2 * padding + 1), np.uint8)) result = image.copy() result[keep == 0] = 255 return result def clean_scan_background(input_path: Path, output_path: Path): image = cv2.imread(str(input_path), cv2.IMREAD_COLOR) if image is None: raise RuntimeError("Scan-Bild konnte nicht gelesen werden") if not cv2.imwrite(str(output_path), whiten_background(image)): raise RuntimeError("Bereinigtes Scan-Bild konnte nicht gespeichert werden")