ibsmiro commited on
Commit
6f92e81
·
verified ·
1 Parent(s): 4e8b3a8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +477 -669
app.py CHANGED
@@ -1,226 +1,145 @@
1
- import os
2
  import cv2
3
  import numpy as np
4
- import tempfile
5
- import gradio as gr
6
 
7
 
8
  # ============================================================
9
- # SETTINGS
10
  # ============================================================
11
 
12
- MAX_PROCESS_SIZE = 1800
13
-
14
- # Egyptian ID cards are normally wider than tall.
15
- MIN_ASPECT = 1.20
16
- MAX_ASPECT = 2.40
17
-
18
- # Don't accept extremely tiny contours.
19
- MIN_CARD_AREA = 0.04
20
-
21
- # Padding around detected card.
22
- PADDING = 3
23
-
24
-
25
- # ============================================================
26
- # IMAGE HELPERS
27
- # ============================================================
28
-
29
- def resize_for_processing(image):
30
- """
31
- Resize only for processing.
32
- The final crop is taken from the ORIGINAL image.
33
- """
34
-
35
- h, w = image.shape[:2]
36
-
37
- scale = min(1.0, MAX_PROCESS_SIZE / max(h, w))
38
-
39
- if scale == 1.0:
40
- return image.copy(), 1.0
41
-
42
- new_w = int(w * scale)
43
- new_h = int(h * scale)
44
-
45
- small = cv2.resize(
46
- image,
47
- (new_w, new_h),
48
- interpolation=cv2.INTER_AREA
49
- )
50
-
51
- return small, scale
52
-
53
-
54
- def order_points(points):
55
  """
56
  Return points in:
57
- top-left
58
- top-right
59
- bottom-right
60
- bottom-left
61
  """
62
-
63
- pts = np.array(points, dtype=np.float32)
64
 
65
  s = pts.sum(axis=1)
66
  d = np.diff(pts, axis=1).reshape(-1)
67
 
68
- ordered = np.zeros((4, 2), dtype=np.float32)
69
-
70
- ordered[0] = pts[np.argmin(s)]
71
- ordered[2] = pts[np.argmax(s)]
72
-
73
- ordered[1] = pts[np.argmin(d)]
74
- ordered[3] = pts[np.argmax(d)]
75
 
76
- return ordered
77
 
78
 
79
- def polygon_angle(a, b, c):
80
  """
81
- Angle ABC.
82
- """
83
-
84
- ba = a - b
85
- bc = c - b
86
-
87
- denom = (
88
- np.linalg.norm(ba) *
89
- np.linalg.norm(bc)
90
- )
91
-
92
- if denom == 0:
93
- return 0
94
-
95
- cos_angle = np.dot(ba, bc) / denom
96
- cos_angle = np.clip(cos_angle, -1.0, 1.0)
97
-
98
- return np.degrees(np.arccos(cos_angle))
99
-
100
 
101
- def valid_rectangle(points, image_shape):
102
- """
103
- Check whether four points look like a card.
104
  """
105
 
106
- h, w = image_shape[:2]
107
-
108
- pts = order_points(points)
109
-
110
- area = cv2.contourArea(pts.astype(np.float32))
111
-
112
- if area < h * w * MIN_CARD_AREA:
113
- return False, 0
114
 
115
- width_top = np.linalg.norm(pts[1] - pts[0])
116
- width_bottom = np.linalg.norm(pts[2] - pts[3])
117
 
118
- height_left = np.linalg.norm(pts[3] - pts[0])
119
- height_right = np.linalg.norm(pts[2] - pts[1])
120
 
121
- width = (width_top + width_bottom) / 2
122
- height = (height_left + height_right) / 2
123
 
124
- if height <= 1:
125
- return False, 0
126
 
127
- aspect = width / height
128
-
129
- # Card should be wider than tall.
130
- if aspect < MIN_ASPECT or aspect > MAX_ASPECT:
131
- return False, 0
132
-
133
- # Check angles.
134
- angles = []
135
-
136
- for i in range(4):
137
- a = pts[(i - 1) % 4]
138
- b = pts[i]
139
- c = pts[(i + 1) % 4]
140
-
141
- angles.append(
142
- polygon_angle(a, b, c)
143
- )
144
-
145
- # A real card should have reasonably rectangular corners.
146
- angle_score = 0
147
-
148
- for angle in angles:
149
- difference = abs(angle - 90)
150
-
151
- if difference < 10:
152
- angle_score += 1.0
153
- elif difference < 20:
154
- angle_score += 0.7
155
- elif difference < 30:
156
- angle_score += 0.3
157
-
158
- angle_score /= 4
159
 
160
- # Opposite sides should have similar lengths.
161
- width_ratio = min(width_top, width_bottom) / max(
162
- width_top,
163
- width_bottom
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  )
165
 
166
- height_ratio = min(height_left, height_right) / max(
167
- height_left,
168
- height_right
169
- )
170
 
171
- rectangularity = (
172
- 0.5 * angle_score +
173
- 0.25 * width_ratio +
174
- 0.25 * height_ratio
175
- )
 
176
 
177
- return True, rectangularity
178
 
179
 
180
  # ============================================================
181
- # FIND CARD
 
182
  # ============================================================
183
 
184
- def find_card_contour(image):
185
- """
186
- Search for the ID card using several different edge/threshold
187
- methods.
188
 
189
- IMPORTANT:
190
- We only detect the card.
191
- We do NOT perspective-transform it.
192
- """
193
 
