import cv2 import numpy as np from rembg import remove def remove_background_and_crop(image_bytes: bytes) -> np.ndarray: """ Production-grade card isolation: 1. Use rembg to remove background (produces alpha mask) 2. Analyze contours in the alpha mask 3. Keep ONLY the most card-like (rectangular) contour 4. Discard all other objects (coins, clips, fingers, etc.) 5. Return a tightly cropped BGRA image with clean transparent background Works for both vertical and horizontal card orientations. """ # Step 1: Run rembg with alpha matting for clean edges bg_removed_bytes = remove( image_bytes, alpha_matting=True, alpha_matting_foreground_threshold=240, alpha_matting_background_threshold=10, alpha_matting_erode_size=10 ) # Decode result (BGRA) nparr = np.frombuffer(bg_removed_bytes, np.uint8) img = cv2.imdecode(nparr, cv2.IMREAD_UNCHANGED) if img is None: raise ValueError("Could not decode image") if len(img.shape) != 3 or img.shape[2] != 4: # No alpha channel — return as is return img # Step 2: Extract alpha and find contours alpha = img[:, :, 3] _, thresh = cv2.threshold(alpha, 127, 255, cv2.THRESH_BINARY) # Morphological close to fill small holes kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel, iterations=3) contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return img # Step 3: Score each contour for "card-likeness" # A card is: (a) the largest object, (b) very rectangular img_area = img.shape[0] * img.shape[1] best_contour = None best_score = -1 for contour in contours: area = cv2.contourArea(contour) # Skip tiny contours (noise) if area < img_area * 0.05: continue # Fit a minimum area rectangle rect = cv2.minAreaRect(contour) box = cv2.boxPoints(rect) rect_area = cv2.contourArea(box) if rect_area == 0: continue # Rectangularity score: how well the contour fills its bounding rectangle # A perfect rectangle scores 1.0; a circle scores ~0.78 rectangularity = area / rect_area # Check aspect ratio — standard credit card is 85.6mm x 53.98mm ≈ 1.586 # Allow range from 1.3 to 1.8 (and its inverse for vertical cards) w_rect, h_rect = rect[1] if min(w_rect, h_rect) == 0: continue aspect = max(w_rect, h_rect) / min(w_rect, h_rect) # Card-like aspect ratio bonus if 1.2 <= aspect <= 1.9: aspect_score = 1.0 else: aspect_score = 0.3 # Penalize non-card shapes # Combined score: weighted by area, rectangularity, and aspect ratio score = (area / img_area) * rectangularity * aspect_score if score > best_score: best_score = score best_contour = contour if best_contour is None: # Fallback: use the largest contour best_contour = max(contours, key=cv2.contourArea) # Step 4: Create a clean mask from ONLY the best contour clean_mask = np.zeros(img.shape[:2], dtype=np.uint8) cv2.drawContours(clean_mask, [best_contour], -1, 255, -1) # Step 5: Apply the clean mask to the alpha channel # This removes all non-card objects new_alpha = cv2.bitwise_and(alpha, clean_mask) img[:, :, 3] = new_alpha # Step 6: Tight crop around the card only _, crop_thresh = cv2.threshold(new_alpha, 10, 255, cv2.THRESH_BINARY) crop_contours, _ = cv2.findContours(crop_thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if crop_contours: largest = max(crop_contours, key=cv2.contourArea) x, y, w, h = cv2.boundingRect(largest) # Minimal padding (just 2px to avoid border clipping) pad = 2 x1 = max(0, x - pad) y1 = max(0, y - pad) x2 = min(img.shape[1], x + w + pad) y2 = min(img.shape[0], y + h + pad) return img[y1:y2, x1:x2] return img