Spaces:
Sleeping
Sleeping
| """ | |
| Credit card detection and scale calibration utilities. | |
| This module handles: | |
| - Detecting credit card contour in an image | |
| - Verifying aspect ratio matches standard credit card | |
| - Perspective rectification | |
| - Computing pixels-per-cm scale factor | |
| """ | |
| import cv2 | |
| import numpy as np | |
| from typing import Optional, Tuple, Dict, Any, List | |
| from pathlib import Path | |
| # Import debug observer and drawing functions | |
| from .debug_observer import DebugObserver, draw_contours_overlay, draw_candidates_with_scores | |
| # Import shared visualization constants | |
| from .viz_constants import ( | |
| FONT_FACE, | |
| Color, | |
| StrategyColor, | |
| FontScale, | |
| FontThickness, | |
| Size, | |
| Layout, | |
| ) | |
| # Standard credit card dimensions (ISO/IEC 7810 ID-1) | |
| CARD_WIDTH_MM = 85.60 | |
| CARD_HEIGHT_MM = 53.98 | |
| CARD_WIDTH_CM = CARD_WIDTH_MM / 10 | |
| CARD_HEIGHT_CM = CARD_HEIGHT_MM / 10 | |
| CARD_ASPECT_RATIO = CARD_WIDTH_MM / CARD_HEIGHT_MM # ~1.586 | |
| # Detection parameters | |
| MIN_CARD_AREA_RATIO = 0.01 # Card must be at least 1% of image area | |
| MAX_CARD_AREA_RATIO = 0.5 # Card must be at most 50% of image area | |
| def order_corners(corners: np.ndarray) -> np.ndarray: | |
| """ | |
| Order corners as: top-left, top-right, bottom-right, bottom-left. | |
| Args: | |
| corners: 4x2 array of corner points | |
| Returns: | |
| Ordered 4x2 array of corners | |
| """ | |
| corners = corners.reshape(4, 2).astype(np.float32) | |
| # Sort by sum (x+y): smallest = top-left, largest = bottom-right | |
| s = corners.sum(axis=1) | |
| tl_idx = np.argmin(s) | |
| br_idx = np.argmax(s) | |
| # Sort by diff (y-x): smallest = top-right, largest = bottom-left | |
| d = np.diff(corners, axis=1).flatten() | |
| tr_idx = np.argmin(d) | |
| bl_idx = np.argmax(d) | |
| return np.array([ | |
| corners[tl_idx], | |
| corners[tr_idx], | |
| corners[br_idx], | |
| corners[bl_idx], | |
| ], dtype=np.float32) | |
| def get_quad_dimensions(corners: np.ndarray) -> Tuple[float, float]: | |
| """ | |
| Get width and height of a quadrilateral from ordered corners. | |
| Args: | |
| corners: Ordered 4x2 array (TL, TR, BR, BL) | |
| Returns: | |
| Tuple of (width, height) in pixels | |
| """ | |
| # Width: average of top and bottom edges | |
| top_width = np.linalg.norm(corners[1] - corners[0]) | |
| bottom_width = np.linalg.norm(corners[2] - corners[3]) | |
| width = (top_width + bottom_width) / 2 | |
| # Height: average of left and right edges | |
| left_height = np.linalg.norm(corners[3] - corners[0]) | |
| right_height = np.linalg.norm(corners[2] - corners[1]) | |
| height = (left_height + right_height) / 2 | |
| return width, height | |
| def score_card_candidate( | |
| contour: np.ndarray, | |
| corners: np.ndarray, | |
| image_area: float, | |
| aspect_ratio_tolerance: float = 0.15, | |
| ) -> Tuple[float, Dict[str, Any]]: | |
| """ | |
| Score a quadrilateral candidate for being a credit card. | |
| Since candidates come from minAreaRect, corners are always a perfect | |
| rectangle. Scoring focuses on aspect ratio match and area coverage. | |
| Args: | |
| contour: Original contour (minAreaRect box points) | |
| corners: 4 corner points | |
| image_area: Total image area for relative sizing | |
| aspect_ratio_tolerance: Allowed deviation from standard ratio | |
| Returns: | |
| Tuple of (score, details_dict) | |
| """ | |
| ordered = order_corners(corners) | |
| width, height = get_quad_dimensions(ordered) | |
| area = cv2.contourArea(corners) | |
| details = { | |
| "corners": ordered, | |
| "width": width, | |
| "height": height, | |
| "area": area, | |
| } | |
| # Check area ratio | |
| area_ratio = area / image_area | |
| if area_ratio < MIN_CARD_AREA_RATIO or area_ratio > MAX_CARD_AREA_RATIO: | |
| details["reject_reason"] = f"area_ratio={area_ratio:.3f}" | |
| return 0.0, details | |
| # Safeguard against zero dimensions | |
| if width <= 0 or height <= 0: | |
| details["reject_reason"] = "invalid_dimensions" | |
| return 0.0, details | |
| # Calculate aspect ratio (always use larger/smaller for consistency) | |
| if width > height: | |
| aspect_ratio = width / height | |
| else: | |
| aspect_ratio = height / width | |
| details["aspect_ratio"] = aspect_ratio | |
| # Check aspect ratio against credit card standard | |
| ratio_diff = abs(aspect_ratio - CARD_ASPECT_RATIO) / CARD_ASPECT_RATIO | |
| if ratio_diff > aspect_ratio_tolerance: | |
| details["reject_reason"] = f"aspect_ratio={aspect_ratio:.3f}, expected~{CARD_ASPECT_RATIO:.3f}" | |
| return 0.0, details | |
| # Compute score (higher is better) | |
| # minAreaRect always produces perfect rectangles, so no angle check needed. | |
| # Score based on area size and aspect ratio match. | |
| area_score = min(area_ratio / 0.1, 1.0) # Normalize to max at 10% of image | |
| ratio_score = 1.0 - ratio_diff / aspect_ratio_tolerance | |
| score = 0.5 * area_score + 0.5 * ratio_score | |
| details["score_components"] = { | |
| "area": area_score, | |
| "ratio": ratio_score, | |
| } | |
| return score, details | |
| def find_card_contours( | |
| image: np.ndarray, | |
| image_area: float, | |
| aspect_ratio_tolerance: float = 0.15, | |
| min_score: float = 0.3, | |
| debug_dir: Optional[str] = None, | |
| ) -> List[np.ndarray]: | |
| """ | |
| Find potential card contours using a waterfall of detection strategies. | |
| Strategies are tried in order: Canny → Adaptive → Otsu → Color. | |
| If a strategy produces a candidate scoring above min_score, subsequent | |
| strategies are skipped. | |
| Args: | |
| image: Input BGR image | |
| image_area: Total image area in pixels | |
| aspect_ratio_tolerance: Allowed deviation from standard aspect ratio | |
| min_score: Minimum score to accept a strategy's candidates | |
| debug_dir: Optional directory to save debug images | |
| Returns: | |
| List of 4-point contour approximations from the first successful strategy | |
| """ | |
| # Create debug observer if debug mode enabled | |
| observer = DebugObserver(debug_dir) if debug_dir else None | |
| h, w = image.shape[:2] | |
| min_area = h * w * 0.01 # At least 1% of image | |
| max_area = h * w * 0.5 # At most 50% of image | |
| # Save original image | |
| if observer: | |
| observer.save_stage("01_original", image) | |
| # Convert to grayscale | |
| gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) | |
| if observer: | |
| observer.save_stage("02_grayscale", gray) | |
| # Apply bilateral filter to reduce noise while keeping edges | |
| filtered = cv2.bilateralFilter(gray, 11, 75, 75) | |
| if observer: | |
| observer.save_stage("03_bilateral_filtered", filtered) | |
| def extract_quads(contours, epsilon_factor=0.02, min_rectangularity=0.7, | |
| aspect_tolerance=0.15): | |
| """Extract quadrilaterals from contours using minAreaRect. | |
| Shape constraints: | |
| - Rectangularity (contour_area / rect_area): rejects irregular shapes | |
| - Aspect ratio: rejects rectangles that don't match card proportions | |
| """ | |
| quads = [] | |
| for contour in contours: | |
| contour_area = cv2.contourArea(contour) | |
| if contour_area < min_area or contour_area > max_area: | |
| continue | |
| peri = cv2.arcLength(contour, True) | |
| approx = cv2.approxPolyDP(contour, epsilon_factor * peri, True) | |
| if len(approx) < 4: | |
| continue | |
| rect = cv2.minAreaRect(contour) | |
| box = cv2.boxPoints(rect).astype(np.float32) | |
| rect_area = cv2.contourArea(box) | |
| if rect_area <= 0: | |
| continue | |
| rectangularity = contour_area / rect_area | |
| if rectangularity < min_rectangularity: | |
| continue | |
| (_, _), (bw, bh), _ = rect | |
| if bw <= 0 or bh <= 0: | |
| continue | |
| aspect = max(bw, bh) / min(bw, bh) | |
| if abs(aspect - CARD_ASPECT_RATIO) / CARD_ASPECT_RATIO > aspect_tolerance: | |
| continue | |
| quads.append(box.reshape(4, 1, 2)) | |
| return quads | |
| def dedup_quads(quads, center_threshold=50): | |
| """Remove near-duplicate boxes, keeping the largest when centers overlap. | |
| Two boxes are considered duplicates if their centers are within | |
| center_threshold pixels of each other. | |
| """ | |
| if len(quads) <= 1: | |
| return quads | |
| # Sort by area descending so largest comes first | |
| quads_with_area = [(q, cv2.contourArea(q)) for q in quads] | |
| quads_with_area.sort(key=lambda x: x[1], reverse=True) | |
| kept = [] | |
| for quad, area in quads_with_area: | |
| center = quad.reshape(4, 2).mean(axis=0) | |
| is_dup = False | |
| for kept_quad in kept: | |
| kept_center = kept_quad.reshape(4, 2).mean(axis=0) | |
| dist = np.linalg.norm(center - kept_center) | |
| if dist < center_threshold: | |
| is_dup = True | |
| break | |
| if not is_dup: | |
| kept.append(quad) | |
| return kept | |
| def score_best(quads): | |
| """Return the best score among quads.""" | |
| best = 0.0 | |
| for q in quads: | |
| corners = q.reshape(4, 2) | |
| score, _ = score_card_candidate( | |
| q, corners, image_area, aspect_ratio_tolerance | |
| ) | |
| best = max(best, score) | |
| return best | |
| # --- Waterfall: try strategies in order, stop on first success --- | |
| # Strategy 1: Canny edge detection with various thresholds | |
| canny_candidates = [] | |
| canny_configs = [(20, 60), (30, 100), (50, 150), (75, 200), (100, 250)] | |
| saved_canny_indices = [0, 2, 4] | |
| for idx, (canny_low, canny_high) in enumerate(canny_configs): | |
| edges = cv2.Canny(filtered, canny_low, canny_high) | |
| if idx in saved_canny_indices and observer: | |
| observer.save_stage(f"04_canny_{canny_low}_{canny_high}", edges) | |
| kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) | |
| edges_morphed = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel) | |
| if idx == 2 and observer: | |
| observer.save_stage("07_canny_morphology", edges_morphed) | |
| contours, _ = cv2.findContours(edges_morphed, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) | |
| canny_candidates.extend(extract_quads(contours)) | |
| canny_candidates = dedup_quads(canny_candidates) | |
| if observer and canny_candidates: | |
| observer.draw_and_save("08_canny_contours", image, | |
| draw_contours_overlay, canny_candidates, "Canny Edge Detection", StrategyColor.CANNY) | |
| if canny_candidates and score_best(canny_candidates) >= min_score: | |
| return canny_candidates | |
| # Strategy 2: Adaptive thresholding (for varying lighting) | |
| adaptive_candidates = [] | |
| adaptive_configs = [(11, 2), (21, 5), (31, 10), (51, 10)] | |
| saved_adaptive = [0, 2] | |
| for idx, (block_size, C) in enumerate(adaptive_configs): | |
| thresh = cv2.adaptiveThreshold( | |
| filtered, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, | |
| cv2.THRESH_BINARY, block_size, C | |
| ) | |
| if idx in saved_adaptive and observer: | |
| if idx == 0: | |
| observer.save_stage("09_adaptive_11_2", thresh) | |
| elif idx == 2: | |
| observer.save_stage("10_adaptive_31_10", thresh) | |
| for img in [thresh, 255 - thresh]: | |
| contours, _ = cv2.findContours(img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) | |
| adaptive_candidates.extend(extract_quads(contours)) | |
| adaptive_candidates = dedup_quads(adaptive_candidates) | |
| if observer and adaptive_candidates: | |
| observer.draw_and_save("11_adaptive_contours", image, | |
| draw_contours_overlay, adaptive_candidates, "Adaptive Thresholding", StrategyColor.ADAPTIVE) | |
| if adaptive_candidates and score_best(adaptive_candidates) >= min_score: | |
| return adaptive_candidates | |
| # Strategy 3: Otsu's thresholding | |
| otsu_candidates = [] | |
| _, otsu = cv2.threshold(filtered, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) | |
| if observer: | |
| observer.save_stage("12_otsu_binary", otsu) | |
| otsu_inverted = 255 - otsu | |
| if observer: | |
| observer.save_stage("13_otsu_inverted", otsu_inverted) | |
| for img in [otsu, otsu_inverted]: | |
| kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) | |
| img_morphed = cv2.morphologyEx(img, cv2.MORPH_CLOSE, kernel) | |
| contours, _ = cv2.findContours(img_morphed, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) | |
| otsu_candidates.extend(extract_quads(contours)) | |
| otsu_candidates = dedup_quads(otsu_candidates) | |
| if observer and otsu_candidates: | |
| observer.draw_and_save("14_otsu_contours", image, | |
| draw_contours_overlay, otsu_candidates, "Otsu Thresholding", StrategyColor.OTSU) | |
| if otsu_candidates and score_best(otsu_candidates) >= min_score: | |
| return otsu_candidates | |
| # Strategy 4: Color-based segmentation (gray card on light background) | |
| color_candidates = [] | |
| hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) | |
| sat = hsv[:, :, 1] | |
| if observer: | |
| observer.save_stage("15_hsv_saturation", sat) | |
| _, low_sat_mask = cv2.threshold(sat, 30, 255, cv2.THRESH_BINARY_INV) | |
| if observer: | |
| observer.save_stage("16_low_sat_mask", low_sat_mask) | |
| val = hsv[:, :, 2] | |
| gray_mask = cv2.bitwise_and(low_sat_mask, cv2.inRange(val, 80, 200)) | |
| kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7)) | |
| gray_mask = cv2.morphologyEx(gray_mask, cv2.MORPH_CLOSE, kernel) | |
| gray_mask = cv2.morphologyEx(gray_mask, cv2.MORPH_OPEN, kernel) | |
| if observer: | |
| observer.save_stage("17_gray_mask", gray_mask) | |
| contours, _ = cv2.findContours(gray_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| color_candidates = dedup_quads(extract_quads(contours, epsilon_factor=0.03)) | |
| if observer and color_candidates: | |
| observer.draw_and_save("18_color_contours", image, | |
| draw_contours_overlay, color_candidates, "Color-Based Detection", StrategyColor.COLOR_BASED) | |
| if color_candidates and score_best(color_candidates) >= min_score: | |
| return color_candidates | |
| # No strategy succeeded — return all collected candidates as last resort | |
| all_candidates = canny_candidates + adaptive_candidates + otsu_candidates + color_candidates | |
| if observer and all_candidates: | |
| observer.draw_and_save("19_all_candidates", image, | |
| draw_contours_overlay, all_candidates, "All Candidates (fallback)", StrategyColor.ALL_CANDIDATES) | |
| return all_candidates | |
| def detect_credit_card( | |
| image: np.ndarray, | |
| aspect_ratio_tolerance: float = 0.15, | |
| debug_dir: Optional[str] = None, | |
| ) -> Optional[Dict[str, Any]]: | |
| """ | |
| Detect a credit card in the image. | |
| Args: | |
| image: Input BGR image | |
| aspect_ratio_tolerance: Allowed deviation from standard aspect ratio | |
| debug_dir: Optional directory to save debug images | |
| Returns: | |
| Dictionary containing: | |
| - corners: 4x2 array of corner points (ordered) | |
| - contour: Full contour points | |
| - confidence: Detection confidence score | |
| - width_px, height_px: Detected dimensions | |
| - aspect_ratio: Detected aspect ratio | |
| Or None if no card detected | |
| """ | |
| # Create debug observer if debug mode enabled | |
| observer = DebugObserver(debug_dir) if debug_dir else None | |
| if observer: | |
| print(f"Saving card detection debug images to: {debug_dir}") | |
| h, w = image.shape[:2] | |
| image_area = h * w | |
| # Find candidate contours (waterfall: stops after first successful strategy) | |
| candidates = find_card_contours( | |
| image, image_area=image_area, | |
| aspect_ratio_tolerance=aspect_ratio_tolerance, | |
| debug_dir=debug_dir, | |
| ) | |
| if not candidates: | |
| if observer: | |
| print(" No candidates found") | |
| return None | |
| # Score each candidate | |
| best_score = 0.0 | |
| best_result = None | |
| all_scored = [] | |
| for contour in candidates: | |
| corners = contour.reshape(4, 2) | |
| score, details = score_card_candidate( | |
| contour, corners, image_area, aspect_ratio_tolerance | |
| ) | |
| all_scored.append((corners, score, details)) | |
| if score > best_score: | |
| best_score = score | |
| best_result = details | |
| # Sort by score (descending) and take top 5 | |
| all_scored.sort(key=lambda x: x[1], reverse=True) | |
| top_candidates = all_scored[:5] | |
| # Save scored candidates visualization | |
| if observer and top_candidates: | |
| observer.draw_and_save("20_scored_candidates", image, | |
| draw_candidates_with_scores, top_candidates, "Top 5 Candidates") | |
| if best_result is None or best_score < 0.3: | |
| if observer: | |
| print(f" Best score {best_score:.2f} below threshold 0.3") | |
| return None | |
| # Save final detection | |
| if observer: | |
| final_overlay = image.copy() | |
| corners = best_result["corners"].astype(np.int32) | |
| cv2.polylines(final_overlay, [corners], True, Color.GREEN, Size.CONTOUR_THICK) | |
| # Draw corners | |
| for pt in corners: | |
| cv2.circle(final_overlay, tuple(pt), Size.CORNER_RADIUS + 2, Color.RED, -1) | |
| # Add details text | |
| text_y = Layout.TITLE_Y | |
| details_text = [ | |
| "Final Detection", | |
| f"Score: {best_score:.3f}", | |
| f"Aspect Ratio: {best_result['aspect_ratio']:.3f}", | |
| f"Dimensions: {best_result['width']:.0f}x{best_result['height']:.0f}px", | |
| ] | |
| for text in details_text: | |
| cv2.putText( | |
| final_overlay, text, (Layout.TEXT_OFFSET_X, text_y), | |
| FONT_FACE, FontScale.SUBTITLE, Color.WHITE, | |
| FontThickness.SUBTITLE_OUTLINE, cv2.LINE_AA | |
| ) | |
| cv2.putText( | |
| final_overlay, text, (Layout.TEXT_OFFSET_X, text_y), | |
| FONT_FACE, FontScale.SUBTITLE, Color.GREEN, | |
| FontThickness.SUBTITLE, cv2.LINE_AA | |
| ) | |
| text_y += Layout.LINE_SPACING | |
| observer.save_stage("21_final_detection", final_overlay) | |
| print(f" Saved 21 debug images") | |
| return { | |
| "corners": best_result["corners"], | |
| "contour": best_result["corners"], | |
| "confidence": best_score, | |
| "width_px": best_result["width"], | |
| "height_px": best_result["height"], | |
| "aspect_ratio": best_result["aspect_ratio"], | |
| } | |
| def rectify_card( | |
| image: np.ndarray, | |
| corners: np.ndarray, | |
| output_width: int = 856, | |
| ) -> Tuple[np.ndarray, np.ndarray]: | |
| """ | |
| Apply perspective transform to rectify the card region. | |
| Args: | |
| image: Input BGR image | |
| corners: Ordered 4x2 array of corner points (TL, TR, BR, BL) | |
| output_width: Width of output image (height computed from aspect ratio) | |
| Returns: | |
| Tuple of (rectified_image, transform_matrix) | |
| """ | |
| corners = corners.astype(np.float32) | |
| # Determine if card is in portrait or landscape orientation | |
| width, height = get_quad_dimensions(corners) | |
| if width > height: | |
| # Landscape orientation | |
| out_w = output_width | |
| out_h = int(output_width / CARD_ASPECT_RATIO) | |
| else: | |
| # Portrait orientation (rotated 90°) | |
| out_h = output_width | |
| out_w = int(output_width / CARD_ASPECT_RATIO) | |
| # Destination points | |
| dst = np.array([ | |
| [0, 0], | |
| [out_w - 1, 0], | |
| [out_w - 1, out_h - 1], | |
| [0, out_h - 1], | |
| ], dtype=np.float32) | |
| # Compute perspective transform | |
| M = cv2.getPerspectiveTransform(corners, dst) | |
| # Apply transform | |
| rectified = cv2.warpPerspective(image, M, (out_w, out_h)) | |
| return rectified, M | |
| def compute_scale_factor( | |
| corners: np.ndarray, | |
| ) -> Tuple[float, float]: | |
| """ | |
| Compute pixels-per-cm scale factor from detected card corners. | |
| Args: | |
| corners: Ordered 4x2 array of corner points | |
| Returns: | |
| Tuple of (px_per_cm, confidence) | |
| """ | |
| width_px, height_px = get_quad_dimensions(corners) | |
| # Determine orientation and compute scale | |
| if width_px > height_px: | |
| # Landscape: width corresponds to card width (8.56 cm) | |
| px_per_cm_w = width_px / CARD_WIDTH_CM | |
| px_per_cm_h = height_px / CARD_HEIGHT_CM | |
| else: | |
| # Portrait: width corresponds to card height (5.398 cm) | |
| px_per_cm_w = width_px / CARD_HEIGHT_CM | |
| px_per_cm_h = height_px / CARD_WIDTH_CM | |
| # Average the two estimates | |
| px_per_cm = (px_per_cm_w + px_per_cm_h) / 2 | |
| # Confidence based on consistency between width and height estimates | |
| consistency = 1.0 - abs(px_per_cm_w - px_per_cm_h) / max(px_per_cm_w, px_per_cm_h) | |
| confidence = max(0.0, min(1.0, consistency)) | |
| return px_per_cm, confidence | |