ibsware / app.py
ibsocr1's picture
Update app.py
51ca978 verified
Raw
History Blame Contribute Delete
49.9 kB
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
# ============================================================
# SETTINGS
# ============================================================
CARD_ASPECT_RATIO = 85.60 / 53.98
MIN_CARD_AREA_RATIO = 0.020
MAX_CARD_AREA_RATIO = 0.92
# IMPORTANT:
# Prevent small face/photo rectangles from being accepted
# as the complete ID card.
MIN_CARD_LONG_SIDE_RATIO = 0.22
# Card should normally have a reasonable width/height
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
# ============================================================
# EXTRA DETECTION / PERFORMANCE SETTINGS
# ============================================================
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
# ============================================================
# BATCH SETTINGS
# ============================================================
SUPPORTED_EXTENSIONS = {
".jpg",
".jpeg",
".png",
".bmp",
".webp",
".tif",
".tiff"
}
# ============================================================
# FASTAPI
# ============================================================
app = FastAPI(
title="Egyptian ID Card Cropper"
)
# ============================================================
# POINT HELPERS
# ============================================================
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)
)
)
# ============================================================
# BORDER CHECK
# ============================================================
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
# ============================================================
# QUADRILATERAL GEOMETRY
# ============================================================
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
)
}
# ============================================================
# CARD VALIDATION
# ============================================================
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
)
# --------------------------------------------------------
# AREA
# --------------------------------------------------------
if area_ratio < MIN_CARD_AREA_RATIO:
return False
if area_ratio > MAX_CARD_AREA_RATIO:
return False
# --------------------------------------------------------
# CARD DIMENSIONS
# --------------------------------------------------------
long_side = max(
geometry["width"],
geometry["height"]
)
short_side = min(
geometry["width"],
geometry["height"]
)
# IMPORTANT:
# A person's face/photo is normally much smaller
# than the physical ID card.
#
# This rejects many face rectangles.
# --------------------------------------------------------
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
# --------------------------------------------------------
# CARD RATIO
# --------------------------------------------------------
ratio = geometry["ratio"]
if ratio < 1.15:
return False
if ratio > 2.30:
return False
points = geometry["points"]
# --------------------------------------------------------
# IMAGE BORDER
# --------------------------------------------------------
if touches_image_border(
points,
image_shape,
tolerance=3
):
return False
tl, tr, br, bl = points
# --------------------------------------------------------
# ANGLES
# --------------------------------------------------------
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
# ============================================================
# SCORE CARD
# ============================================================
def score_card(
quad,
contour_area,
image_area
):
geometry = quad_geometry(
quad
)
if geometry is None:
return -1
ratio = geometry["ratio"]
# --------------------------------------------------------
# RATIO SCORE
# --------------------------------------------------------
ratio_error = abs(
ratio -
CARD_ASPECT_RATIO
)
ratio_score = max(
0.0,
1.0 -
ratio_error / 0.70
)
# --------------------------------------------------------
# AREA SCORE
# --------------------------------------------------------
area_ratio = (
contour_area /
image_area
)
area_score = min(
area_ratio / 0.30,
1.0
)
# --------------------------------------------------------
# SIZE SCORE
# --------------------------------------------------------
long_side = max(
geometry["width"],
geometry["height"]
)
# Normalize against image diagonal-ish scale.
# Bigger physical card = better candidate.
h = np.sqrt(image_area)
size_ratio = (
long_side /
max(h, 1)
)
size_score = min(
size_ratio / 1.2,
1.0
)
# --------------------------------------------------------
# RECTANGLE SCORE
# --------------------------------------------------------
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
)
# --------------------------------------------------------
# FINAL SCORE
# --------------------------------------------------------
score = (
ratio_score * 0.50 +
area_score * 0.20 +
angle_score * 0.15 +
size_score * 0.15
)
return float(score)
# ============================================================
# FAST DETECTION
# ============================================================
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 = []
# --------------------------------------------------------
# CANNY
# --------------------------------------------------------
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
)
# --------------------------------------------------------
# OTSU
# --------------------------------------------------------
_, 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 OTSU
# --------------------------------------------------------
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
)
# ============================================================
# COLOR DETECTION
# ============================================================
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
)
# ============================================================
# EDGE CONTOURS
# ============================================================
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
# ============================================================
# THRESHOLD CONTOURS
# ============================================================
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
# ============================================================
# FIND CARD
# ============================================================
def find_card_contour(image):
original = image.copy()
# ========================================================
# PATH 1
# ========================================================
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)
)
# ========================================================
# PATH 2
# ========================================================
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)
)
# ========================================================
# ORIGINAL DETECTOR
# ========================================================
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
# ========================================================
# QUADRILATERAL SEARCH
# ========================================================
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
# ========================================================
# MIN AREA RECTANGLE FALLBACK
# ========================================================
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
)
# ============================================================
# EXPAND CARD
# ============================================================
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
)
# ============================================================
# PERSPECTIVE CROP
# ============================================================
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
# ============================================================
# ORIENTATION
# ============================================================
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
# ============================================================
# SINGLE IMAGE CROP
# ============================================================
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
# --------------------------------------------------------
# Ensure we didn't accidentally crop a tiny region
# --------------------------------------------------------
x, y, cw, ch = cv2.boundingRect(
corners.astype(
np.float32
)
)
bbox_area = (
cw *
ch
)
# If the detected region is suspiciously small,
# reject it.
if bbox_area < (
original_area *
0.02
):
return None
# If detector somehow selected almost the entire image,
# reject it.
if bbox_area >= (
original_area *
0.97
):
return None
cropped = normalize_card_orientation(
cropped
)
return cropped
# ============================================================
# READ IMAGE
# ============================================================
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
)
# ============================================================
# PROCESS SINGLE IMAGE
# ============================================================
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
)
# ----------------------------------------------------
# IMPORTANT:
#
# If detection fails, RETURN ORIGINAL IMAGE.
#
# This means we never lose an 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)
)
# ============================================================
# ZIP HELPERS
# ============================================================
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:
# ====================================================
# EXTRACT ZIP
# ====================================================
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."
)
# ====================================================
# PROCESS 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
# Copy original if OpenCV couldn't read it.
shutil.copy2(
image_path,
output_path
)
continue
try:
cropped = crop_id_card(
image
)
if cropped is None:
# ----------------------------------------
# DETECTION FAILED
#
# Keep original.
# ----------------------------------------
shutil.copy2(
image_path,
output_path
)
original_count += 1
else:
# ----------------------------------------
# DETECTION SUCCESS
# ----------------------------------------
ok = cv2.imwrite(
output_path,
cropped,
[
int(
cv2.IMWRITE_JPEG_QUALITY
),
95
]
)
if ok:
cropped_count += 1
else:
# If saving cropped image fails,
# preserve original.
shutil.copy2(
image_path,
output_path
)
original_count += 1
except Exception as image_error:
print(
"IMAGE PROCESS ERROR:",
image_path,
repr(image_error)
)
# Never lose an image.
shutil.copy2(
image_path,
output_path
)
failed_count += 1
processed += 1
# ====================================================
# CREATE OUTPUT ZIP
# ====================================================
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)
)
# ============================================================
# FASTAPI SINGLE IMAGE ENDPOINT
# ============================================================
@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
)
# ----------------------------------------------------
# IMPORTANT:
# If detection fails, return original 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)
}
)
# ============================================================
# FASTAPI ZIP ENDPOINT
# ============================================================
@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 ZIP directly
# ----------------------------------------------------
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:
# NOTE:
# FileResponse may still be using the file when this
# finally executes depending on server behavior.
#
# Therefore we intentionally do not delete the
# temporary directory here.
#
# In production, use a background cleanup task.
pass
# ============================================================
# HEALTH CHECK
# ============================================================
@app.get(
"/health"
)
def health():
return {
"status": "ok",
"service": "Egyptian ID Card Cropper"
}
# ============================================================
# GRADIO UI
# ============================================================
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.
"""
)
# ========================================================
# SINGLE IMAGE
# ========================================================
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
]
)
# ========================================================
# ZIP
# ========================================================
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
]
)
# ============================================================
# MOUNT GRADIO
# ============================================================
app = gr.mount_gradio_app(
app,
interface,
path="/"
)
# ============================================================
# RUN
# ============================================================
if __name__ == "__main__":
import uvicorn
port = int(
os.environ.get(
"PORT",
7860
)
)
uvicorn.run(
app,
host="0.0.0.0",
port=port
)