194
- small, scale = resize_for_processing(image)
195
 
196
- h, w = small.shape[:2]
 
197
 
198
- gray = cv2.cvtColor(
199
- small,
200
- cv2.COLOR_BGR2GRAY
201
- )
 
 
 
 
 
202
 
203
- # --------------------------------------------------------
204
- # Slight blur
205
- # --------------------------------------------------------
206
 
 
207
  blur = cv2.GaussianBlur(
208
  gray,
209
  (5, 5),
210
  0
211
  )
212
 
213
- candidates = []
214
-
215
- # ========================================================
216
- # METHOD 1 - CANNY
217
- # ========================================================
218
 
219
  for low, high in [
 
220
  (30, 100),
 
221
  (50, 150),
222
- (70, 180),
223
- (100, 200),
224
  ]:
225
 
226
  edges = cv2.Canny(
@@ -241,9 +160,15 @@ def find_card_contour(image):
241
  iterations=2
242
  )
243
 
 
 
 
 
 
 
244
  contours, _ = cv2.findContours(
245
  edges,
246
- cv2.RETR_LIST,
247
  cv2.CHAIN_APPROX_SIMPLE
248
  )
249
 
@@ -251,7 +176,14 @@ def find_card_contour(image):
251
 
252
  area = cv2.contourArea(contour)
253
 
254
- if area < h * w * MIN_CARD_AREA:
 
 
 
 
 
 
 
255
  continue
256
 
257
  perimeter = cv2.arcLength(
@@ -259,685 +191,561 @@ def find_card_contour(image):
259
  True
260
  )
261
 
262
- if perimeter == 0:
263
  continue
264
 
265
- for epsilon_factor in [
266
- 0.015,
267
- 0.02,
268
- 0.025,
269
- 0.03,
270
- 0.04,
271
- ]:
272
-
273
- approx = cv2.approxPolyDP(
274
- contour,
275
- epsilon_factor * perimeter,
276
- True
277
- )
278
-
279
- if len(approx) != 4:
280
- continue
281
-
282
- pts = approx.reshape(4, 2)
283
 
284
- valid, score = valid_rectangle(
285
- pts,
286
- small.shape
287
- )
288
 
289
- if not valid:
290
- continue
291
 
292
- candidates.append(
293
- (
294
- score,
295
- area,
296
- pts
297
- )
298
  )
299
 
300
- # ========================================================
301
- # METHOD 2 - ADAPTIVE THRESHOLD
302
- # ========================================================
303
-
304
- adaptive = cv2.adaptiveThreshold(
305
- blur,
306
- 255,
307
- cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
308
- cv2.THRESH_BINARY,
309
- 31,
310
- 7
311
- )
312
-
313
- kernel = cv2.getStructuringElement(
314
- cv2.MORPH_RECT,
315
- (11, 11)
316
- )
317
-
318
- adaptive = cv2.morphologyEx(
319
- adaptive,
320
- cv2.MORPH_CLOSE,
321
- kernel,
322
- iterations=2
323
- )
324
-
325
- contours, _ = cv2.findContours(
326
- adaptive,
327
- cv2.RETR_LIST,
328
- cv2.CHAIN_APPROX_SIMPLE
329
- )
330
-
331
- for contour in contours:
332
-
333
- area = cv2.contourArea(contour)
334
 
335
- if area < h * w * MIN_CARD_AREA:
336
- continue
 
337
 
338
- perimeter = cv2.arcLength(
339
- contour,
340
- True
341
- )
342
 
343
- if perimeter == 0:
344
- continue
345
 
346
- approx = cv2.approxPolyDP(
347
- contour,
348
- 0.025 * perimeter,
349
- True
350
- )
351
-
352
- if len(approx) != 4:
353
- continue
354
-
355
- pts = approx.reshape(4, 2)
356
-
357
- valid, score = valid_rectangle(
358
- pts,
359
- small.shape
360
- )
361
-
362
- if valid:
363
- candidates.append(
364
- (
365
- score,
366
- area,
367
- pts
368
  )
369
- )
370
 
371
- # ========================================================
372
- # METHOD 3 - THRESHOLD
373
- # ========================================================
374
-
375
- for threshold_value in [80, 100, 120, 140, 160, 180, 200]:
376
-
377
- _, binary = cv2.threshold(
378
- blur,
379
- threshold_value,
380
- 255,
381
- cv2.THRESH_BINARY
382
- )
383
-
384
- binary = cv2.morphologyEx(
385
- binary,
386
- cv2.MORPH_CLOSE,
387
- kernel,
388
- iterations=2
389
- )
390
 
391
- contours, _ = cv2.findContours(
392
- binary,
393
- cv2.RETR_LIST,
394
- cv2.CHAIN_APPROX_SIMPLE
395
- )
396
 
397
- for contour in contours:
 
398
 
399
- area = cv2.contourArea(contour)
400
 
401
- if area < h * w * MIN_CARD_AREA:
 
 
402
  continue
403
 
404
- perimeter = cv2.arcLength(
405
- contour,
406
- True
 
 
407
  )
408
 
409
- if perimeter == 0:
410
  continue
411
 
412
- approx = cv2.approxPolyDP(
413
- contour,
414
- 0.025 * perimeter,
415
- True
416
- )
417
 
418
- if len(approx) != 4:
419
- continue
 
 
420
 
