| import os |
| import cv2 |
| import numpy as np |
| import gradio as gr |
|
|
| import zipfile |
| import tempfile |
| import shutil |
| from pathlib import Path |
|
|
| from fastapi import FastAPI, UploadFile, File |
| from fastapi.responses import Response, JSONResponse, FileResponse |
|
|
|
|
| |
| |
| |
|
|
| CARD_ASPECT_RATIO = 85.60 / 53.98 |
|
|
| MIN_CARD_AREA_RATIO = 0.020 |
| MAX_CARD_AREA_RATIO = 0.92 |
|
|
| |
| |
| |
| MIN_CARD_LONG_SIDE_RATIO = 0.22 |
|
|
| |
| MIN_CARD_SHORT_SIDE_RATIO = 0.10 |
|
|
| CARD_MARGIN = 0.030 |
|
|
| MAX_DIMENSION = 1600 |
|
|
| MIN_OUTPUT_WIDTH = 100 |
| MIN_OUTPUT_HEIGHT = 60 |
|
|
| MAX_OUTPUT_WIDTH = 2500 |
| MAX_OUTPUT_HEIGHT = 1600 |
|
|
|
|
| |
| |
| |
|
|
| MAX_CANDIDATES = 2500 |
|
|
| MAX_FALLBACK_CONTOURS = 100 |
|
|
| EARLY_ACCEPT_SCORE = 0.91 |
|
|
| FAST_PATH_ENABLED = True |
| COLOR_PATH_ENABLED = True |
|
|
| FAST_ACCEPT_SCORE = 0.60 |
|
|
| FAST_MAX_DIMENSION = 1200 |
|
|
|
|
| |
| |
| |
|
|
| SUPPORTED_EXTENSIONS = { |
| ".jpg", |
| ".jpeg", |
| ".png", |
| ".bmp", |
| ".webp", |
| ".tif", |
| ".tiff" |
| } |
|
|
|
|
| |
| |
| |
|
|
| app = FastAPI( |
| title="Egyptian ID Card Cropper" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def distance(a, b): |
|
|
| return float( |
| np.linalg.norm( |
| a - b |
| ) |
| ) |
|
|
|
|
| def order_points(points): |
|
|
| pts = np.asarray( |
| points, |
| dtype=np.float32 |
| ).reshape(4, 2) |
|
|
| center = np.mean( |
| pts, |
| axis=0 |
| ) |
|
|
| angles = np.arctan2( |
| pts[:, 1] - center[1], |
| pts[:, 0] - center[0] |
| ) |
|
|
| pts = pts[ |
| np.argsort(angles) |
| ] |
|
|
| sums = ( |
| pts[:, 0] + |
| pts[:, 1] |
| ) |
|
|
| tl_index = np.argmin( |
| sums |
| ) |
|
|
| pts = np.roll( |
| pts, |
| -tl_index, |
| axis=0 |
| ) |
|
|
| remaining = pts[1:] |
|
|
| br_index = np.argmax( |
| remaining[:, 0] + |
| remaining[:, 1] |
| ) |
|
|
| br = remaining[ |
| br_index |
| ] |
|
|
| others = [ |
| p |
| for i, p in enumerate(remaining) |
| if i != br_index |
| ] |
|
|
| others = sorted( |
| others, |
| key=lambda p: p[1] |
| ) |
|
|
| tr = others[0] |
| bl = others[1] |
|
|
| tl = pts[0] |
|
|
| return np.array( |
| [ |
| tl, |
| tr, |
| br, |
| bl |
| ], |
| dtype=np.float32 |
| ) |
|
|
|
|
| def polygon_angle(a, b, c): |
|
|
| ba = a - b |
| bc = c - b |
|
|
| denominator = ( |
| np.linalg.norm(ba) * |
| np.linalg.norm(bc) |
| ) |
|
|
| if denominator <= 1e-8: |
| return 0.0 |
|
|
| cosine = ( |
| np.dot(ba, bc) / |
| denominator |
| ) |
|
|
| cosine = np.clip( |
| cosine, |
| -1.0, |
| 1.0 |
| ) |
|
|
| return float( |
| np.degrees( |
| np.arccos(cosine) |
| ) |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def touches_image_border( |
| quad, |
| image_shape, |
| tolerance=3 |
| ): |
|
|
| h, w = image_shape[:2] |
|
|
| points = order_points( |
| quad |
| ) |
|
|
| for x, y in points: |
|
|
| if x <= tolerance: |
| return True |
|
|
| if y <= tolerance: |
| return True |
|
|
| if x >= w - 1 - tolerance: |
| return True |
|
|
| if y >= h - 1 - tolerance: |
| return True |
|
|
| return False |
|
|
|
|
| |
| |
| |
|
|
| def quad_geometry(quad): |
|
|
| ordered = order_points( |
| quad |
| ) |
|
|
| tl, tr, br, bl = ordered |
|
|
| width_top = distance( |
| tl, |
| tr |
| ) |
|
|
| width_bottom = distance( |
| bl, |
| br |
| ) |
|
|
| height_left = distance( |
| tl, |
| bl |
| ) |
|
|
| height_right = distance( |
| tr, |
| br |
| ) |
|
|
| width = ( |
| width_top + |
| width_bottom |
| ) / 2.0 |
|
|
| height = ( |
| height_left + |
| height_right |
| ) / 2.0 |
|
|
| if width <= 0 or height <= 0: |
| return None |
|
|
| ratio = ( |
| max(width, height) / |
| min(width, height) |
| ) |
|
|
| return { |
| "points": ordered, |
| "width": width, |
| "height": height, |
| "ratio": ratio, |
| "area": cv2.contourArea( |
| ordered |
| ) |
| } |
|
|
|
|
| |
| |
| |
|
|
| def is_reasonable_card( |
| quad, |
| image_shape |
| ): |
|
|
| h, w = image_shape[:2] |
|
|
| image_area = float( |
| h * w |
| ) |
|
|
| geometry = quad_geometry( |
| quad |
| ) |
|
|
| if geometry is None: |
| return False |
|
|
| area = geometry["area"] |
|
|
| area_ratio = ( |
| area / |
| image_area |
| ) |
|
|
| |
| |
| |
|
|
| if area_ratio < MIN_CARD_AREA_RATIO: |
| return False |
|
|
| if area_ratio > MAX_CARD_AREA_RATIO: |
| return False |
|
|
| |
| |
| |
|
|
| long_side = max( |
| geometry["width"], |
| geometry["height"] |
| ) |
|
|
| short_side = min( |
| geometry["width"], |
| geometry["height"] |
| ) |
|
|
| |
| |
| |
| |
| |
| |
|
|
| if long_side < ( |
| max(w, h) * |
| MIN_CARD_LONG_SIDE_RATIO |
| ): |
| return False |
|
|
| if short_side < ( |
| min(w, h) * |
| MIN_CARD_SHORT_SIDE_RATIO |
| ): |
| return False |
|
|
| |
| |
| |
|
|
| ratio = geometry["ratio"] |
|
|
| if ratio < 1.15: |
| return False |
|
|
| if ratio > 2.30: |
| return False |
|
|
| points = geometry["points"] |
|
|
| |
| |
| |
|
|
| if touches_image_border( |
| points, |
| image_shape, |
| tolerance=3 |
| ): |
| return False |
|
|
| tl, tr, br, bl = points |
|
|
| |
| |
| |
|
|
| angles = [ |
| polygon_angle( |
| tl, |
| tr, |
| br |
| ), |
| polygon_angle( |
| tr, |
| br, |
| bl |
| ), |
| polygon_angle( |
| br, |
| bl, |
| tl |
| ), |
| polygon_angle( |
| bl, |
| tl, |
| tr |
| ) |
| ] |
|
|
| for angle in angles: |
|
|
| if angle < 35: |
| return False |
|
|
| if angle > 145: |
| return False |
|
|
| return True |
|
|
|
|
| |
| |
| |
|
|
| def score_card( |
| quad, |
| contour_area, |
| image_area |
| ): |
|
|
| geometry = quad_geometry( |
| quad |
| ) |
|
|
| if geometry is None: |
| return -1 |
|
|
| ratio = geometry["ratio"] |
|
|
| |
| |
| |
|
|
| ratio_error = abs( |
| ratio - |
| CARD_ASPECT_RATIO |
| ) |
|
|
| ratio_score = max( |
| 0.0, |
| 1.0 - |
| ratio_error / 0.70 |
| ) |
|
|
| |
| |
| |
|
|
| area_ratio = ( |
| contour_area / |
| image_area |
| ) |
|
|
| area_score = min( |
| area_ratio / 0.30, |
| 1.0 |
| ) |
|
|
| |
| |
| |
|
|
| long_side = max( |
| geometry["width"], |
| geometry["height"] |
| ) |
|
|
| |
| |
| h = np.sqrt(image_area) |
|
|
| size_ratio = ( |
| long_side / |
| max(h, 1) |
| ) |
|
|
| size_score = min( |
| size_ratio / 1.2, |
| 1.0 |
| ) |
|
|
| |
| |
| |
|
|
| points = geometry["points"] |
|
|
| tl, tr, br, bl = points |
|
|
| angles = [ |
| polygon_angle( |
| tl, |
| tr, |
| br |
| ), |
| polygon_angle( |
| tr, |
| br, |
| bl |
| ), |
| polygon_angle( |
| br, |
| bl, |
| tl |
| ), |
| polygon_angle( |
| bl, |
| tl, |
| tr |
| ) |
| ] |
|
|
| angle_error = np.mean( |
| [ |
| abs( |
| angle - 90.0 |
| ) |
| for angle in angles |
| ] |
| ) |
|
|
| angle_score = max( |
| 0.0, |
| 1.0 - |
| angle_error / 50.0 |
| ) |
|
|
| |
| |
| |
|
|
| score = ( |
| ratio_score * 0.50 + |
| area_score * 0.20 + |
| angle_score * 0.15 + |
| size_score * 0.15 |
| ) |
|
|
| return float(score) |
|
|
|
|
| |
| |
| |
|
|
| def fast_card_detection(image): |
|
|
| h, w = image.shape[:2] |
|
|
| scale = 1.0 |
|
|
| if max(h, w) > FAST_MAX_DIMENSION: |
|
|
| scale = ( |
| FAST_MAX_DIMENSION / |
| float(max(h, w)) |
| ) |
|
|
| work = cv2.resize( |
| image, |
| None, |
| fx=scale, |
| fy=scale, |
| interpolation=cv2.INTER_AREA |
| ) |
|
|
| else: |
|
|
| work = image.copy() |
|
|
| gray = cv2.cvtColor( |
| work, |
| cv2.COLOR_BGR2GRAY |
| ) |
|
|
| gray = cv2.GaussianBlur( |
| gray, |
| (3, 3), |
| 0 |
| ) |
|
|
| candidates = [] |
|
|
| |
| |
| |
|
|
| for low, high in [ |
| (35, 110), |
| (50, 150), |
| (70, 180) |
| ]: |
|
|
| edges = cv2.Canny( |
| gray, |
| low, |
| high |
| ) |
|
|
| kernel = cv2.getStructuringElement( |
| cv2.MORPH_RECT, |
| (5, 5) |
| ) |
|
|
| edges = cv2.morphologyEx( |
| edges, |
| cv2.MORPH_CLOSE, |
| kernel, |
| iterations=1 |
| ) |
|
|
| contours, _ = cv2.findContours( |
| edges, |
| cv2.RETR_EXTERNAL, |
| cv2.CHAIN_APPROX_SIMPLE |
| ) |
|
|
| candidates.extend( |
| contours |
| ) |
|
|
| |
| |
| |
|
|
| _, binary = cv2.threshold( |
| gray, |
| 0, |
| 255, |
| cv2.THRESH_BINARY + |
| cv2.THRESH_OTSU |
| ) |
|
|
| contours, _ = cv2.findContours( |
| binary, |
| cv2.RETR_EXTERNAL, |
| cv2.CHAIN_APPROX_SIMPLE |
| ) |
|
|
| candidates.extend( |
| contours |
| ) |
|
|
| |
| |
| |
|
|
| inverted = cv2.bitwise_not( |
| binary |
| ) |
|
|
| contours, _ = cv2.findContours( |
| inverted, |
| cv2.RETR_EXTERNAL, |
| cv2.CHAIN_APPROX_SIMPLE |
| ) |
|
|
| candidates.extend( |
| contours |
| ) |
|
|
| image_area = float( |
| work.shape[0] * |
| work.shape[1] |
| ) |
|
|
| candidates = sorted( |
| candidates, |
| key=cv2.contourArea, |
| reverse=True |
| )[:80] |
|
|
| best_quad = None |
| best_score = -1 |
|
|
| for contour in candidates: |
|
|
| area = cv2.contourArea( |
| contour |
| ) |
|
|
| if area <= 0: |
| continue |
|
|
| area_ratio = ( |
| area / |
| image_area |
| ) |
|
|
| if area_ratio < MIN_CARD_AREA_RATIO: |
| continue |
|
|
| if area_ratio > MAX_CARD_AREA_RATIO: |
| continue |
|
|
| perimeter = cv2.arcLength( |
| contour, |
| True |
| ) |
|
|
| if perimeter <= 0: |
| continue |
|
|
| for epsilon_factor in [ |
| 0.012, |
| 0.020, |
| 0.030 |
| ]: |
|
|
| approx = cv2.approxPolyDP( |
| contour, |
| epsilon_factor * |
| perimeter, |
| True |
| ) |
|
|
| if len(approx) != 4: |
| continue |
|
|
| quad = ( |
| approx |
| .reshape(4, 2) |
| .astype(np.float32) |
| ) |
|
|
| if not is_reasonable_card( |
| quad, |
| work.shape |
| ): |
| continue |
|
|
| score = score_card( |
| quad, |
| area, |
| image_area |
| ) |
|
|
| if score > best_score: |
|
|
| best_score = score |
|
|
| best_quad = ( |
| quad.copy() |
| ) |
|
|
| if ( |
| best_score >= |
| EARLY_ACCEPT_SCORE |
| ): |
| break |
|
|
| if ( |
| best_score >= |
| EARLY_ACCEPT_SCORE |
| ): |
| break |
|
|
| if ( |
| best_quad is None or |
| best_score < FAST_ACCEPT_SCORE |
| ): |
| return None |
|
|
| if scale != 1.0: |
|
|
| best_quad = ( |
| best_quad / |
| scale |
| ) |
|
|
| best_quad[:, 0] = np.clip( |
| best_quad[:, 0], |
| 0, |
| w - 1 |
| ) |
|
|
| best_quad[:, 1] = np.clip( |
| best_quad[:, 1], |
| 0, |
| h - 1 |
| ) |
|
|
| return order_points( |
| best_quad |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def color_card_detection(image): |
|
|
| h, w = image.shape[:2] |
|
|
| scale = 1.0 |
|
|
| if max(h, w) > FAST_MAX_DIMENSION: |
|
|
| scale = ( |
| FAST_MAX_DIMENSION / |
| float(max(h, w)) |
| ) |
|
|
| work = cv2.resize( |
| image, |
| None, |
| fx=scale, |
| fy=scale, |
| interpolation=cv2.INTER_AREA |
| ) |
|
|
| else: |
|
|
| work = image.copy() |
|
|
| hsv = cv2.cvtColor( |
| work, |
| cv2.COLOR_BGR2HSV |
| ) |
|
|
| lower = np.array( |
| [0, 0, 70], |
| dtype=np.uint8 |
| ) |
|
|
| upper = np.array( |
| [179, 150, 255], |
| dtype=np.uint8 |
| ) |
|
|
| mask = cv2.inRange( |
| hsv, |
| lower, |
| upper |
| ) |
|
|
| kernel = cv2.getStructuringElement( |
| cv2.MORPH_RECT, |
| (9, 9) |
| ) |
|
|
| mask = cv2.morphologyEx( |
| mask, |
| cv2.MORPH_CLOSE, |
| kernel, |
| iterations=2 |
| ) |
|
|
| mask = cv2.morphologyEx( |
| mask, |
| cv2.MORPH_OPEN, |
| kernel, |
| iterations=1 |
| ) |
|
|
| contours, _ = cv2.findContours( |
| mask, |
| cv2.RETR_EXTERNAL, |
| cv2.CHAIN_APPROX_SIMPLE |
| ) |
|
|
| image_area = float( |
| work.shape[0] * |
| work.shape[1] |
| ) |
|
|
| contours = sorted( |
| contours, |
| key=cv2.contourArea, |
| reverse=True |
| )[:50] |
|
|
| best_quad = None |
| best_score = -1 |
|
|
| for contour in contours: |
|
|
| area = cv2.contourArea( |
| contour |
| ) |
|
|
| if area <= 0: |
| continue |
|
|
| area_ratio = ( |
| area / |
| image_area |
| ) |
|
|
| if area_ratio < MIN_CARD_AREA_RATIO: |
| continue |
|
|
| if area_ratio > MAX_CARD_AREA_RATIO: |
| continue |
|
|
| perimeter = cv2.arcLength( |
| contour, |
| True |
| ) |
|
|
| if perimeter <= 0: |
| continue |
|
|
| for epsilon in [ |
| 0.01, |
| 0.02, |
| 0.03, |
| 0.04 |
| ]: |
|
|
| approx = cv2.approxPolyDP( |
| contour, |
| epsilon * perimeter, |
| True |
| ) |
|
|
| if len(approx) != 4: |
| continue |
|
|
| quad = ( |
| approx |
| .reshape(4, 2) |
| .astype(np.float32) |
| ) |
|
|
| if not is_reasonable_card( |
| quad, |
| work.shape |
| ): |
| continue |
|
|
| score = score_card( |
| quad, |
| area, |
| image_area |
| ) |
|
|
| if score > best_score: |
|
|
| best_score = score |
|
|
| best_quad = ( |
| quad.copy() |
| ) |
|
|
| if ( |
| best_score >= |
| EARLY_ACCEPT_SCORE |
| ): |
| break |
|
|
| if ( |
| best_score >= |
| EARLY_ACCEPT_SCORE |
| ): |
| break |
|
|
| if ( |
| best_quad is None or |
| best_score < FAST_ACCEPT_SCORE |
| ): |
| return None |
|
|
| if scale != 1.0: |
|
|
| best_quad = ( |
| best_quad / |
| scale |
| ) |
|
|
| best_quad[:, 0] = np.clip( |
| best_quad[:, 0], |
| 0, |
| w - 1 |
| ) |
|
|
| best_quad[:, 1] = np.clip( |
| best_quad[:, 1], |
| 0, |
| h - 1 |
| ) |
|
|
| return order_points( |
| best_quad |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def get_contours_from_edges(gray): |
|
|
| candidates = [] |
|
|
| blur = cv2.GaussianBlur( |
| gray, |
| (5, 5), |
| 0 |
| ) |
|
|
| canny_settings = [ |
| (20, 80), |
| (30, 100), |
| (40, 120), |
| (50, 150), |
| (70, 180), |
| (90, 220), |
| (110, 240) |
| ] |
|
|
| for low, high in canny_settings: |
|
|
| edges = cv2.Canny( |
| blur, |
| low, |
| high |
| ) |
|
|
| for kernel_size in [ |
| 3, |
| 5, |
| 7, |
| 9 |
| ]: |
|
|
| kernel = cv2.getStructuringElement( |
| cv2.MORPH_RECT, |
| ( |
| kernel_size, |
| kernel_size |
| ) |
| ) |
|
|
| closed = cv2.morphologyEx( |
| edges, |
| cv2.MORPH_CLOSE, |
| kernel, |
| iterations=1 |
| ) |
|
|
| contours, _ = cv2.findContours( |
| closed, |
| cv2.RETR_LIST, |
| cv2.CHAIN_APPROX_SIMPLE |
| ) |
|
|
| candidates.extend( |
| contours |
| ) |
|
|
| return candidates |
|
|
|
|
| |
| |
| |
|
|
| def get_threshold_contours(gray): |
|
|
| candidates = [] |
|
|
| blur = cv2.GaussianBlur( |
| gray, |
| (5, 5), |
| 0 |
| ) |
|
|
| _, binary = cv2.threshold( |
| blur, |
| 0, |
| 255, |
| cv2.THRESH_BINARY + |
| cv2.THRESH_OTSU |
| ) |
|
|
| for image in [ |
| binary, |
| cv2.bitwise_not( |
| binary |
| ) |
| ]: |
|
|
| kernel = cv2.getStructuringElement( |
| cv2.MORPH_RECT, |
| (5, 5) |
| ) |
|
|
| image = cv2.morphologyEx( |
| image, |
| cv2.MORPH_CLOSE, |
| kernel, |
| iterations=2 |
| ) |
|
|
| contours, _ = cv2.findContours( |
| image, |
| cv2.RETR_LIST, |
| cv2.CHAIN_APPROX_SIMPLE |
| ) |
|
|
| candidates.extend( |
| contours |
| ) |
|
|
| for block_size, c in [ |
| (21, 5), |
| (31, 7), |
| (41, 9), |
| (51, 11) |
| ]: |
|
|
| adaptive = cv2.adaptiveThreshold( |
| blur, |
| 255, |
| cv2.ADAPTIVE_THRESH_GAUSSIAN_C, |
| cv2.THRESH_BINARY, |
| block_size, |
| c |
| ) |
|
|
| for image in [ |
| adaptive, |
| cv2.bitwise_not( |
| adaptive |
| ) |
| ]: |
|
|
| contours, _ = cv2.findContours( |
| image, |
| cv2.RETR_LIST, |
| cv2.CHAIN_APPROX_SIMPLE |
| ) |
|
|
| candidates.extend( |
| contours |
| ) |
|
|
| return candidates |
|
|
|
|
| |
| |
| |
|
|
| def find_card_contour(image): |
|
|
| original = image.copy() |
|
|
| |
| |
| |
|
|
| if FAST_PATH_ENABLED: |
|
|
| try: |
|
|
| fast_quad = fast_card_detection( |
| original |
| ) |
|
|
| if fast_quad is not None: |
| return fast_quad |
|
|
| except Exception as e: |
|
|
| print( |
| "FAST PATH ERROR:", |
| repr(e) |
| ) |
|
|
| |
| |
| |
|
|
| if COLOR_PATH_ENABLED: |
|
|
| try: |
|
|
| color_quad = color_card_detection( |
| original |
| ) |
|
|
| if color_quad is not None: |
| return color_quad |
|
|
| except Exception as e: |
|
|
| print( |
| "COLOR PATH ERROR:", |
| repr(e) |
| ) |
|
|
| |
| |
| |
|
|
| h, w = original.shape[:2] |
|
|
| scale = 1.0 |
|
|
| if max(h, w) > MAX_DIMENSION: |
|
|
| scale = ( |
| MAX_DIMENSION / |
| float(max(h, w)) |
| ) |
|
|
| work = cv2.resize( |
| original, |
| None, |
| fx=scale, |
| fy=scale, |
| interpolation=cv2.INTER_AREA |
| ) |
|
|
| else: |
|
|
| work = original.copy() |
|
|
| gray = cv2.cvtColor( |
| work, |
| cv2.COLOR_BGR2GRAY |
| ) |
|
|
| clahe = cv2.createCLAHE( |
| clipLimit=2.0, |
| tileGridSize=(8, 8) |
| ) |
|
|
| enhanced = clahe.apply( |
| gray |
| ) |
|
|
| candidates = [] |
|
|
| candidates.extend( |
| get_contours_from_edges( |
| enhanced |
| ) |
| ) |
|
|
| candidates.extend( |
| get_threshold_contours( |
| enhanced |
| ) |
| ) |
|
|
| if len(candidates) > MAX_CANDIDATES: |
|
|
| candidates = sorted( |
| candidates, |
| key=cv2.contourArea, |
| reverse=True |
| )[ |
| :MAX_CANDIDATES |
| ] |
|
|
| if len(candidates) == 0: |
|
|
| candidates.extend( |
| get_contours_from_edges( |
| gray |
| ) |
| ) |
|
|
| if len(candidates) > MAX_CANDIDATES: |
|
|
| candidates = sorted( |
| candidates, |
| key=cv2.contourArea, |
| reverse=True |
| )[ |
| :MAX_CANDIDATES |
| ] |
|
|
| image_area = float( |
| work.shape[0] * |
| work.shape[1] |
| ) |
|
|
| best_quad = None |
| best_score = -1 |
|
|
| |
| |
| |
|
|
| for contour in candidates: |
|
|
| contour_area = cv2.contourArea( |
| contour |
| ) |
|
|
| if contour_area <= 0: |
| continue |
|
|
| if contour_area < ( |
| image_area * |
| MIN_CARD_AREA_RATIO |
| ): |
| continue |
|
|
| if contour_area > ( |
| image_area * |
| MAX_CARD_AREA_RATIO |
| ): |
| continue |
|
|
| perimeter = cv2.arcLength( |
| contour, |
| True |
| ) |
|
|
| if perimeter <= 0: |
| continue |
|
|
| for epsilon_factor in [ |
| 0.008, |
| 0.010, |
| 0.012, |
| 0.015, |
| 0.018, |
| 0.020, |
| 0.025, |
| 0.030, |
| 0.035, |
| 0.040, |
| 0.050 |
| ]: |
|
|
| approx = cv2.approxPolyDP( |
| contour, |
| epsilon_factor * |
| perimeter, |
| True |
| ) |
|
|
| if len(approx) != 4: |
| continue |
|
|
| quad = ( |
| approx |
| .reshape(4, 2) |
| .astype(np.float32) |
| ) |
|
|
| if not is_reasonable_card( |
| quad, |
| work.shape |
| ): |
| continue |
|
|
| score = score_card( |
| quad, |
| contour_area, |
| image_area |
| ) |
|
|
| if score > best_score: |
|
|
| best_score = score |
|
|
| best_quad = ( |
| quad.copy() |
| ) |
|
|
| if ( |
| best_score >= |
| EARLY_ACCEPT_SCORE |
| ): |
| break |
|
|
| if ( |
| best_score >= |
| EARLY_ACCEPT_SCORE |
| ): |
| break |
|
|
| |
| |
| |
|
|
| if best_quad is None: |
|
|
| sorted_candidates = sorted( |
| candidates, |
| key=cv2.contourArea, |
| reverse=True |
| ) |
|
|
| for contour in sorted_candidates[ |
| :MAX_FALLBACK_CONTOURS |
| ]: |
|
|
| contour_area = cv2.contourArea( |
| contour |
| ) |
|
|
| if contour_area <= ( |
| image_area * |
| MIN_CARD_AREA_RATIO |
| ): |
| continue |
|
|
| rect = cv2.minAreaRect( |
| contour |
| ) |
|
|
| box = cv2.boxPoints( |
| rect |
| ) |
|
|
| box = np.asarray( |
| box, |
| dtype=np.float32 |
| ) |
|
|
| if not is_reasonable_card( |
| box, |
| work.shape |
| ): |
| continue |
|
|
| score = score_card( |
| box, |
| contour_area, |
| image_area |
| ) |
|
|
| score *= 0.92 |
|
|
| if score > best_score: |
|
|
| best_score = score |
|
|
| best_quad = ( |
| box.copy() |
| ) |
|
|
| if best_quad is None: |
|
|
| return None |
|
|
| if scale != 1.0: |
|
|
| best_quad = ( |
| best_quad / |
| scale |
| ) |
|
|
| best_quad[:, 0] = np.clip( |
| best_quad[:, 0], |
| 0, |
| original.shape[1] - 1 |
| ) |
|
|
| best_quad[:, 1] = np.clip( |
| best_quad[:, 1], |
| 0, |
| original.shape[0] - 1 |
| ) |
|
|
| return order_points( |
| best_quad |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def expand_quad( |
| corners, |
| image_shape, |
| margin=CARD_MARGIN |
| ): |
|
|
| h, w = image_shape[:2] |
|
|
| corners = order_points( |
| corners |
| ) |
|
|
| tl, tr, br, bl = corners |
|
|
| width_top = distance( |
| tl, |
| tr |
| ) |
|
|
| width_bottom = distance( |
| bl, |
| br |
| ) |
|
|
| height_left = distance( |
| tl, |
| bl |
| ) |
|
|
| height_right = distance( |
| tr, |
| br |
| ) |
|
|
| avg_width = ( |
| width_top + |
| width_bottom |
| ) / 2.0 |
|
|
| avg_height = ( |
| height_left + |
| height_right |
| ) / 2.0 |
|
|
| pad_x = ( |
| avg_width * |
| margin |
| ) |
|
|
| pad_y = ( |
| avg_height * |
| margin |
| ) |
|
|
| center = np.mean( |
| corners, |
| axis=0 |
| ) |
|
|
| expanded = [] |
|
|
| for point in corners: |
|
|
| direction = ( |
| point - |
| center |
| ) |
|
|
| norm = np.linalg.norm( |
| direction |
| ) |
|
|
| if norm > 0: |
|
|
| amount = ( |
| margin * |
| 0.75 * |
| norm |
| ) |
|
|
| new_point = ( |
| point + |
| direction / norm * |
| amount |
| ) |
|
|
| else: |
|
|
| new_point = point |
|
|
| expanded.append( |
| new_point |
| ) |
|
|
| expanded = np.asarray( |
| expanded, |
| dtype=np.float32 |
| ) |
|
|
| expanded[0] += np.array( |
| [ |
| -pad_x * 0.15, |
| -pad_y * 0.15 |
| ], |
| dtype=np.float32 |
| ) |
|
|
| expanded[1] += np.array( |
| [ |
| pad_x * 0.15, |
| -pad_y * 0.15 |
| ], |
| dtype=np.float32 |
| ) |
|
|
| expanded[2] += np.array( |
| [ |
| pad_x * 0.15, |
| pad_y * 0.15 |
| ], |
| dtype=np.float32 |
| ) |
|
|
| expanded[3] += np.array( |
| [ |
| -pad_x * 0.15, |
| pad_y * 0.15 |
| ], |
| dtype=np.float32 |
| ) |
|
|
| expanded[:, 0] = np.clip( |
| expanded[:, 0], |
| 0, |
| w - 1 |
| ) |
|
|
| expanded[:, 1] = np.clip( |
| expanded[:, 1], |
| 0, |
| h - 1 |
| ) |
|
|
| return order_points( |
| expanded |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def perspective_crop( |
| image, |
| corners |
| ): |
|
|
| corners = order_points( |
| corners |
| ) |
|
|
| tl, tr, br, bl = corners |
|
|
| width_top = distance( |
| tl, |
| tr |
| ) |
|
|
| width_bottom = distance( |
| bl, |
| br |
| ) |
|
|
| height_left = distance( |
| tl, |
| bl |
| ) |
|
|
| height_right = distance( |
| tr, |
| br |
| ) |
|
|
| output_width = int( |
| round( |
| max( |
| width_top, |
| width_bottom |
| ) |
| ) |
| ) |
|
|
| output_height = int( |
| round( |
| max( |
| height_left, |
| height_right |
| ) |
| ) |
| ) |
|
|
| if output_width < MIN_OUTPUT_WIDTH: |
| return None |
|
|
| if output_height < MIN_OUTPUT_HEIGHT: |
| return None |
|
|
| output_width = min( |
| output_width, |
| MAX_OUTPUT_WIDTH |
| ) |
|
|
| output_height = min( |
| output_height, |
| MAX_OUTPUT_HEIGHT |
| ) |
|
|
| destination = np.array( |
| [ |
| [0, 0], |
| [output_width - 1, 0], |
| [ |
| output_width - 1, |
| output_height - 1 |
| ], |
| [ |
| 0, |
| output_height - 1 |
| ] |
| ], |
| dtype=np.float32 |
| ) |
|
|
| source = np.array( |
| [ |
| tl, |
| tr, |
| br, |
| bl |
| ], |
| dtype=np.float32 |
| ) |
|
|
| matrix = cv2.getPerspectiveTransform( |
| source, |
| destination |
| ) |
|
|
| cropped = cv2.warpPerspective( |
| image, |
| matrix, |
| ( |
| output_width, |
| output_height |
| ), |
| flags=cv2.INTER_CUBIC, |
| borderMode=cv2.BORDER_REPLICATE |
| ) |
|
|
| if ( |
| cropped is None or |
| cropped.size == 0 |
| ): |
| return None |
|
|
| return cropped |
|
|
|
|
| |
| |
| |
|
|
| def normalize_card_orientation( |
| image |
| ): |
|
|
| if image is None: |
| return None |
|
|
| h, w = image.shape[:2] |
|
|
| if h <= 0 or w <= 0: |
| return image |
|
|
| if h > w: |
|
|
| image = cv2.rotate( |
| image, |
| cv2.ROTATE_90_CLOCKWISE |
| ) |
|
|
| return image |
|
|
|
|
| |
| |
| |
|
|
| def crop_id_card( |
| image |
| ): |
|
|
| if image is None: |
| return None |
|
|
| if len(image.shape) == 2: |
|
|
| image = cv2.cvtColor( |
| image, |
| cv2.COLOR_GRAY2BGR |
| ) |
|
|
| if ( |
| len(image.shape) == 3 and |
| image.shape[2] == 4 |
| ): |
|
|
| image = cv2.cvtColor( |
| image, |
| cv2.COLOR_BGRA2BGR |
| ) |
|
|
| original_h, original_w = ( |
| image.shape[:2] |
| ) |
|
|
| original_area = ( |
| original_h * |
| original_w |
| ) |
|
|
| corners = find_card_contour( |
| image |
| ) |
|
|
| if corners is None: |
|
|
| return None |
|
|
| corners = expand_quad( |
| corners, |
| image.shape, |
| CARD_MARGIN |
| ) |
|
|
| cropped = perspective_crop( |
| image, |
| corners |
| ) |
|
|
| if cropped is None: |
|
|
| return None |
|
|
| |
| |
| |
|
|
| x, y, cw, ch = cv2.boundingRect( |
| corners.astype( |
| np.float32 |
| ) |
| ) |
|
|
| bbox_area = ( |
| cw * |
| ch |
| ) |
|
|
| |
| |
| if bbox_area < ( |
| original_area * |
| 0.02 |
| ): |
|
|
| return None |
|
|
| |
| |
| if bbox_area >= ( |
| original_area * |
| 0.97 |
| ): |
|
|
| return None |
|
|
| cropped = normalize_card_orientation( |
| cropped |
| ) |
|
|
| return cropped |
|
|
|
|
| |
| |
| |
|
|
| def read_gradio_image( |
| file |
| ): |
|
|
| if file is None: |
| return None |
|
|
| if isinstance( |
| file, |
| str |
| ): |
|
|
| return cv2.imread( |
| file, |
| cv2.IMREAD_COLOR |
| ) |
|
|
| if isinstance( |
| file, |
| np.ndarray |
| ): |
|
|
| image = file.copy() |
|
|
| if len( |
| image.shape |
| ) == 3: |
|
|
| image = cv2.cvtColor( |
| image, |
| cv2.COLOR_RGB2BGR |
| ) |
|
|
| return image |
|
|
| if hasattr( |
| file, |
| "read" |
| ): |
|
|
| data = file.read() |
|
|
| else: |
|
|
| data = file |
|
|
| if not isinstance( |
| data, |
| bytes |
| ): |
|
|
| return None |
|
|
| npimg = np.frombuffer( |
| data, |
| dtype=np.uint8 |
| ) |
|
|
| return cv2.imdecode( |
| npimg, |
| cv2.IMREAD_COLOR |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def process_image( |
| file |
| ): |
|
|
| try: |
|
|
| if file is None: |
|
|
| return ( |
| None, |
| "Please upload an ID image." |
| ) |
|
|
| image = read_gradio_image( |
| file |
| ) |
|
|
| if image is None: |
|
|
| return ( |
| None, |
| "Could not decode image." |
| ) |
|
|
| cropped = crop_id_card( |
| image |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| if cropped is None: |
|
|
| original_rgb = cv2.cvtColor( |
| image, |
| cv2.COLOR_BGR2RGB |
| ) |
|
|
| return ( |
| original_rgb, |
| "⚠️ ID card not detected. Original image returned." |
| ) |
|
|
| cropped_rgb = cv2.cvtColor( |
| cropped, |
| cv2.COLOR_BGR2RGB |
| ) |
|
|
| return ( |
| cropped_rgb, |
| "✅ ID card detected and cropped." |
| ) |
|
|
| except Exception as e: |
|
|
| print( |
| "PROCESS ERROR:", |
| repr(e) |
| ) |
|
|
| return ( |
| None, |
| "❌ Error: " + |
| str(e) |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def is_supported_image( |
| filename |
| ): |
|
|
| return ( |
| Path(filename).suffix.lower() |
| in SUPPORTED_EXTENSIONS |
| ) |
|
|
|
|
| def process_zip_file( |
| zip_path |
| ): |
|
|
| if zip_path is None: |
|
|
| return ( |
| None, |
| "Please upload a ZIP file." |
| ) |
|
|
| temp_root = tempfile.mkdtemp( |
| prefix="id_batch_" |
| ) |
|
|
| input_dir = os.path.join( |
| temp_root, |
| "input" |
| ) |
|
|
| output_dir = os.path.join( |
| temp_root, |
| "output" |
| ) |
|
|
| os.makedirs( |
| input_dir, |
| exist_ok=True |
| ) |
|
|
| os.makedirs( |
| output_dir, |
| exist_ok=True |
| ) |
|
|
| try: |
|
|
| |
| |
| |
|
|
| with zipfile.ZipFile( |
| zip_path, |
| "r" |
| ) as zip_ref: |
|
|
| zip_ref.extractall( |
| input_dir |
| ) |
|
|
| image_files = [] |
|
|
| for root, dirs, files in os.walk( |
| input_dir |
| ): |
|
|
| for filename in files: |
|
|
| full_path = os.path.join( |
| root, |
| filename |
| ) |
|
|
| if is_supported_image( |
| filename |
| ): |
|
|
| image_files.append( |
| full_path |
| ) |
|
|
| if len(image_files) == 0: |
|
|
| return ( |
| None, |
| "❌ ZIP contains no supported images." |
| ) |
|
|
| |
| |
| |
|
|
| processed = 0 |
| cropped_count = 0 |
| original_count = 0 |
| failed_count = 0 |
|
|
| for image_path in image_files: |
|
|
| relative_path = os.path.relpath( |
| image_path, |
| input_dir |
| ) |
|
|
| output_path = os.path.join( |
| output_dir, |
| relative_path |
| ) |
|
|
| os.makedirs( |
| os.path.dirname( |
| output_path |
| ), |
| exist_ok=True |
| ) |
|
|
| image = cv2.imread( |
| image_path, |
| cv2.IMREAD_COLOR |
| ) |
|
|
| if image is None: |
|
|
| failed_count += 1 |
|
|
| |
| shutil.copy2( |
| image_path, |
| output_path |
| ) |
|
|
| continue |
|
|
| try: |
|
|
| cropped = crop_id_card( |
| image |
| ) |
|
|
| if cropped is None: |
|
|
| |
| |
| |
| |
| |
|
|
| shutil.copy2( |
| image_path, |
| output_path |
| ) |
|
|
| original_count += 1 |
|
|
| else: |
|
|
| |
| |
| |
|
|
| ok = cv2.imwrite( |
| output_path, |
| cropped, |
| [ |
| int( |
| cv2.IMWRITE_JPEG_QUALITY |
| ), |
| 95 |
| ] |
| ) |
|
|
| if ok: |
|
|
| cropped_count += 1 |
|
|
| else: |
|
|
| |
| |
| shutil.copy2( |
| image_path, |
| output_path |
| ) |
|
|
| original_count += 1 |
|
|
| except Exception as image_error: |
|
|
| print( |
| "IMAGE PROCESS ERROR:", |
| image_path, |
| repr(image_error) |
| ) |
|
|
| |
| shutil.copy2( |
| image_path, |
| output_path |
| ) |
|
|
| failed_count += 1 |
|
|
| processed += 1 |
|
|
| |
| |
| |
|
|
| output_zip = os.path.join( |
| temp_root, |
| "cropped_ids.zip" |
| ) |
|
|
| with zipfile.ZipFile( |
| output_zip, |
| "w", |
| compression=zipfile.ZIP_DEFLATED |
| ) as zip_ref: |
|
|
| for root, dirs, files in os.walk( |
| output_dir |
| ): |
|
|
| for filename in files: |
|
|
| full_path = os.path.join( |
| root, |
| filename |
| ) |
|
|
| arcname = os.path.relpath( |
| full_path, |
| output_dir |
| ) |
|
|
| zip_ref.write( |
| full_path, |
| arcname |
| ) |
|
|
| status = ( |
| "✅ Batch completed\n\n" |
| f"Total images: {len(image_files)}\n" |
| f"Cropped IDs: {cropped_count}\n" |
| f"Original returned: {original_count}\n" |
| f"Processing errors: {failed_count}" |
| ) |
|
|
| return ( |
| output_zip, |
| status |
| ) |
|
|
| except zipfile.BadZipFile: |
|
|
| return ( |
| None, |
| "❌ Invalid ZIP file." |
| ) |
|
|
| except Exception as e: |
|
|
| print( |
| "ZIP PROCESS ERROR:", |
| repr(e) |
| ) |
|
|
| return ( |
| None, |
| "❌ Error: " + |
| str(e) |
| ) |
|
|
|
|
| |
| |
| |
|
|
| @app.post( |
| "/crop-id" |
| ) |
| async def crop_id_endpoint( |
| file: UploadFile = File(...) |
| ): |
|
|
| try: |
|
|
| contents = await file.read() |
|
|
| if not contents: |
|
|
| return JSONResponse( |
| status_code=400, |
| content={ |
| "success": False, |
| "error": "Empty file" |
| } |
| ) |
|
|
| npimg = np.frombuffer( |
| contents, |
| dtype=np.uint8 |
| ) |
|
|
| image = cv2.imdecode( |
| npimg, |
| cv2.IMREAD_COLOR |
| ) |
|
|
| if image is None: |
|
|
| return JSONResponse( |
| status_code=400, |
| content={ |
| "success": False, |
| "error": "Invalid image" |
| } |
| ) |
|
|
| cropped = crop_id_card( |
| image |
| ) |
|
|
| |
| |
| |
| |
|
|
| if cropped is None: |
|
|
| cropped = image |
|
|
| ok, buffer = cv2.imencode( |
| ".jpg", |
| cropped, |
| [ |
| int( |
| cv2.IMWRITE_JPEG_QUALITY |
| ), |
| 95 |
| ] |
| ) |
|
|
| if not ok: |
|
|
| return JSONResponse( |
| status_code=500, |
| content={ |
| "success": False, |
| "error": |
| "Could not encode image" |
| } |
| ) |
|
|
| return Response( |
| content=buffer.tobytes(), |
| media_type="image/jpeg" |
| ) |
|
|
| except Exception as e: |
|
|
| print( |
| "API ERROR:", |
| repr(e) |
| ) |
|
|
| return JSONResponse( |
| status_code=500, |
| content={ |
| "success": False, |
| "error": str(e) |
| } |
| ) |
|
|
|
|
| |
| |
| |
|
|
| @app.post( |
| "/crop-zip" |
| ) |
| async def crop_zip_endpoint( |
| file: UploadFile = File(...) |
| ): |
|
|
| temp_dir = tempfile.mkdtemp( |
| prefix="crop_zip_api_" |
| ) |
|
|
| try: |
|
|
| zip_input = os.path.join( |
| temp_dir, |
| "input.zip" |
| ) |
|
|
| with open( |
| zip_input, |
| "wb" |
| ) as f: |
|
|
| while True: |
|
|
| chunk = await file.read( |
| 1024 * 1024 |
| ) |
|
|
| if not chunk: |
| break |
|
|
| f.write( |
| chunk |
| ) |
|
|
| output_zip, status = process_zip_file( |
| zip_input |
| ) |
|
|
| if output_zip is None: |
|
|
| return JSONResponse( |
| status_code=422, |
| content={ |
| "success": False, |
| "error": status |
| } |
| ) |
|
|
| |
| |
| |
|
|
| return FileResponse( |
| output_zip, |
| media_type="application/zip", |
| filename="cropped_ids.zip" |
| ) |
|
|
| except Exception as e: |
|
|
| print( |
| "ZIP API ERROR:", |
| repr(e) |
| ) |
|
|
| return JSONResponse( |
| status_code=500, |
| content={ |
| "success": False, |
| "error": str(e) |
| } |
| ) |
|
|
| finally: |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| pass |
|
|
|
|
| |
| |
| |
|
|
| @app.get( |
| "/health" |
| ) |
| def health(): |
|
|
| return { |
| "status": "ok", |
| "service": "Egyptian ID Card Cropper" |
| } |
|
|
|
|
| |
| |
| |
|
|
| with gr.Blocks( |
| title="Egyptian ID Card Cropper" |
| ) as interface: |
|
|
| gr.Markdown( |
| """ |
| # 🇪🇬 Egyptian ID Card Cropper |
| |
| ## Single Image |
| |
| Upload one image containing an Egyptian ID. |
| |
| The system will: |
| |
| - Detect the physical ID card |
| - Handle tilted cards |
| - Handle perspective |
| - Handle weak borders |
| - Handle difficult backgrounds |
| - Keep the complete card |
| - Keep the photograph |
| - Protect card edges |
| - Straighten the card |
| |
| If the card cannot be detected, the **original image is returned**. |
| |
| --- |
| |
| ## Batch ZIP |
| |
| Upload a ZIP containing many images. |
| |
| Every image will be processed and a new ZIP will be returned. |
| |
| **Detected ID → cropped image** |
| |
| **Detection failed → original image** |
| |
| This guarantees that no image is lost. |
| """ |
| ) |
|
|
| |
| |
| |
|
|
| gr.Markdown( |
| "## 📷 Single Image" |
| ) |
|
|
| with gr.Row(): |
|
|
| input_image = gr.Image( |
| type="filepath", |
| label="Upload ID Image" |
| ) |
|
|
| output_image = gr.Image( |
| type="numpy", |
| label="Cropped ID Card" |
| ) |
|
|
| status = gr.Textbox( |
| label="Status", |
| interactive=False |
| ) |
|
|
| process_button = gr.Button( |
| "Crop ID Card", |
| variant="primary" |
| ) |
|
|
| process_button.click( |
| fn=process_image, |
| inputs=input_image, |
| outputs=[ |
| output_image, |
| status |
| ] |
| ) |
|
|
| input_image.change( |
| fn=process_image, |
| inputs=input_image, |
| outputs=[ |
| output_image, |
| status |
| ] |
| ) |
|
|
| |
| |
| |
|
|
| gr.Markdown( |
| "---" |
| ) |
|
|
| gr.Markdown( |
| "## 📦 Batch ZIP Processing" |
| ) |
|
|
| zip_input = gr.File( |
| type="filepath", |
| file_types=[".zip"], |
| label="Upload ZIP containing ID images" |
| ) |
|
|
| zip_button = gr.Button( |
| "Process ZIP", |
| variant="primary" |
| ) |
|
|
| zip_output = gr.File( |
| label="Download Cropped Images ZIP" |
| ) |
|
|
| zip_status = gr.Textbox( |
| label="Batch Status", |
| interactive=False |
| ) |
|
|
| zip_button.click( |
| fn=process_zip_file, |
| inputs=zip_input, |
| outputs=[ |
| zip_output, |
| zip_status |
| ] |
| ) |
|
|
|
|
| |
| |
| |
|
|
| app = gr.mount_gradio_app( |
| app, |
| interface, |
| path="/" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
|
|
| import uvicorn |
|
|
| port = int( |
| os.environ.get( |
| "PORT", |
| 7860 |
| ) |
| ) |
|
|
| uvicorn.run( |
| app, |
| host="0.0.0.0", |
| port=port |
| ) |