""" Card image processing: extract card shape from background and reduce dirt/noise. Uses OpenCV Otsu thresholding + morphological cleanup to isolate the card shape regardless of background colour. Works at reduced resolution for speed, then upscales the mask to apply to the original image. """ import io import numpy as np import cv2 from PIL import Image, ImageFilter _PROCESS_SCALE = 0.25 # work at 25% resolution, upscale result _MIN_COVERAGE = 0.05 # fail if detected card covers < 5% of image def process_card_photo(pil_image: Image.Image) -> Image.Image: """ Return the card isolated on a white background, lightly denoised. Falls back silently to the original image if extraction fails. """ try: mask = _find_card_mask(pil_image) if mask is None: return pil_image arr = np.array(pil_image.convert('RGB')) white = np.full_like(arr, 255) # mask is (H, W) bool → broadcast over RGB channels result_arr = np.where(mask[:, :, np.newaxis], arr, white).astype(np.uint8) result = Image.fromarray(result_arr) # Mild median filter to soften dirt spots result = result.filter(ImageFilter.MedianFilter(size=3)) # Crop to card bounding box mask_img = Image.fromarray((mask * 255).astype(np.uint8)) bbox = mask_img.getbbox() if bbox: result = result.crop(bbox) return result except Exception: return pil_image def _find_card_mask(pil_image: Image.Image) -> np.ndarray | None: """ Return a boolean mask at original resolution covering the card, or None. Uses Otsu threshold on the L channel of LAB colour space so it adapts to any background brightness. """ w, h = pil_image.size small_w = max(int(w * _PROCESS_SCALE), 80) small_h = max(int(h * _PROCESS_SCALE), 80) # Resize for speed small = pil_image.resize((small_w, small_h), Image.LANCZOS).convert('RGB') bgr = cv2.cvtColor(np.array(small), cv2.COLOR_RGB2BGR) # L channel of LAB is perceptually uniform luminance lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB) L = lab[:, :, 0] # Otsu threshold: automatically finds the best separation _, mask = cv2.threshold(L, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) mask = mask.astype(bool) coverage = mask.mean() if not (_MIN_COVERAGE < coverage < (1 - _MIN_COVERAGE)): return None # Morphological cleanup: open removes isolated specks, close fills gaps kernel_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) kernel_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11)) m = mask.astype(np.uint8) * 255 m = cv2.morphologyEx(m, cv2.MORPH_OPEN, kernel_open) m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, kernel_close) # Keep only the largest connected component (the card itself) n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8) if n_labels <= 1: return None # stats[0] is background; find largest foreground component largest = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA]) m = ((labels == largest) * 255).astype(np.uint8) # Slight dilation to recover edges lost during thresholding kernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) m = cv2.dilate(m, kernel_dilate) # Upscale mask to original resolution mask_full = cv2.resize(m, (w, h), interpolation=cv2.INTER_NEAREST) return mask_full > 127