421
- pts = approx.reshape(4, 2)
 
 
 
422
 
423
- valid, score = valid_rectangle(
424
- pts,
425
- small.shape
 
426
  )
427
 
428
- if valid:
429
- candidates.append(
430
- (
431
- score,
432
- area,
433
- pts
434
- )
435
  )
436
-
437
- # ========================================================
438
- # NO CANDIDATE
439
- # ========================================================
440
 
441
  if not candidates:
442
  return None
443
 
444
- # ========================================================
445
- # SCORE CANDIDATES
446
- # ========================================================
447
-
448
- # Prefer:
449
- # - rectangular shapes
450
- # - larger cards
451
- #
452
- # But don't blindly choose the largest contour.
453
-
454
  candidates.sort(
455
- key=lambda x: (
456
- x[0] * 0.7 +
457
- min(
458
- x[1] / (h * w),
459
- 1.0
460
- ) * 0.3
461
- ),
462
  reverse=True
463
  )
464
 
465
- best_score, best_area, best_pts = candidates[0]
466
-
467
- # Convert coordinates back to original image.
468
- best_pts = best_pts.astype(np.float32)
469
 
470
- if scale != 1.0:
471
- best_pts /= scale
472
 
473
- return order_points(best_pts)
474
 
475
 
476
  # ============================================================
477
- # CREATE MASKED CARD
 
478
  # ============================================================
479
 
480
- def make_card_mask(image, points):
481
- """
482
- Create a mask around the actual card.
483
-
484
- IMPORTANT:
485
- We DO NOT warp the card.
486
-
487
- The original pixels stay exactly where they were.
488
- """
489
 
490
  h, w = image.shape[:2]
491
 
492
- mask = np.zeros(
493
- (h, w),
494
- dtype=np.uint8
495
- )
496
-
497
- polygon = np.round(points).astype(
498
- np.int32
499
- )
500
-
501
- cv2.fillPoly(
502
- mask,
503
- [polygon],
504
- 255
505
- )
506
-
507
- # Slightly close small gaps.
508
- kernel = cv2.getStructuringElement(
509
- cv2.MORPH_ELLIPSE,
510
- (5, 5)
511
- )
512
-
513
- mask = cv2.morphologyEx(
514
- mask,
515
- cv2.MORPH_CLOSE,
516
- kernel
517
- )
518
-
519
- return mask
520
 
 
521
 
522
- # ============================================================
523
- # SAFE RECTANGULAR FALLBACK
524
- # ============================================================
525
 
526
- def fallback_card_detection(image):
527
- """
528
- If the four corners cannot be found, try to find a large
529
- horizontal rectangular region.
 
 
 
530
 
531
- This NEVER stretches the image.
532
- """
533
 
534
- h, w = image.shape[:2]
 
535
 
536
- small, scale = resize_for_processing(image)
 
 
 
537
 
538
- sh, sw = small.shape[:2]
 
 
 
539
 
540
- gray = cv2.cvtColor(
541
- small,
542
- cv2.COLOR_BGR2GRAY
 
543
  )
544
 
545
- blur = cv2.GaussianBlur(
546
- gray,
547
- (7, 7),
548
- 0
549
  )
550
 
551
- edges = cv2.Canny(
552
- blur,
553
- 40,
554
- 150
555
  )
556
 
 
557
  kernel = cv2.getStructuringElement(
558
  cv2.MORPH_RECT,
559
  (15, 15)
560
  )
561
 
562
- edges = cv2.morphologyEx(
563
- edges,
564
  cv2.MORPH_CLOSE,
565
  kernel,
566
  iterations=2
567
  )
568
 
 
 
 
 
 
 
 
569
  contours, _ = cv2.findContours(
570
- edges,
571
  cv2.RETR_EXTERNAL,
572
  cv2.CHAIN_APPROX_SIMPLE
573
  )
574
 
575
- best = None
576
- best_score = 0
 
577
 
578
  for contour in contours:
579
 
580
  area = cv2.contourArea(contour)
581
 
582
- if area < sh * sw * 0.05:
583
  continue
584
 
585
- x, y, cw, ch = cv2.boundingRect(
586
- contour
587
- )
588
 
589
- if ch == 0:
 
 
 
 
590
  continue
591
 
592
- aspect = cw / ch
593
 
594
- if aspect < 1.15 or aspect > 2.8:
595
  continue
596
 
597
- area_ratio = area / (sh * sw)
598
 
599
- # Prefer wider rectangles with reasonable area.
600
- score = (
601
- min(area_ratio, 0.8) * 0.6 +
602
- min(aspect / 1.6, 1.0) * 0.4
603
  )
604
 
605
- if score > best_score:
606
 
607
- best_score = score
 
 
 
608
 
609
- best = (
610
- x,
611
- y,
612
- cw,
613
- ch
614
- )
615
 
616
- if best is None:
617
- return None
 
 
618
 
619
- x, y, cw, ch = best
 
 
 
620
 
621
- if scale != 1.0:
622
- x = int(x / scale)
623
- y = int(y / scale)
624
- cw = int(cw / scale)
625
- ch = int(ch / scale)
626
 
627
- x2 = min(
628
- w,
629
- x + cw
630
- )
 
 
631
 
632
- y2 = min(
633
- h,
634
- y + ch
635
- )
636
 
637
- return (
638
- x,
639
- y,
640
- x2,
641
- y2
642
  )
643
 
 
644
 
