Download app.py from ibsocr1/ibsware: direct link, hf CLI and curl.
- Browser
- Download file 59 kB
-
https://huggingface.co/spaces/ibsocr1/ibsware/resolve/main/app.py
- Command line
-
hf download hf://spaces/ibsocr1/ibsware/app.py
-
curl -L -o app.py https://huggingface.co/spaces/ibsocr1/ibsware/resolve/main/app.py
59 kB
| import os | |
| import cv2 | |
| import numpy as np | |
| from PIL import Image | |
| import gradio as gr | |
| import tempfile | |
| import zipfile | |
| import shutil | |
| from pathlib import Path | |
| from fastapi import FastAPI, UploadFile, File | |
| from fastapi.responses import Response, JSONResponse | |
| import cv2 | |
| import numpy as np | |
| from PIL import Image | |
| import gradio as gr | |
| import tempfile | |
| import os | |
| MAX_SIDE = 2400 | |
| def resize_keep(img, max_side=MAX_SIDE): | |
| h, w = img.shape[:2] | |
| s = min(1.0, max_side / max(h, w)) | |
| if s == 1.0: | |
| return img, 1.0 | |
| return cv2.resize(img, (round(w*s), round(h*s)), interpolation=cv2.INTER_AREA), s | |
| def order_quad(p): | |
| p = np.asarray(p, np.float32) | |
| c = p.mean(axis=0) | |
| ang = np.arctan2(p[:, 1] - c[1], p[:, 0] - c[0]) | |
| p = p[np.argsort(ang)] | |
| # rotate to top-left first | |
| i = np.argmin(p.sum(axis=1)) | |
| p = np.roll(p, -i, axis=0) | |
| # ensure clockwise TL,TR,BR,BL | |
| if np.cross(p[1]-p[0], p[2]-p[0]) < 0: | |
| p = p[[0, 3, 2, 1]] | |
| return p.astype(np.float32) | |
| def angle_score(q): | |
| q = order_quad(q) | |
| vals = [] | |
| for i in range(4): | |
| a = q[(i-1) % 4] - q[i] | |
| b = q[(i+1) % 4] - q[i] | |
| den = np.linalg.norm(a) * np.linalg.norm(b) | |
| if den < 1e-6: | |
| return 0 | |
| vals.append(abs(np.dot(a, b) / den)) | |
| return float(np.clip(1.0 - np.mean(vals) * 2.0, 0, 1)) | |
| def rectangularity(q): | |
| q = order_quad(q) | |
| tl, tr, br, bl = q | |
| top = np.linalg.norm(tr-tl); bot = np.linalg.norm(br-bl) | |
| left = np.linalg.norm(bl-tl); right = np.linalg.norm(br-tr) | |
| if min(top, bot, left, right) < 10: | |
| return 0 | |
| width = (top+bot)/2; height = (left+right)/2 | |
| ratio = max(width,height)/max(min(width,height),1) | |
| # Broad range: certificates are not necessarily A4. | |
| ratio_score = 1.0 if 1.10 <= ratio <= 2.20 else max(0.0, 1.0-abs(ratio-1.55)/1.15) | |
| parallel = ((abs(top-bot)/width)+(abs(left-right)/height))/2 | |
| return float(max(0.0, 1.0-parallel*2.5)*ratio_score) | |
| def touches_border(q, shape, margin=4): | |
| h,w = shape[:2] | |
| return sum(x<=margin or y<=margin or x>=w-1-margin or y>=h-1-margin for x,y in q) | |
| def paper_color_score(img, q): | |
| mask = np.zeros(img.shape[:2], np.uint8) | |
| cv2.fillConvexPoly(mask, np.round(q).astype(np.int32), 255) | |
| k = max(9, int(min(img.shape[:2])*0.018)|1) | |
| outer = cv2.dilate(mask, np.ones((k,k),np.uint8), iterations=1) | |
| ring = cv2.subtract(outer, mask) | |
| if cv2.countNonZero(ring) < 100: | |
| return 0.5 | |
| lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB).astype(np.float32) | |
| inside = lab[mask>0]; outside = lab[ring>0] | |
| if len(inside)==0 or len(outside)==0: | |
| return 0.5 | |
| diff = np.linalg.norm(inside.mean(axis=0)-outside.mean(axis=0)) | |
| return float(np.clip(diff/55.0,0,1)) | |
| def edge_support(img, q): | |
| """How strongly the proposed four sides coincide with real image edges. | |
| This prevents a min-area rectangle from swallowing the gray shadow/background | |
| beyond the actual sheet, which was the problem in the supplied A4 example. | |
| """ | |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) | |
| gray = cv2.GaussianBlur(gray,(5,5),0) | |
| gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3) | |
| gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3) | |
| mag = cv2.magnitude(gx,gy) | |
| h,w=gray.shape | |
| scores=[] | |
| for i in range(4): | |
| a=q[i]; b=q[(i+1)%4] | |
| length=np.linalg.norm(b-a) | |
| n=max(60,int(length/3)) | |
| ts=np.linspace(0,1,n) | |
| pts=a[None,:]*(1-ts[:,None])+b[None,:]*ts[:,None] | |
| # Search a narrow band around the proposed side; true paper edge should | |
| # produce a strong response even if the initial contour is a little off. | |
| dx=b[0]-a[0]; dy=b[1]-a[1] | |
| ln=max(np.hypot(dx,dy),1) | |
| nx=-dy/ln; ny=dx/ln | |
| vals=[] | |
| for off in np.linspace(-7,7,15): | |
| xs=np.clip(np.round(pts[:,0]+nx*off).astype(int),0,w-1) | |
| ys=np.clip(np.round(pts[:,1]+ny*off).astype(int),0,h-1) | |
| vals.append(np.percentile(mag[ys,xs],75)) | |
| scores.append(max(vals)) | |
| # Normalize against the image's typical edge strength. | |
| base=np.percentile(mag,92)+1e-6 | |
| return float(np.clip(np.mean(scores)/base,0,1.8)/1.8) | |
| def refine_quad_to_edges(img, q): | |
| """Conservatively refine each detected side to the nearest *continuous* | |
| outer-paper edge. We deliberately avoid jumping to decorative certificate | |
| artwork, text, or shadows farther away from the detected boundary. | |
| """ | |
| q=order_quad(q).copy() | |
| gray=cv2.cvtColor(img,cv2.COLOR_BGR2GRAY) | |
| gray=cv2.GaussianBlur(gray,(5,5),0) | |
| gx=cv2.Sobel(gray,cv2.CV_32F,1,0,ksize=3) | |
| gy=cv2.Sobel(gray,cv2.CV_32F,0,1,ksize=3) | |
| mag=cv2.magnitude(gx,gy) | |
| h,w=gray.shape | |
| base=float(np.percentile(mag,90))+1e-6 | |
| original=q.copy() | |
| for i in range(4): | |
| a=original[i]; b=original[(i+1)%4] | |
| d=b-a; L=max(np.linalg.norm(d),1) | |
| n=np.array([-d[1],d[0]],np.float32)/L | |
| mid=(a+b)/2; center=original.mean(axis=0) | |
| if np.dot(center-mid,n)<0: n=-n | |
| count=max(100,int(L/2)) | |
| ts=np.linspace(0.08,0.92,count) | |
| pts=a[None,:]*(1-ts[:,None])+b[None,:]*ts[:,None] | |
| candidates=[] | |
| for off in np.linspace(-8,8,33): | |
| xs=np.clip(np.round(pts[:,0]+n[0]*off).astype(int),0,w-1) | |
| ys=np.clip(np.round(pts[:,1]+n[1]*off).astype(int),0,h-1) | |
| vals=mag[ys,xs] | |
| # Continuous edge support: reward both median strength and the | |
| # fraction of samples above a meaningful threshold. | |
| med=float(np.median(vals)); q75=float(np.percentile(vals,75)) | |
| frac=float(np.mean(vals > base*0.42)) | |
| score=0.50*med+0.25*q75+0.25*base*frac | |
| candidates.append((score,abs(off),off)) | |
| # Prefer the strongest edge, but strongly prefer the closest edge when | |
| # strengths are similar. This prevents shadows/inner artwork winning. | |
| best=max(candidates,key=lambda z:z[0]-base*0.018*z[1]) | |
| score,dist,off=best | |
| if score > base*0.38: | |
| off=float(np.clip(off,-7,7)) | |
| q[i]+=n*off; q[(i+1)%4]+=n*off | |
| return order_quad(q) | |
| def add_candidate(candidates,q,source,img): | |
| q=order_quad(q) | |
| h,w=img.shape[:2] | |
| area=abs(cv2.contourArea(q))/(w*h) | |
| if area < 0.12 or area > 0.97: return | |
| sides=[np.linalg.norm(q[(i+1)%4]-q[i]) for i in range(4)] | |
| if min(sides)<min(w,h)*0.10: return | |
| a=angle_score(q); r=rectangularity(q); c=paper_color_score(img,q); e=edge_support(img,q) | |
| border=touches_border(q,img.shape) | |
| score=area*3.2 + a*1.6 + r*2.0 + c*0.7 + e*3.0 - border*0.55 | |
| candidates.append((score,area,q,source,e)) | |
| def make_candidates(img): | |
| h,w=img.shape[:2] | |
| gray=cv2.cvtColor(img,cv2.COLOR_BGR2GRAY) | |
| lab=cv2.cvtColor(img,cv2.COLOR_BGR2LAB) | |
| hsv=cv2.cvtColor(img,cv2.COLOR_BGR2HSV) | |
| edge_sets=[] | |
| for g in [gray,lab[:,:,0],hsv[:,:,2]]: | |
| g=cv2.GaussianBlur(g,(5,5),0) | |
| for lo,hi in [(25,90),(45,140),(70,210)]: | |
| edge_sets.append(cv2.Canny(g,lo,hi)) | |
| b=max(3,int(min(h,w)*0.025)) | |
| border=np.concatenate([lab[:b].reshape(-1,3),lab[-b:].reshape(-1,3),lab[:,:b].reshape(-1,3),lab[:,-b:].reshape(-1,3)],axis=0).astype(np.float32) | |
| bg=np.median(border,axis=0) | |
| dist=np.linalg.norm(lab.astype(np.float32)-bg,axis=2) | |
| dist=cv2.GaussianBlur(dist,(0,0),5) | |
| for threshold in [12,18,25,35,45]: | |
| m=(dist>threshold).astype(np.uint8)*255 | |
| m=cv2.morphologyEx(m,cv2.MORPH_CLOSE,np.ones((11,11),np.uint8),iterations=2) | |
| m=cv2.morphologyEx(m,cv2.MORPH_OPEN,np.ones((5,5),np.uint8),iterations=1) | |
| edge_sets.append(m) | |
| candidates=[] | |
| for idx,e in enumerate(edge_sets): | |
| if idx<6: | |
| e=cv2.morphologyEx(e,cv2.MORPH_CLOSE,np.ones((9,9),np.uint8),iterations=2) | |
| e=cv2.dilate(e,np.ones((3,3),np.uint8),iterations=1) | |
| contours,_=cv2.findContours(e,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE) | |
| contours=sorted(contours,key=cv2.contourArea,reverse=True)[:100] | |
| for c in contours: | |
| ar=cv2.contourArea(c)/(w*h) | |
| if ar<0.10: continue | |
| peri=cv2.arcLength(c,True) | |
| for eps in (0.004,0.006,0.01,0.015,0.022,0.032,0.045): | |
| p=cv2.approxPolyDP(c,eps*peri,True) | |
| if len(p)==4 and cv2.isContourConvex(p): add_candidate(candidates,p.reshape(4,2),idx,img) | |
| rect=cv2.boxPoints(cv2.minAreaRect(c)) | |
| add_candidate(candidates,rect,'minrect',img) | |
| candidates.sort(key=lambda x:x[0],reverse=True) | |
| selected=[] | |
| for cand in candidates: | |
| q=cand[2] | |
| if any(np.mean(np.linalg.norm(q-x[2],axis=1))/max(min(w,h),1)<0.035 for x in selected): continue | |
| selected.append(cand) | |
| if len(selected)>=120: break | |
| return selected | |
| def crop_paper(original, q, extra_trim=0): | |
| """Perspective-correct the detected paper first, then trim in the | |
| rectified image. This is safer and more predictable than moving the | |
| source quadrilateral: left/right and bottom margins are trimmed as true | |
| percentages of the final paper, regardless of camera perspective. | |
| """ | |
| q=order_quad(q) | |
| tl,tr,br,bl=q | |
| ow=max(2,int(round(max(np.linalg.norm(tr-tl),np.linalg.norm(br-bl))))) | |
| oh=max(2,int(round(max(np.linalg.norm(bl-tl),np.linalg.norm(br-tr))))) | |
| dst=np.array([[0,0],[ow-1,0],[ow-1,oh-1],[0,oh-1]],np.float32) | |
| M=cv2.getPerspectiveTransform(q,dst) | |
| warped=cv2.warpPerspective(original,M,(ow,oh),flags=cv2.INTER_CUBIC, | |
| borderMode=cv2.BORDER_CONSTANT,borderValue=(255,255,255)) | |
| # Trim only after rectification. Sides are slightly aggressive and the | |
| # bottom is trimmed more, as requested. Keep the top almost untouched. | |
| pct=max(0.0,float(extra_trim))/100.0 | |
| left=int(round(ow*pct)) | |
| right=int(round(ow*pct)) | |
| bottom=int(round(oh*pct*1.50)) | |
| top=int(round(oh*pct*0.15)) | |
| # Never let cleanup consume the certificate. | |
| left=min(left,max(0,ow//20)); right=min(right,max(0,ow//20)) | |
| bottom=min(bottom,max(0,oh//15)); top=min(top,max(0,oh//100)) | |
| x1=min(left,ow-2); x2=max(x1+2,ow-right) | |
| y1=min(top,oh-2); y2=max(y1+2,oh-bottom) | |
| return warped[y1:y2,x1:x2] | |
| def process(pil, mode, trim): | |
| if pil is None: raise gr.Error('Upload a certificate photo first.') | |
| rgb=np.array(pil.convert('RGB')) | |
| bgr=cv2.cvtColor(rgb,cv2.COLOR_RGB2BGR) | |
| small,scale=resize_keep(bgr) | |
| candidates=make_candidates(small) | |
| if not candidates: raise gr.Error('Paper boundary not found. Make the four paper edges/corners visible.') | |
| # Score using actual edge alignment first. This is the critical fix for the | |
| # supplied example where the previous crop included the bottom/right shadow. | |
| # Choose the actual TOP sheet, not the larger stack/shadow surrounding it. | |
| # In stacked-paper photos the top sheet usually has the strongest continuous | |
| # four-edge signal. The previous version over-rewarded area and could select | |
| # the outer stack. We now make edge continuity the dominant signal, while | |
| # still requiring a plausible rectangle and reasonable area. | |
| viable=[c for c in candidates if rectangularity(c[2])>0.28] or candidates | |
| def final_score(c): | |
| score,area,q,source,edge=c | |
| border=touches_border(q,small.shape) | |
| rect=rectangularity(q) | |
| color=paper_color_score(small,q) | |
| # Edge confidence is dominant because it identifies the visible top-sheet | |
| # boundary. Area gets only a gentle preference; this avoids swallowing | |
| # surrounding shadows or sheets underneath. | |
| edge_term=4.2*edge | |
| shape_term=1.6*rect | |
| original_term=0.22*score | |
| area_term=0.8*min(area,0.60) | |
| border_penalty=1.5*border | |
| return edge_term + shape_term + original_term + area_term + 0.20*color - border_penalty | |
| best=max(viable,key=final_score) | |
| q=refine_quad_to_edges(small,best[2])/scale | |
| result=crop_paper(bgr,q,extra_trim=float(trim)) | |
| out=cv2.cvtColor(result,cv2.COLOR_BGR2RGB) if mode=='Color' else cv2.cvtColor(result,cv2.COLOR_BGR2GRAY) | |
| # Always create a real JPEG file so the Hugging Face output/download is .jpg, | |
| # rather than Gradio silently converting the PIL image to PNG. | |
| fd, jpg_path = tempfile.mkstemp(prefix='certificate_crop_', suffix='.jpg') | |
| os.close(fd) | |
| if not cv2.imwrite(jpg_path, cv2.cvtColor(out, cv2.COLOR_RGB2BGR) if len(out.shape)==3 else out, | |
| [int(cv2.IMWRITE_JPEG_QUALITY), 95]): | |
| try: os.remove(jpg_path) | |
| except OSError: pass | |
| raise gr.Error('Could not save the cropped certificate as JPEG.') | |
| debug=rgb.copy(); poly=np.round(q).astype(np.int32).reshape((-1,1,2)) | |
| cv2.polylines(debug,[poly],True,(0,0,255),max(3,round(rgb.shape[1]/700))) | |
| for i,(x,y) in enumerate(q,1): | |
| cv2.circle(debug,(int(x),int(y)),max(7,round(rgb.shape[1]/130)),(0,255,0),-1) | |
| cv2.putText(debug,str(i),(int(x)+10,int(y)-10),cv2.FONT_HERSHEY_SIMPLEX,1.0,(0,0,0),3,cv2.LINE_AA) | |
| ratio=max(np.linalg.norm(q[1]-q[0]),np.linalg.norm(q[2]-q[3]))/max(min(np.linalg.norm(q[3]-q[0]),np.linalg.norm(q[2]-q[1])),1) | |
| status=f'Paper only | top-sheet boundary: {best[3]} | edge confidence: {best[4]:.2f} | aspect ratio: {ratio:.3f} | perspective corrected' | |
| return jpg_path,Image.fromarray(debug),status | |
| app = FastAPI( | |
| title="Combined Certificate + Egyptian ID Card Cropper" | |
| ) | |
| MIN_CARD_AREA_RATIO = 0.08 | |
| MAX_CARD_AREA_RATIO = 0.92 | |
| CARD_ASPECT_RATIO = 1.586 | |
| 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 | |
| # ------------------------------------------------------------ | |
| # Brightness | |
| # ------------------------------------------------------------ | |
| # Small default increase. | |
| # | |
| # 0 = original | |
| # 10 = slightly brighter | |
| # 20 = brighter | |
| # 30 = strong | |
| # | |
| # ZIP processing uses this value. | |
| # ------------------------------------------------------------ | |
| DEFAULT_BRIGHTNESS = 10 | |
| # ============================================================ | |
| # 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) | |
| } | |
| 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 | |
| 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 | |
| # ============================================================ | |
| # SCORE CARD | |
| # ============================================================ | |
| 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 | |
| ) | |
| 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.55 + | |
| area_score * 0.25 + | |
| angle_score * 0.20 | |
| ) | |
| 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 = [] | |
| 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) | |
| # ============================================================ | |
| # 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() | |
| # ======================================================== | |
| # FAST | |
| # ======================================================== | |
| 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) | |
| ) | |
| # ======================================================== | |
| # COLOR | |
| # ======================================================== | |
| 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 | |
| # ======================================================== | |
| 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] | |
| 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 | |
| # ======================================================== | |
| # 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_score >= EARLY_ACCEPT_SCORE: | |
| break | |
| 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 | |
| # IMPORTANT: | |
| # We only rotate if the resulting image is clearly portrait. | |
| # No flip is ever performed. | |
| if h > w: | |
| image = cv2.rotate( | |
| image, | |
| cv2.ROTATE_90_CLOCKWISE | |
| ) | |
| return image | |
| # ============================================================ | |
| # BRIGHTNESS | |
| # ============================================================ | |
| def adjust_brightness( | |
| image, | |
| brightness | |
| ): | |
| if image is None: | |
| return None | |
| try: | |
| brightness = float(brightness) | |
| except Exception: | |
| brightness = 0 | |
| brightness = max( | |
| -100, | |
| min(100, brightness) | |
| ) | |
| if abs(brightness) < 0.01: | |
| return image.copy() | |
| # -------------------------------------------------------- | |
| # ONLY brightness. | |
| # | |
| # No sharpening. | |
| # No saturation modification. | |
| # No contrast modification. | |
| # No resizing. | |
| # No flipping. | |
| # | |
| # This preserves the original details much better. | |
| # -------------------------------------------------------- | |
| result = cv2.convertScaleAbs( | |
| image, | |
| alpha=1.0, | |
| beta=brightness | |
| ) | |
| return result | |
| # ============================================================ | |
| # CROP | |
| # ============================================================ | |
| def crop_id_card( | |
| image, | |
| brightness=0 | |
| ): | |
| 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.97 | |
| ): | |
| # The image is probably already cropped. | |
| # Keep the original rather than returning failure. | |
| cropped = image.copy() | |
| cropped = normalize_card_orientation( | |
| cropped | |
| ) | |
| cropped = adjust_brightness( | |
| cropped, | |
| brightness | |
| ) | |
| return cropped | |
| # ============================================================ | |
| # IMAGE DECODING | |
| # ============================================================ | |
| def read_image_file(path): | |
| image = cv2.imread( | |
| str(path), | |
| cv2.IMREAD_COLOR | |
| ) | |
| return image | |
| def decode_bytes(data): | |
| if not data: | |
| return None | |
| npimg = np.frombuffer( | |
| data, | |
| dtype=np.uint8 | |
| ) | |
| return cv2.imdecode( | |
| npimg, | |
| cv2.IMREAD_COLOR | |
| ) | |
| # ============================================================ | |
| # IMAGE ENCODING | |
| # ============================================================ | |
| def get_output_extension(original_name): | |
| # ID-card output is ALWAYS JPEG, regardless of input format. | |
| return ".jpg" | |
| def encode_image( | |
| image, | |
| extension | |
| ): | |
| extension = extension.lower() | |
| if extension in [ | |
| ".jpg", | |
| ".jpeg" | |
| ]: | |
| ok, buffer = cv2.imencode( | |
| ".jpg", | |
| image, | |
| [ | |
| int( | |
| cv2.IMWRITE_JPEG_QUALITY | |
| ), | |
| 95 | |
| ] | |
| ) | |
| elif extension == ".png": | |
| ok, buffer = cv2.imencode( | |
| ".png", | |
| image, | |
| [ | |
| int( | |
| cv2.IMWRITE_PNG_COMPRESSION | |
| ), | |
| 3 | |
| ] | |
| ) | |
| else: | |
| ok, buffer = cv2.imencode( | |
| ".jpg", | |
| image, | |
| [ | |
| int( | |
| cv2.IMWRITE_JPEG_QUALITY | |
| ), | |
| 95 | |
| ] | |
| ) | |
| if not ok: | |
| return None | |
| return buffer.tobytes() | |
| # ============================================================ | |
| # GRADIO IMAGE READER | |
| # ============================================================ | |
| 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 | |
| return decode_bytes(data) | |
| # ============================================================ | |
| # SINGLE IMAGE PROCESSOR | |
| # ============================================================ | |
| def process_image( | |
| file, | |
| brightness, | |
| rotation | |
| ): | |
| try: | |
| if file is None: | |
| return ( | |
| None, | |
| None, | |
| "Please upload an ID image." | |
| ) | |
| image = read_gradio_image(file) | |
| if image is None: | |
| return ( | |
| None, | |
| None, | |
| "Could not decode image." | |
| ) | |
| cropped = crop_id_card( | |
| image, | |
| brightness | |
| ) | |
| if cropped is None: | |
| return ( | |
| None, | |
| None, | |
| "β ID card was not detected." | |
| ) | |
| # ---------------------------------------------------- | |
| # USER ROTATION | |
| # ---------------------------------------------------- | |
| rotation = int(rotation) | |
| if rotation == 90: | |
| cropped = cv2.rotate( | |
| cropped, | |
| cv2.ROTATE_90_CLOCKWISE | |
| ) | |
| elif rotation == 180: | |
| cropped = cv2.rotate( | |
| cropped, | |
| cv2.ROTATE_180 | |
| ) | |
| elif rotation == 270: | |
| cropped = cv2.rotate( | |
| cropped, | |
| cv2.ROTATE_90_COUNTERCLOCKWISE | |
| ) | |
| # ---------------------------------------------------- | |
| # PREVIEW | |
| # ---------------------------------------------------- | |
| # Show the processed crop immediately in the ID section. | |
| # This is independent from the downloadable JPG file. | |
| cropped_rgb = cv2.cvtColor( | |
| cropped, | |
| cv2.COLOR_BGR2RGB | |
| ) | |
| # ---------------------------------------------------- | |
| # SAVE AS REAL JPG | |
| # ---------------------------------------------------- | |
| fd, jpg_path = tempfile.mkstemp( | |
| prefix="id_card_crop_", | |
| suffix=".jpg" | |
| ) | |
| os.close(fd) | |
| success = cv2.imwrite( | |
| jpg_path, | |
| cropped, | |
| [ | |
| int(cv2.IMWRITE_JPEG_QUALITY), | |
| 95 | |
| ] | |
| ) | |
| if not success: | |
| try: | |
| os.remove(jpg_path) | |
| except OSError: | |
| pass | |
| return ( | |
| None, | |
| None, | |
| "β Could not save cropped ID card as JPG." | |
| ) | |
| return ( | |
| jpg_path, | |
| jpg_path, | |
| "β ID card cropped successfully. Preview and download are both forced to JPG." | |
| ) | |
| except Exception as e: | |
| print( | |
| "PROCESS ERROR:", | |
| repr(e) | |
| ) | |
| return ( | |
| None, | |
| None, | |
| "β Error: " + str(e) | |
| ) | |
| # ============================================================ | |
| # ZIP PROCESSING | |
| # ============================================================ | |
| def process_zip( | |
| zip_file, | |
| brightness | |
| ): | |
| if zip_file is None: | |
| return ( | |
| None, | |
| "Please upload a ZIP file." | |
| ) | |
| work_dir = tempfile.mkdtemp( | |
| prefix="id_crop_" | |
| ) | |
| input_dir = os.path.join( | |
| work_dir, | |
| "input" | |
| ) | |
| output_dir = os.path.join( | |
| work_dir, | |
| "output" | |
| ) | |
| os.makedirs(input_dir) | |
| os.makedirs(output_dir) | |
| try: | |
| # ---------------------------------------------------- | |
| # Get ZIP path | |
| # ---------------------------------------------------- | |
| if isinstance(zip_file, str): | |
| zip_path = zip_file | |
| elif hasattr(zip_file, "name"): | |
| zip_path = zip_file.name | |
| else: | |
| return ( | |
| None, | |
| "Invalid ZIP file." | |
| ) | |
| # ---------------------------------------------------- | |
| # Extract ZIP | |
| # ---------------------------------------------------- | |
| with zipfile.ZipFile( | |
| zip_path, | |
| "r" | |
| ) as z: | |
| z.extractall( | |
| input_dir | |
| ) | |
| supported = { | |
| ".jpg", | |
| ".jpeg", | |
| ".png", | |
| ".bmp", | |
| ".tif", | |
| ".tiff", | |
| ".webp" | |
| } | |
| image_files = [] | |
| for root, dirs, files in os.walk( | |
| input_dir | |
| ): | |
| for filename in files: | |
| path = Path( | |
| root | |
| ) / filename | |
| if path.suffix.lower() in supported: | |
| image_files.append( | |
| path | |
| ) | |
| if not image_files: | |
| return ( | |
| None, | |
| "No supported images were found in the ZIP." | |
| ) | |
| processed = 0 | |
| failed = 0 | |
| # ---------------------------------------------------- | |
| # Process every image | |
| # ---------------------------------------------------- | |
| for source_path in image_files: | |
| try: | |
| image = read_image_file( | |
| source_path | |
| ) | |
| if image is None: | |
| failed += 1 | |
| continue | |
| cropped = crop_id_card( | |
| image, | |
| brightness | |
| ) | |
| if cropped is None: | |
| # If detection fails, keep original. | |
| # This is important because the user requested | |
| # that already-cropped / difficult images | |
| # should not disappear. | |
| cropped = image.copy() | |
| cropped = adjust_brightness( | |
| cropped, | |
| brightness | |
| ) | |
| # ------------------------------------------------ | |
| # Preserve directory structure. | |
| # ------------------------------------------------ | |
| relative = source_path.relative_to( | |
| input_dir | |
| ) | |
| relative_parent = relative.parent | |
| output_parent = ( | |
| Path(output_dir) / | |
| relative_parent | |
| ) | |
| output_parent.mkdir( | |
| parents=True, | |
| exist_ok=True | |
| ) | |
| # ------------------------------------------------ | |
| # IMPORTANT: ALWAYS SAVE ZIP RESULTS AS JPG. | |
| # The input format does not matter. PNG/WEBP/BMP/TIFF | |
| # and JPEG inputs are all converted to a real .jpg file. | |
| # ------------------------------------------------ | |
| output_extension = ".jpg" | |
| output_name = ( | |
| source_path.stem + | |
| "_cropped.jpg" | |
| ) | |
| output_path = ( | |
| output_parent / | |
| output_name | |
| ) | |
| encoded = encode_image( | |
| cropped, | |
| output_extension | |
| ) | |
| if encoded is None: | |
| failed += 1 | |
| continue | |
| with open( | |
| output_path, | |
| "wb" | |
| ) as f: | |
| f.write(encoded) | |
| processed += 1 | |
| except Exception as e: | |
| failed += 1 | |
| print( | |
| "ZIP IMAGE ERROR:", | |
| source_path, | |
| repr(e) | |
| ) | |
| # ---------------------------------------------------- | |
| # Create output ZIP | |
| # ---------------------------------------------------- | |
| output_zip_base = os.path.join( | |
| work_dir, | |
| "cropped_images" | |
| ) | |
| output_zip = shutil.make_archive( | |
| output_zip_base, | |
| "zip", | |
| output_dir | |
| ) | |
| message = ( | |
| f"β ZIP processing complete. " | |
| f"{processed} images processed." | |
| ) | |
| if failed: | |
| message += ( | |
| f" {failed} images could not be " | |
| f"processed and were skipped." | |
| ) | |
| return ( | |
| output_zip, | |
| message | |
| ) | |
| except zipfile.BadZipFile: | |
| return ( | |
| None, | |
| "β Invalid ZIP file." | |
| ) | |
| except Exception as e: | |
| print( | |
| "ZIP PROCESS ERROR:", | |
| repr(e) | |
| ) | |
| return ( | |
| None, | |
| "β ZIP error: " + str(e) | |
| ) | |
| finally: | |
| # -------------------------------------------------------- | |
| # Do not delete output ZIP here. | |
| # | |
| # Gradio needs the file to remain available. | |
| # | |
| # The temporary directory will be cleaned by the | |
| # operating system / environment. | |
| # -------------------------------------------------------- | |
| pass | |
| # ============================================================ | |
| with gr.Blocks(title="Certificate & Egyptian ID Card Cropper") as interface: | |
| gr.Markdown("# π Certificate & πͺπ¬ Egyptian ID Card Cropper\n\nTwo independent cropper tools on one page.") | |
| gr.Markdown("## π Universal Certificate Paper Cropper") | |
| gr.Markdown("Detects the actual outer sheet of paper using multiple color/edge/shape signals, then refines the four edges before perspective correction. Works with portrait, landscape, rotated and perspective photos without forcing the paper into A4.") | |
| with gr.Row(): | |
| with gr.Column(): | |
| certificate_input = gr.Image(type="pil", sources=["upload", "webcam"], label="Certificate photo") | |
| certificate_mode = gr.Radio(["Color", "Grayscale"], value="Color", label="Output") | |
| certificate_trim = gr.Slider(0, 5, value=2.0, step=.25, label="Aggressive edge crop (%)", info="Trims a little extra from the sides and more from the bottom. 2% is recommended.") | |
| certificate_button = gr.Button("βοΈ Crop Paper Only", variant="primary") | |
| with gr.Column(): | |
| certificate_output = gr.Image(type="filepath", label="Paper only", format="jpeg") | |
| certificate_debug = gr.Image(type="pil", label="Detection check") | |
| certificate_status = gr.Textbox(label="Detection", interactive=False) | |
| certificate_button.click(process, [certificate_input, certificate_mode, certificate_trim], [certificate_output, certificate_debug, certificate_status], api_name="crop_certificate") | |
| gr.Markdown("---") | |
| gr.Markdown("## πͺπ¬ Egyptian ID Card Cropper") | |
| gr.Markdown("Upload an Egyptian ID photograph or a ZIP containing multiple images.") | |
| gr.Markdown("### Single Image") | |
| input_image = gr.Image(type="filepath", label="Upload ID Image") | |
| process_button = gr.Button("βοΈ Crop ID Card", variant="primary") | |
| with gr.Row(): | |
| with gr.Column(): | |
| output_image = gr.Image( | |
| type="filepath", | |
| format="jpeg", | |
| label="πͺͺ New Cropped ID Card Preview (JPG)", | |
| interactive=False | |
| ) | |
| with gr.Column(): | |
| output_file = gr.File( | |
| label="β¬οΈ Download Cropped ID Card (JPG)" | |
| ) | |
| gr.Markdown("### Image Controls") | |
| brightness_slider = gr.Slider(minimum=-50, maximum=50, value=10, step=1, label="Brightness", info="0 = original. Positive values make the ID brighter.") | |
| rotation_dropdown = gr.Dropdown(choices=[0, 90, 180, 270], value=0, label="Rotate Image", info="Rotation is applied clockwise.") | |
| status = gr.Textbox(label="Status", interactive=False) | |
| process_outputs = [output_image, output_file, status] | |
| process_inputs = [input_image, brightness_slider, rotation_dropdown] | |
| process_button.click(process_image, process_inputs, process_outputs) | |
| input_image.change(process_image, process_inputs, process_outputs) | |
| brightness_slider.change(process_image, process_inputs, process_outputs) | |
| rotation_dropdown.change(process_image, process_inputs, process_outputs) | |
| gr.Markdown("---") | |
| gr.Markdown("### π¦ Process a ZIP") | |
| gr.Markdown("Upload a ZIP containing your ID images. The application will process every image and create **cropped_images.zip**. **Every processed image is saved as a real JPG (.jpg), regardless of the input format.**") | |
| zip_input = gr.File(type="filepath", label="Upload ZIP File", file_types=[".zip"]) | |
| zip_brightness = gr.Slider(minimum=0, maximum=50, value=DEFAULT_BRIGHTNESS, step=1, label="ZIP Brightness", info="All images in the resulting ZIP receive this brightness increase.") | |
| zip_button = gr.Button("Process ZIP", variant="primary") | |
| zip_output = gr.File(label="Download Cropped ZIP") | |
| zip_status = gr.Textbox(label="ZIP Status", interactive=False) | |
| zip_button.click(process_zip, [zip_input, zip_brightness], [zip_output, zip_status]) | |
| app = gr.mount_gradio_app(app, interface, path="/") | |
| # API - SINGLE IMAGE | |
| # ============================================================ | |
| 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" | |
| } | |
| ) | |
| image = decode_bytes( | |
| contents | |
| ) | |
| if image is None: | |
| return JSONResponse( | |
| status_code=400, | |
| content={ | |
| "success": False, | |
| "error": "Invalid image" | |
| } | |
| ) | |
| cropped = crop_id_card( | |
| image, | |
| DEFAULT_BRIGHTNESS | |
| ) | |
| if cropped is None: | |
| return JSONResponse( | |
| status_code=422, | |
| content={ | |
| "success": False, | |
| "error": | |
| "ID card could not be detected" | |
| } | |
| ) | |
| 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 cropped 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) | |
| } | |
| ) | |
| # ============================================================ | |
| # HEALTH | |
| # ============================================================ | |
| def health(): | |
| return { | |
| "status": "ok", | |
| "service": "Egyptian ID Card Cropper" | |
| } | |
| # ============================================================ | |
| if __name__ == "__main__": | |
| import uvicorn | |
| port = int(os.environ.get("PORT", "7860")) | |
| print(f"Starting combined cropper on port {port}") | |
| uvicorn.run(app, host="0.0.0.0", port=port) | |