645
- # ============================================================
646
- # MAIN CARD EXTRACTION
647
- # ============================================================
648
 
649
- def extract_id_card(input_image):
650
- """
651
- Main processing function.
652
 
653
- Output:
654
- PNG with transparent background around the ID card.
655
 
656
- NO perspective correction.
657
- NO stretching.
658
- NO resizing.
659
- """
 
 
 
 
 
660
 
661
- if input_image is None:
662
  return None
663
 
664
- # Gradio may give RGB.
665
- if len(input_image.shape) == 3:
666
 
667
- if input_image.shape[2] == 4:
668
- image = cv2.cvtColor(
669
- input_image,
670
- cv2.COLOR_RGBA2BGR
671
- )
672
- else:
673
- image = cv2.cvtColor(
674
- input_image,
675
- cv2.COLOR_RGB2BGR
676
- )
677
 
678
- else:
679
- image = cv2.cvtColor(
680
- input_image,
681
- cv2.COLOR_GRAY2BGR
682
- )
683
 
684
- original = image.copy()
685
 
686
- h, w = original.shape[:2]
 
687
 
688
- # ========================================================
689
- # TRY REAL CARD QUADRILATERAL
690
- # ========================================================
691
 
692
- points = find_card_contour(
693
- original
694
- )
695
 
696
- if points is not None:
 
697
 
698
- mask = make_card_mask(
699
- original,
700
- points
701
- )
 
702
 
703
- # ----------------------------------------------------
704
- # Bounding box of the card
705
- # ----------------------------------------------------
706
 
707
- xs = points[:, 0]
708
- ys = points[:, 1]
 
 
 
 
709
 
710
- x1 = max(
711
- 0,
712
- int(np.floor(xs.min())) - PADDING
713
- )
714
 
715
- y1 = max(
716
- 0,
717
- int(np.floor(ys.min())) - PADDING
718
- )
719
 
720
- x2 = min(
721
- w,
722
- int(np.ceil(xs.max())) + PADDING
723
- )
724
 
725
- y2 = min(
726
- h,
727
- int(np.ceil(ys.max())) + PADDING
728
- )
 
729
 
730
- if x2 > x1 and y2 > y1:
731
 
732
- crop = original[
733
- y1:y2,
734
- x1:x2
735
- ]
736
 
737
- crop_mask = mask[
738
- y1:y2,
739
- x1:x2
740
- ]
741
 
742
- # ------------------------------------------------
743
- # Create RGBA
744
- # ------------------------------------------------
 
 
 
745
 
746
- rgba = cv2.cvtColor(
747
- crop,
748
- cv2.COLOR_BGR2RGBA
749
- )
750
 
751
- # Transparent outside the card.
752
- rgba[:, :, 3] = crop_mask
 
753
 
754
- return save_result(
755
- rgba,
756
- "id_card_detected"
757
- )
758
 
759
- # ========================================================
760
- # FALLBACK
761
- # ========================================================
762
 
763
- fallback = fallback_card_detection(
764
- original
765
- )
766
 
767
- if fallback is not None:
 
 
768
 
769
- x1, y1, x2, y2 = fallback
 
770
 
771
- crop = original[
772
- y1:y2,
773
- x1:x2
774
- ]
775
 
776
- if crop.size > 0:
 
 
 
777
 
778
- # Create a soft rectangular mask.
779
- rgba = cv2.cvtColor(
780
- crop,
781
- cv2.COLOR_BGR2RGBA
782
- )
783
 
784
- return save_result(
785
- rgba,
786
- "id_card_fallback"
787
- )
788
 
789
- # ========================================================
790
- # LAST RESORT
791
- # ========================================================
792
- #
793
- # Do NOT resize/stretch the original.
794
- #
795
- # Instead return None so the user knows detection failed.
796
- #
797
 
798
- return None
 
799
 
 
 
800
 
801
- # ============================================================
802
- # SAVE RESULT
803
- # ============================================================
 
804
 
805
- def save_result(rgba, prefix):
 
 
 
806
 
807
- fd, path = tempfile.mkstemp(
808
- suffix=".png",
809
- prefix=prefix + "_"
810
- )
 
 
 
 
 
 
 
 
811
 
812
- os.close(fd)
813
 
814
- cv2.imwrite(
815
- path,
816
- cv2.cvtColor(
817
- rgba,
818
- cv2.COLOR_RGBA2BGRA
819
  )
820
- )
821
 
822
- return path
823
 
824
 
825
  # ============================================================
826
- # GRADIO UI
827
  # ============================================================
828
 
829
- DESCRIPTION = """
830
- ### Egyptian ID Card Extractor
831
 
832
- Upload an image containing an Egyptian ID card.
 
833
 
834
- The detector will:
 
 
835
 
836
- - Detect the ID card boundaries
837
- - Remove the surrounding background
838
- - Keep the man's photo and all card information
839
- - Crop around the card
840
- - Preserve the original pixels
841
- - NOT perspective-stretch the card
842
- - NOT resize the card
843
- - Output a transparent PNG outside the card
844
 
845
- If the card is tilted, it remains tilted rather than being artificially
846
- stretched into a rectangle.
847
- """
 
848
 
 
849
 
850
- with gr.Blocks(
851
- title="ID Card Extractor"
852
- ) as demo:
853
 
854
- gr.Markdown(
855
- "# 🪪 Egyptian ID Card Extractor"
856
- )
857
 
858
- gr.Markdown(
859
- DESCRIPTION
 
 
 
 
 
 
 
 
860
  )
861
 
862
- with gr.Row():
 
 
863
 
864
- with gr.Column():
 
 
865
 
866
- input_image = gr.Image(
867
- label="Upload ID Image",
868
- type="numpy"
869
- )
870
 
871
- process_button = gr.Button(
872
- "✂️ Detect & Remove Background",
873
- variant="primary"
874
- )
 
 
 
 
875
 
876
- with gr.Column():
877
 
878
- output_image = gr.Image(
879
- label="Cropped ID Card",
880
- type="filepath",
881
- format="png"
882
  )
883
 
884
- status = gr.Markdown(
885
- ""
 
 
 
 
886
  )
887
 
888
- def process(image):
889
 
890
- if image is None:
891
- return None, " Please upload an image."
 
892
 
893
- result = extract_id_card(
894
- image
 
895
  )
896
 
897
- if result is None:
898
- return (
899
- None,
900
- " I could not confidently detect the ID card. "
901
- "The original image was NOT stretched or returned."
902
  )
903
 
904
- return (
905
- result,
906
- "✅ ID card detected. Background removed without perspective stretching."
907
- )
908
 
909
- process_button.click(
910
- fn=process,
911
- inputs=input_image,
912
- outputs=[
913
- output_image,
914
- status
915
- ]
916
  )
917
 
918
- input_image.change(
919
- fn=process,
920
- inputs=input_image,
921
- outputs=[
922
- output_image,
923
- status
924
- ]
925
- )
926
 
 
 
 
927
 
928
- # ============================================================
929
- # START
930
- # ============================================================
 
931
 
932
- if __name__ == "__main__":
933
 
934
- demo.launch(
935
- server_name="0.0.0.0",
936
- server_port=int(
937
- os.environ.get(
938
- "PORT",
939
- 7860
940
  )
941
- ),
942
- show_error=True
943
- )
 
 
 
 
 
 
 
 
 
 
1
  import cv2
2
  import numpy as np
 
 
3
 
4
 
5
  # ============================================================
6
+ # ID CARD DETECTOR
7
  # ============================================================
8
 
9
+ def order_points(pts):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
  Return points in:
12
+ top-left, top-right, bottom-right, bottom-left
 
 
 
13
  """
14
+ pts = np.array(pts, dtype=np.float32)
 
15
 
16
  s = pts.sum(axis=1)
17
  d = np.diff(pts, axis=1).reshape(-1)
18
 
19
+ tl = pts[np.argmin(s)]
20
+ br = pts[np.argmax(s)]
21
+ tr = pts[np.argmin(d)]
22
+ bl = pts[np.argmax(d)]
 
 
 
23
 
24
+ return np.array([tl, tr, br, bl], dtype=np.float32)
25
 
26
 
27
+ def four_point_crop(image, pts, padding=0):
28
  """
29
+ Perspective-correct the ID card.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
+ IMPORTANT:
32
+ This does NOT stretch the entire original image.
33
+ Only the detected quadrilateral is transformed.
34
  """
35
 
36
+ rect = order_points(pts)
 
 
 
 
 
 
 
37
 
38
+ tl, tr, br, bl = rect
 
39
 
40
+ width_a = np.linalg.norm(br - bl)
41
+ width_b = np.linalg.norm(tr - tl)
42
 
43
+ height_a = np.linalg.norm(tr - br)
44
+ height_b = np.linalg.norm(tl - bl)
45
 
46
+ width = int(max(width_a, width_b))
47
+ height = int(max(height_a, height_b))
48
 
49
+ if width < 100 or height < 60:
50
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
+ # ID cards are normally wider than tall.
53
+ # If detector returned the opposite orientation,
54
+ # rotate the result.
55
+ if height > width:
56
+ width, height = height, width
57
+
58
+ dst = np.array([
59
+ [0, 0],
60
+ [width - 1, 0],
61
+ [width - 1, height - 1],
62
+ [0, height - 1]
63
+ ], dtype=np.float32)
64
+
65
+ # Use original dimensions from the ordered points
66
+ real_width = int(max(width_a, width_b))
67
+ real_height = int(max(height_a, height_b))
68
+
69
+ dst = np.array([
70
+ [0, 0],
71
+ [real_width - 1, 0],
72
+ [real_width - 1, real_height - 1],
73
+ [0, real_height - 1]
74
+ ], dtype=np.float32)
75
+
76
+ matrix = cv2.getPerspectiveTransform(rect, dst)
77
+
78
+ warped = cv2.warpPerspective(
79
+ image,
80
+ matrix,
81
+ (real_width, real_height),
82
+ flags=cv2.INTER_CUBIC,
83
+ borderMode=cv2.BORDER_REPLICATE
84
  )
85
 
86
+ if warped is None or warped.size == 0:
87
+ return None
 
 
88
 
89
+ # Always make ID horizontal.
90
+ if warped.shape[0] > warped.shape[1]:
91
+ warped = cv2.rotate(
92
+ warped,
93
+ cv2.ROTATE_90_CLOCKWISE
94
+ )
95
 
96
+ return warped
97
 
98
 
99
  # ============================================================
100
+ # METHOD 1
101
+ # STRONG RECTANGLE / EDGE DETECTION
102
  # ============================================================
103
 
104
+ def detect_by_edges(image):
105
+ h, w = image.shape[:2]
 
 
106
 
107
+ # Work on a smaller image for detection only.
108
+ scale = 1.0
 
 
109
 
110
+ max_dimension = 1400
111
 
112
+ if max(h, w) > max_dimension:
113
+ scale = max_dimension / max(h, w)
114
 
115
+ small = cv2.resize(
116
+ image,
117
+ None,
118
+ fx=scale,
119
+ fy=scale,
120
+ interpolation=cv2.INTER_AREA
121
+ )
122
+ else:
123
+ small = image.copy()
124
 
125
+ gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
 
 
126
 
127
+ # Remove small texture from cloth/background.
128
  blur = cv2.GaussianBlur(
129
  gray,
130
  (5, 5),
131
  0
132
  )
133
 
134
+ # Multiple edge thresholds.
135
+ edge_images = []
 
 
 
136
 
137
  for low, high in [
138
+ (20, 70),
139
  (30, 100),
140
+ (40, 130),
141
  (50, 150),
142
+ (70, 180)
 
143
  ]:
144
 
145
  edges = cv2.Canny(
 
160
  iterations=2
161
  )
162
 
163
+ edge_images.append(edges)
164
+
165
+ candidates = []
166
+
167
+ for edges in edge_images:
168
+
169
  contours, _ = cv2.findContours(
170
  edges,
171
+ cv2.RETR_EXTERNAL,
172
  cv2.CHAIN_APPROX_SIMPLE
173
  )
174
 
 
176
 
177
  area = cv2.contourArea(contour)
178
 
179
+ image_area = small.shape[0] * small.shape[1]
180
+
181
+ # Card should occupy a reasonable amount
182
+ # of the photograph.
183
+ if area < image_area * 0.015:
184
+ continue
185
+
186
+ if area > image_area * 0.95:
187
  continue
188
 
189
  perimeter = cv2.arcLength(
 
191
  True
192
  )
193
 
194
+ if perimeter <= 0:
195
  continue
196
 
197
+ approx = cv2.approxPolyDP(
198
+ contour,
199
+ 0.025 * perimeter,
200
+ True
201
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
+ # ------------------------------------------------
204
+ # NORMAL 4-CORNER DETECTION
205
+ # ------------------------------------------------
 
206
 
207
+ if len(approx) == 4:
 
208
 
209
+ pts = approx.reshape(4, 2).astype(
210
+ np.float32
 
 
 
 
211
  )
212
 
213
+ else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
+ # ------------------------------------------------
216
+ # ROTATED RECTANGLE FALLBACK
217
+ # ------------------------------------------------
218
 
219
+ rect = cv2.minAreaRect(contour)
 
 
 
220
 
221
+ box = cv2.boxPoints(rect)
 
222
 
223
+ pts = np.array(
224
+ box,
225
+ dtype=np.float32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  )
 
227
 
228
+ rect = cv2.minAreaRect(
229
+ pts.astype(np.float32)
230
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
+ rw, rh = rect[1]
 
 
 
 
233
 
234
+ if rw <= 0 or rh <= 0:
235
+ continue
236
 
237
+ ratio = max(rw, rh) / min(rw, rh)
238
 
239
+ # Egyptian ID card is approximately 1.58:1.
240
+ # Allow considerable perspective / rotation.
241
+ if ratio < 1.25 or ratio > 2.25:
242
  continue
243
 
244
+ rect_area = rw * rh
245
+
246
+ rectangularity = area / max(
247
+ rect_area,
248
+ 1
249
  )
250
 
251
+ if rectangularity < 0.45:
252
  continue
253
 
254
+ # Score:
255
+ # - large area
256
+ # - good rectangle
257
+ # - ratio near ID-card ratio
258
+ target_ratio = 1.58
259
 
260
+ ratio_score = 1.0 - min(
261
+ abs(ratio - target_ratio) / 1.0,
262
+ 1.0
263
+ )
264
 
265
+ area_score = min(
266
+ area / image_area / 0.35,
267
+ 1.0
268
+ )
269
 
270
+ score = (
271
+ ratio_score * 0.40 +
272
+ rectangularity * 0.35 +
273
+ area_score * 0.25
274
  )
275
 
276
+ candidates.append(
277
+ (
278
+ score,
279
+ pts / scale
 
 
 
280
  )
281
+ )
 
 
 
282
 
283
  if not candidates:
284
  return None
285
 
 
 
 
 
 
 
 
 
 
 
286
  candidates.sort(
287
+ key=lambda x: x[0],
 
 
 
 
 
 
288
  reverse=True
289
  )
290
 
291
+ # Only accept a reasonably strong detection.
292
+ best_score, best_pts = candidates[0]
 
 
293
 
294
+ if best_score < 0.42:
295
+ return None
296
 
297
+ return best_pts
298
 
299
 
300
  # ============================================================
301
+ # METHOD 2
302
+ # COLOR / LIGHT CARD DETECTION
303
  # ============================================================
304
 
305
+ def detect_by_brightness(image):
 
 
 
 
 
 
 
 
306
 
307
  h, w = image.shape[:2]
308
 
309
+ max_dimension = 1400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
 
311
+ if max(h, w) > max_dimension:
312
 
313
+ scale = max_dimension / max(h, w)
 
 
314
 
315
+ small = cv2.resize(
316
+ image,
317
+ None,
318
+ fx=scale,
319
+ fy=scale,
320
+ interpolation=cv2.INTER_AREA
321
+ )
322
 
323
+ else:
 
324
 
325
+ scale = 1.0
326
+ small = image.copy()
327
 
328
+ hsv = cv2.cvtColor(
329
+ small,
330
+ cv2.COLOR_BGR2HSV
331
+ )
332
 
333
+ # ID card is generally relatively bright
334
+ # and low/medium saturation.
335
+ sat = hsv[:, :, 1]
336
+ val = hsv[:, :, 2]
337
 
338
+ mask1 = cv2.inRange(
339
+ val,
340
+ 135,
341
+ 255
342
  )
343
 
344
+ mask2 = cv2.inRange(
345
+ sat,
346
+ 0,
347
+ 145
348
  )
349
 
350
+ mask = cv2.bitwise_and(
351
+ mask1,
352
+ mask2
 
353
  )
354
 
355
+ # Clean the mask.
356
  kernel = cv2.getStructuringElement(
357
  cv2.MORPH_RECT,
358
  (15, 15)
359
  )
360
 
361
+ mask = cv2.morphologyEx(
362
+ mask,
363
  cv2.MORPH_CLOSE,
364
  kernel,
365
  iterations=2
366
  )
367
 
368
+ mask = cv2.morphologyEx(
369
+ mask,
370
+ cv2.MORPH_OPEN,
371
+ kernel,
372
+ iterations=1
373
+ )
374
+
375
  contours, _ = cv2.findContours(
376
+ mask,
377
  cv2.RETR_EXTERNAL,
378
  cv2.CHAIN_APPROX_SIMPLE
379
  )
380
 
381
+ image_area = small.shape[0] * small.shape[1]
382
+
383
+ candidates = []
384
 
385
  for contour in contours:
386
 
387
  area = cv2.contourArea(contour)
388
 
389
+ if area < image_area * 0.02:
390
  continue
391
 
392
+ if area > image_area * 0.80:
393
+ continue
 
394
 
395
+ rect = cv2.minAreaRect(contour)
396
+
397
+ rw, rh = rect[1]
398
+
399
+ if rw <= 0 or rh <= 0:
400
  continue
401
 
402
+ ratio = max(rw, rh) / min(rw, rh)
403
 
404
+ if ratio < 1.25 or ratio > 2.25:
405
  continue
406
 
407
+ box = cv2.boxPoints(rect)
408
 
409
+ box = np.array(
410
+ box,
411
+ dtype=np.float32
 
412
  )
413
 
414
+ rect_area = rw * rh
415
 
416
+ rectangularity = area / max(
417
+ rect_area,
418
+ 1
419
+ )
420
 
421
+ if rectangularity < 0.40:
422
+ continue
 
 
 
 
423
 
424
+ ratio_score = 1.0 - min(
425
+ abs(ratio - 1.58) / 1.0,
426
+ 1.0
427
+ )
428
 
429
+ area_score = min(
430
+ area / image_area / 0.35,
431
+ 1.0
432
+ )
433
 
434
+ score = (
435
+ ratio_score * 0.50 +
436
+ rectangularity * 0.30 +
437
+ area_score * 0.20
438
+ )
439
 
440
+ candidates.append(
441
+ (
442
+ score,
443
+ box / scale
444
+ )
445
+ )
446
 
447
+ if not candidates:
448
+ return None
 
 
449
 
450
+ candidates.sort(
451
+ key=lambda x: x[0],
452
+ reverse=True
 
 
453
  )
454
 
455
+ score, pts = candidates[0]
456
 
457
+ if score < 0.40:
458
+ return None
 
459
 
460
+ return pts
 
 
461
 
 
 
462
 
463
+ # ============================================================
464
+ # METHOD 3
465
+ # OCR-REGION FALLBACK
466
+ #
467
+ # This is particularly useful when the card and background
468
+ # have almost the same color.
469
+ # ============================================================
470
+
471
+ def detect_from_text_boxes(image, ocr=None):
472
 
473
+ if ocr is None:
474
  return None
475
 
476
+ try:
 
477
 
478
+ result = ocr.predict(image)
 
 
 
 
 
 
 
 
 
479
 
480
+ if result is None:
481
+ return None
 
 
 
482
 
483
+ boxes = []
484
 
485
+ # PaddleOCR versions can return different structures.
486
+ for item in result:
487
 
488
+ if item is None:
489
+ continue
 
490
 
491
+ data = None
 
 
492
 
493
+ if isinstance(item, dict):
494
+ data = item
495
 
496
+ elif hasattr(item, "json"):
497
+ try:
498
+ data = item.json
499
+ except:
500
+ data = None
501
 
502
+ if not data:
503
+ continue
 
504
 
505
+ # Try common PaddleOCR structures.
506
+ for key in [
507
+ "rec_polys",
508
+ "dt_polys",
509
+ "rec_boxes"
510
+ ]:
511
 
512
+ if key in data:
 
 
 
513
 
514
+ arr = np.asarray(
515
+ data[key]
516
+ )
 
517
 
518
+ if arr.ndim == 2 and arr.shape[1] == 4:
 
 
 
519
 
520
+ arr = arr.reshape(
521
+ -1,
522
+ 2,
523
+ 2
524
+ )
525
 
526
+ if arr.ndim == 3:
527
 
528
+ for b in arr:
 
 
 
529
 
530
+ if b.shape[0] >= 4:
 
 
 
531
 
532
+ boxes.append(
533
+ np.array(
534
+ b,
535
+ dtype=np.float32
536
+ )
537
+ )
538
 
539
+ if len(boxes) < 2:
540
+ return None
 
 
541
 
542
+ all_points = np.vstack(
543
+ boxes
544
+ )
545
 
546
+ # Remove tiny / isolated OCR detections.
547
+ x_min = np.min(
548
+ all_points[:, 0]
549
+ )
550
 
551
+ y_min = np.min(
552
+ all_points[:, 1]
553
+ )
554
 
555
+ x_max = np.max(
556
+ all_points[:, 0]
557
+ )
558
 
559
+ y_max = np.max(
560
+ all_points[:, 1]
561
+ )
562
 
563
+ width = x_max - x_min
564
+ height = y_max - y_min
565
 
566
+ if width <= 0 or height <= 0:
567
+ return None
 
 
568
 
569
+ ratio = max(width, height) / min(
570
+ width,
571
+ height
572
+ )
573
 
574
+ # The collection of ID text should have
575
+ # a wide overall shape.
576
+ if ratio < 1.15 or ratio > 3.5:
577
+ return None
 
578
 
579
+ # Expand around OCR text.
580
+ pad_x = width * 0.35
581
+ pad_y = height * 0.55
 
582
 
583
+ x_min -= pad_x
584
+ x_max += pad_x
 
 
 
 
 
 
585
 
586
+ y_min -= pad_y
587
+ y_max += pad_y
588
 
589
+ x_min = max(0, int(x_min))
590
+ y_min = max(0, int(y_min))
591
 
592
+ x_max = min(
593
+ image.shape[1] - 1,
594
+ int(x_max)
595
+ )
596
 
597
+ y_max = min(
598
+ image.shape[0] - 1,
599
+ int(y_max)
600
+ )
601
 
602
+ if x_max <= x_min or y_max <= y_min:
603
+ return None
604
+
605
+ return np.array(
606
+ [
607
+ [x_min, y_min],
608
+ [x_max, y_min],
609
+ [x_max, y_max],
610
+ [x_min, y_max]
611
+ ],
612
+ dtype=np.float32
613
+ )
614
 
615
+ except Exception as e:
616
 
617
+ print(
618
+ "OCR fallback failed:",
619
+ e
 
 
620
  )
 
621
 
622
+ return None
623
 
624
 
625
  # ============================================================
626
+ # MAIN DETECTOR
627
  # ============================================================
628
 
629
+ def detect_id_card(image, ocr=None):
 
630
 
631
+ if image is None:
632
+ return None
633
 
634
+ # Gradio may provide RGB.
635
+ # Convert to OpenCV BGR.
636
+ if len(image.shape) == 3:
637
 
638
+ if image.shape[2] == 3:
 
 
 
 
 
 
 
639
 
640
+ cv_image = cv2.cvtColor(
641
+ image,
642
+ cv2.COLOR_RGB2BGR
643
+ )
644
 
645
+ else:
646
 
647
+ cv_image = image.copy()
 
 
648
 
649
+ else:
 
 
650
 
651
+ cv_image = cv2.cvtColor(
652
+ image,
653
+ cv2.COLOR_GRAY2BGR
654
+ )
655
+
656
+ original = cv_image.copy()
657
+
658
+ print(
659
+ "Input:",
660
+ original.shape
661
  )
662
 
663
+ # ========================================================
664
+ # TRY 1 - EDGE
665
+ # ========================================================
666
 
667
+ pts = detect_by_edges(
668
+ original
669
+ )
670
 
671
+ if pts is not None:
 
 
 
672
 
673
+ print(
674
+ "ID detected using EDGE method"
675
+ )
676
+
677
+ result = four_point_crop(
678
+ original,
679
+ pts
680
+ )
681
 
682
+ if result is not None:
683
 
684
+ return cv2.cvtColor(
685
+ result,
686
+ cv2.COLOR_BGR2RGB
 
687
  )
688
 
689
+ # ========================================================
690
+ # TRY 2 - BRIGHTNESS / COLOR
691
+ # ========================================================
692
+
693
+ pts = detect_by_brightness(
694
+ original
695
  )
696
 
697
+ if pts is not None:
698
 
699
+ print(
700
+ "ID detected using BRIGHTNESS method"
701
+ )
702
 
703
+ result = four_point_crop(
704
+ original,
705
+ pts
706
  )
707
 
708
+ if result is not None:
709
+
710
+ return cv2.cvtColor(
711
+ result,
712
+ cv2.COLOR_BGR2RGB
713
  )
714
 
715
+ # ========================================================
716
+ # TRY 3 - OCR
717
+ # ========================================================
 
718
 
719
+ pts = detect_from_text_boxes(
720
+ original,
721
+ ocr
 
 
 
 
722
  )
723
 
724
+ if pts is not None:
 
 
 
 
 
 
 
725
 
726
+ print(
727
+ "ID detected using OCR fallback"
728
+ )
729
 
730
+ result = four_point_crop(
731
+ original,
732
+ pts
733
+ )
734
 
735
+ if result is not None:
736
 
737
+ return cv2.cvtColor(
738
+ result,
739
+ cv2.COLOR_BGR2RGB
 
 
 
740
  )
741
+
742
+ # ========================================================
743
+ # IMPORTANT:
744
+ # DO NOT RESIZE / STRETCH THE ORIGINAL IMAGE
745
+ # ========================================================
746
+
747
+ print(
748
+ "No reliable ID card detected."
749
+ )
750
+
751
+ return None