kingkill1111 commited on
Commit
628d11d
·
verified ·
1 Parent(s): 88a526c

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +833 -115
main.py CHANGED
@@ -1,134 +1,852 @@
1
- from fastapi import FastAPI, File, UploadFile
2
- from fastapi.middleware.cors import CORSMiddleware
3
- from fastapi.responses import JSONResponse
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import cv2
5
  import numpy as np
6
- import re
7
  import torch
8
- import easyocr
9
- from ultralytics import YOLO
10
  from huggingface_hub import hf_hub_download
11
- from ultralytics.nn.tasks import DetectionModel
12
-
13
- print("Booting 2Factor KYC AI Engine... Loading models.")
14
 
15
- torch.serialization.add_safe_globals([DetectionModel])
16
- _original_torch_load = torch.load
17
- def _patched_load(*args, **kwargs):
18
- kwargs['weights_only'] = False
19
- return _original_torch_load(*args, **kwargs)
20
- torch.load = _patched_load
21
 
22
- # Load Models
23
- spoof_detector = YOLO('yolov8n.pt')
24
- pan_model_path = hf_hub_download(repo_id="foduucom/pan-card-detection", filename="best.pt")
25
- pan_detector = YOLO(pan_model_path)
26
- ocr_reader = easyocr.Reader(['en'], gpu=False) # CPU rendering
 
27
 
28
- torch.load = _original_torch_load
 
29
 
30
  PAN_ENTITY_MAP = {
31
- 'P': 'Person (Individual)', 'C': 'Company', 'F': 'Firm / Limited Liability Partnership (LLP)',
32
- 'H': 'Hindu Undivided Family (HUF)', 'T': 'Trust', 'A': 'Association of Persons (AOP)',
33
- 'B': 'Body of Individuals (BOI)', 'G': 'Government Agency', 'L': 'Local Authority',
34
- 'J': 'Artificial Juridical Person'
 
 
 
 
 
 
35
  }
36
 
37
- to_letter = str.maketrans({'0': 'O', '1': 'I', '2': 'Z', '5': 'S', '8': 'B'})
38
- to_number = str.maketrans({'O': '0', 'I': '1', 'Z': '2', 'S': '5', 'B': '8', 'G': '6', 'Q': '0'})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
- app = FastAPI()
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  app.add_middleware(
43
  CORSMiddleware,
44
- allow_origins=["*"], # Secure this to your specific frontend URL in production
45
- allow_credentials=True,
46
- allow_methods=["POST"],
47
  allow_headers=["*"],
48
  )
49
 
50
- @app.post("/api/v1/kyc/validate")
51
- async def validate_document(file: UploadFile = File(...)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  try:
53
- contents = await file.read()
54
- nparr = np.frombuffer(contents, np.uint8)
55
- img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
56
-
57
- if img is None:
58
- return JSONResponse(status_code=400, content={"status": "rejected", "reason": "Invalid image file format provided."})
59
-
60
- # --- GATE 1: DEVICE / SCREEN SPOOF DETECTION ---
61
- spoof_results = spoof_detector.predict(img, verbose=False)
62
- SPOOF_CLASSES = [62, 63, 67]
63
-
64
- for box in spoof_results[0].boxes:
65
- class_id = int(box.cls[0].item())
66
- confidence = box.conf[0].item()
67
-
68
- if class_id in SPOOF_CLASSES and confidence > 0.35:
69
- device_name = spoof_detector.names[class_id]
70
- return JSONResponse(status_code=403, content={
71
- "status": "rejected",
72
- "reason": f"Presentation attack detected. Found '{device_name}' in frame."
73
- })
74
-
75
- # --- GATE 2: YOLO GEOMETRIC DETECTION ---
76
- pan_results = pan_detector.predict(img, verbose=False)
77
- extracted_texts = []
78
- valid_geometry_found = False
79
- CONFIDENCE_THRESHOLD = 0.50
80
-
81
- for box in pan_results[0].boxes:
82
- if box.conf[0].item() >= CONFIDENCE_THRESHOLD:
83
- valid_geometry_found = True
84
- x1, y1, x2, y2 = map(int, box.xyxy[0].tolist())
85
- crop = img[y1:y2, x1:x2]
86
-
87
- # --- GATE 3: OCR EXTRACTION ---
88
- ocr_result = ocr_reader.readtext(crop, detail=0)
89
- if ocr_result:
90
- text = " ".join(ocr_result).strip()
91
- extracted_texts.append(text)
92
-
93
- if not valid_geometry_found:
94
- return JSONResponse(status_code=400, content={
95
- "status": "rejected",
96
- "reason": "Could not recognize a structurally valid PAN card."
97
- })
98
-
99
- # --- GATE 4: DATA VALIDATION ---
100
- pan_regex = re.compile(r'[A-Z]{5}[0-9]{4}[A-Z]')
101
- detected_pan = None
102
-
103
- for text in extracted_texts:
104
- clean_text = re.sub(r'[^A-Z0-9]', '', text.upper())
105
- for i in range(len(clean_text) - 9):
106
- substring = clean_text[i:i+10]
107
- first_five = substring[0:5].translate(to_letter)
108
- next_four = substring[5:9].translate(to_number)
109
- last_one = substring[9:10].translate(to_letter)
110
- reconstructed_pan = first_five + next_four + last_one
111
-
112
- if pan_regex.match(reconstructed_pan):
113
- detected_pan = reconstructed_pan
114
- break
115
- if detected_pan: break
116
-
117
- if detected_pan:
118
- entity_char = detected_pan[3]
119
- return {
120
- "status": "accepted",
121
- "data": {
122
- "pan_number": detected_pan,
123
- "classification_code": entity_char,
124
- "classification_name": PAN_ENTITY_MAP.get(entity_char, "Unknown")
125
- }
126
- }
127
- else:
128
- return JSONResponse(status_code=422, content={
129
- "status": "rejected",
130
- "reason": "PAN card detected, but a valid 10-digit PAN string could not be extracted."
131
- })
132
-
133
- except Exception as e:
134
- return JSONResponse(status_code=500, content={"status": "error", "reason": str(e)})
 
 
 
 
 
 
1
+ """PAN KYC screening API for a Hugging Face Docker Space.
2
+ Run locally with:
3
+ uvicorn main:app --host 0.0.0.0 --port 7860
4
+ This service performs preliminary image screening only; it does not prove
5
+ that a PAN card is genuine, unedited, or physically present.
6
+ """
7
+
8
+ import contextlib
9
+ import hashlib
10
+ import io
11
+ import json
12
+ import logging
13
+ import os
14
+ import re
15
+ import threading
16
+ import time
17
+ import uuid
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ # Must be set before Paddle/PaddleOCR is imported.
22
+ os.environ.setdefault("FLAGS_use_mkldnn", "0")
23
+
24
  import cv2
25
  import numpy as np
 
26
  import torch
 
 
27
  from huggingface_hub import hf_hub_download
28
+ from paddleocr import PaddleOCR
29
+ from PIL import Image, ImageOps, UnidentifiedImageError
30
+ from ultralytics import YOLO
31
 
32
+ ENGINE_LOGGER = logging.getLogger("pan_kyc")
 
 
 
 
 
33
 
34
+ PAN_DETECTION_THRESHOLD = float(os.getenv("PAN_DETECTION_THRESHOLD", "0.80"))
35
+ DEVICE_CONFIDENCE_THRESHOLD = float(os.getenv("DEVICE_CONFIDENCE_THRESHOLD", "0.35"))
36
+ DEVICE_MIN_AREA_RATIO = float(os.getenv("DEVICE_MIN_AREA_RATIO", "0.12"))
37
+ OCR_MIN_CONFIDENCE = float(os.getenv("OCR_MIN_CONFIDENCE", "0.30"))
38
+ MAX_OCR_CORRECTIONS = int(os.getenv("MAX_OCR_CORRECTIONS", "2"))
39
+ MAX_IMAGE_PIXELS = int(os.getenv("MAX_IMAGE_PIXELS", "25000000"))
40
 
41
+ # Prevent extremely large decompression-bomb images from being silently accepted.
42
+ Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
43
 
44
  PAN_ENTITY_MAP = {
45
+ "P": "Person (Individual)",
46
+ "C": "Company",
47
+ "F": "Firm / Limited Liability Partnership (LLP)",
48
+ "H": "Hindu Undivided Family (HUF)",
49
+ "T": "Trust",
50
+ "A": "Association of Persons (AOP)",
51
+ "B": "Body of Individuals (BOI)",
52
+ "G": "Government Agency",
53
+ "L": "Local Authority",
54
+ "J": "Artificial Juridical Person",
55
  }
56
 
57
+ LETTER_FIX = {
58
+ "0": "O",
59
+ "1": "I",
60
+ "2": "Z",
61
+ "5": "S",
62
+ "6": "G",
63
+ "8": "B",
64
+ }
65
+ DIGIT_FIX = {
66
+ "O": "0",
67
+ "Q": "0",
68
+ "D": "0",
69
+ "I": "1",
70
+ "L": "1",
71
+ "Z": "2",
72
+ "S": "5",
73
+ "G": "6",
74
+ "B": "8",
75
+ }
76
+ STRICT_PAN_REGEX = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$")
77
+
78
+ PAN_MODEL_REPO = "foduucom/pan-card-detection"
79
+ PAN_MODEL_FILENAME = "best.pt"
80
+ PAN_MODEL_REVISION = "5b6395bcfda0814d8817dc6a446fd70533f88a24"
81
+ PAN_MODEL_SHA256 = "a8721936f8585a53227445f997e1ebe10af5ba7faacd3602c01d65514c8dbbc8"
82
+
83
+ # COCO class IDs used by yolov8n.pt.
84
+ DEVICE_CLASSES = {62, 63, 67} # tv, laptop, cell phone
85
+
86
+
87
+ class InvalidImageError(ValueError):
88
+ """Raised when the upload is not a valid or acceptable image."""
89
+
90
+
91
+ def sha256_file(path: str | Path, chunk_size: int = 1024 * 1024) -> str:
92
+ digest = hashlib.sha256()
93
+ with open(path, "rb") as file:
94
+ while chunk := file.read(chunk_size):
95
+ digest.update(chunk)
96
+ return digest.hexdigest()
97
+
98
+
99
+ @contextlib.contextmanager
100
+ def allow_legacy_checkpoint_load():
101
+ """
102
+ The pinned PAN checkpoint is a legacy full-model PyTorch pickle.
103
+ This context is used only after the exact file hash is verified.
104
+ """
105
+ original_load = torch.load
106
+
107
+ def patched_load(*args: Any, **kwargs: Any):
108
+ kwargs["weights_only"] = False
109
+ return original_load(*args, **kwargs)
110
+
111
+ torch.load = patched_load
112
+ try:
113
+ yield
114
+ finally:
115
+ torch.load = original_load
116
+
117
+
118
+ def download_verified_pan_checkpoint() -> str:
119
+ path = hf_hub_download(
120
+ repo_id=PAN_MODEL_REPO,
121
+ filename=PAN_MODEL_FILENAME,
122
+ revision=PAN_MODEL_REVISION,
123
+ )
124
+ actual_hash = sha256_file(path)
125
+ if actual_hash != PAN_MODEL_SHA256:
126
+ raise RuntimeError(
127
+ "PAN model hash verification failed. "
128
+ f"Expected {PAN_MODEL_SHA256}, received {actual_hash}."
129
+ )
130
+ return path
131
+
132
+
133
+ def build_ocr_reader() -> PaddleOCR:
134
+ return PaddleOCR(
135
+ lang="en",
136
+ use_doc_orientation_classify=False,
137
+ use_doc_unwarping=False,
138
+ use_textline_orientation=False,
139
+ engine="paddle",
140
+ device="cpu",
141
+ enable_mkldnn=False,
142
+ cpu_threads=2,
143
+ text_rec_score_thresh=OCR_MIN_CONFIDENCE,
144
+ )
145
+
146
+
147
+ def decode_image(image_bytes: bytes) -> tuple[np.ndarray, int, int]:
148
+ if not image_bytes:
149
+ raise InvalidImageError("Uploaded file is empty.")
150
+
151
+ try:
152
+ with Image.open(io.BytesIO(image_bytes)) as image:
153
+ image = ImageOps.exif_transpose(image)
154
+ image.load()
155
+
156
+ width, height = image.size
157
+ if width < 64 or height < 64:
158
+ raise InvalidImageError("Image is too small. Minimum dimension is 64 pixels.")
159
+ if width * height > MAX_IMAGE_PIXELS:
160
+ raise InvalidImageError(
161
+ f"Image exceeds the {MAX_IMAGE_PIXELS:,}-pixel safety limit."
162
+ )
163
+
164
+ image_rgb = image.convert("RGB")
165
+ rgb_array = np.asarray(image_rgb)
166
+ except (UnidentifiedImageError, OSError, ValueError) as error:
167
+ if isinstance(error, InvalidImageError):
168
+ raise
169
+ raise InvalidImageError("The upload is not a readable JPG, JPEG, PNG, or WEBP image.") from error
170
+
171
+ bgr_array = cv2.cvtColor(rgb_array, cv2.COLOR_RGB2BGR)
172
+ return bgr_array, width, height
173
+
174
+
175
+ def extract_ocr_tokens(ocr_reader: PaddleOCR, image_bgr: np.ndarray) -> list[str]:
176
+ """Extract PaddleOCR 3.x text while tolerating minor result-shape differences."""
177
+ tokens: list[str] = []
178
+ results = ocr_reader.predict(image_bgr)
179
+
180
+ for result in results:
181
+ payload = getattr(result, "json", {})
182
+ if callable(payload):
183
+ payload = payload()
184
+ if isinstance(payload, str):
185
+ payload = json.loads(payload)
186
+ if not isinstance(payload, dict):
187
+ continue
188
+
189
+ data = payload.get("res", payload)
190
+ if not isinstance(data, dict):
191
+ continue
192
+
193
+ texts = data.get("rec_texts", []) or []
194
+ scores = data.get("rec_scores", []) or []
195
+
196
+ if len(scores) != len(texts):
197
+ scores = [1.0] * len(texts)
198
+
199
+ for text, score in zip(texts, scores):
200
+ cleaned = str(text).strip()
201
+ if cleaned and float(score) >= OCR_MIN_CONFIDENCE:
202
+ tokens.append(cleaned)
203
+
204
+ return tokens
205
+
206
+
207
+ def crop_with_padding(
208
+ image_bgr: np.ndarray,
209
+ xyxy: list[float],
210
+ padding_ratio: float = 0.03,
211
+ ) -> np.ndarray:
212
+ height, width = image_bgr.shape[:2]
213
+ x1, y1, x2, y2 = [float(value) for value in xyxy]
214
+ pad_x = (x2 - x1) * padding_ratio
215
+ pad_y = (y2 - y1) * padding_ratio
216
+
217
+ x1 = max(0, int(x1 - pad_x))
218
+ y1 = max(0, int(y1 - pad_y))
219
+ x2 = min(width, int(x2 + pad_x))
220
+ y2 = min(height, int(y2 + pad_y))
221
+
222
+ crop = image_bgr[y1:y2, x1:x2]
223
+ return crop if crop.size else image_bgr
224
+
225
+
226
+ def upscale_for_ocr(image_bgr: np.ndarray, target_width: int = 1400) -> np.ndarray:
227
+ height, width = image_bgr.shape[:2]
228
+ if width <= 0 or height <= 0:
229
+ return image_bgr
230
+
231
+ scale = max(1.0, target_width / width)
232
+ new_size = (int(width * scale), int(height * scale))
233
+ return cv2.resize(image_bgr, new_size, interpolation=cv2.INTER_CUBIC)
234
+
235
+
236
+ def enhance_for_ocr(image_bgr: np.ndarray) -> np.ndarray:
237
+ upscaled = upscale_for_ocr(image_bgr)
238
+ lab = cv2.cvtColor(upscaled, cv2.COLOR_BGR2LAB)
239
+ lightness, channel_a, channel_b = cv2.split(lab)
240
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
241
+ lightness = clahe.apply(lightness)
242
+ enhanced = cv2.cvtColor(
243
+ cv2.merge((lightness, channel_a, channel_b)),
244
+ cv2.COLOR_LAB2BGR,
245
+ )
246
+
247
+ blurred = cv2.GaussianBlur(enhanced, (0, 0), 1.0)
248
+ return cv2.addWeighted(enhanced, 1.45, blurred, -0.45, 0)
249
+
250
+
251
+ def build_ocr_variants(
252
+ card_bgr: np.ndarray,
253
+ full_image_bgr: np.ndarray,
254
+ ) -> list[tuple[str, np.ndarray]]:
255
+ variants: list[tuple[str, np.ndarray]] = []
256
+
257
+ card_upscaled = upscale_for_ocr(card_bgr)
258
+ card_enhanced = enhance_for_ocr(card_bgr)
259
+ variants.append(("card-upscaled", card_upscaled))
260
+ variants.append(("card-enhanced", card_enhanced))
261
+
262
+ height, width = card_enhanced.shape[:2]
263
+ lower_region = card_enhanced[
264
+ int(height * 0.45):int(height * 0.90),
265
+ 0:int(width * 0.82),
266
+ ]
267
+ if lower_region.size:
268
+ variants.append(("card-lower-region", lower_region))
269
+
270
+ variants.append(("full-image-enhanced", enhance_for_ocr(full_image_bgr)))
271
+ return variants
272
+
273
+
274
+ def normalize_pan_candidate(raw_candidate: str) -> str | None:
275
+ cleaned = re.sub(r"[^A-Z0-9]", "", raw_candidate.upper())
276
+ if len(cleaned) != 10:
277
+ return None
278
+
279
+ chars = list(cleaned)
280
+ corrections = 0
281
+ letter_positions = {0, 1, 2, 3, 4, 9}
282
+ digit_positions = {5, 6, 7, 8}
283
+
284
+ for index in letter_positions:
285
+ character = chars[index]
286
+ if "A" <= character <= "Z":
287
+ continue
288
+ replacement = LETTER_FIX.get(character)
289
+ if replacement is None:
290
+ return None
291
+ chars[index] = replacement
292
+ corrections += 1
293
+
294
+ for index in digit_positions:
295
+ character = chars[index]
296
+ if character.isdigit():
297
+ continue
298
+ replacement = DIGIT_FIX.get(character)
299
+ if replacement is None:
300
+ return None
301
+ chars[index] = replacement
302
+ corrections += 1
303
+
304
+ candidate = "".join(chars)
305
+
306
+ if corrections > MAX_OCR_CORRECTIONS:
307
+ return None
308
+ if not STRICT_PAN_REGEX.fullmatch(candidate):
309
+ return None
310
+ if candidate[3] not in PAN_ENTITY_MAP:
311
+ return None
312
+
313
+ return candidate
314
+
315
+
316
+ def windows_of_10(text: str):
317
+ cleaned = re.sub(r"[^A-Z0-9]", "", text.upper())
318
+ if len(cleaned) < 10:
319
+ return
320
+ for index in range(len(cleaned) - 9):
321
+ yield cleaned[index:index + 10]
322
+
323
+
324
+ def find_pan_number(ocr_tokens: list[str]) -> str | None:
325
+ sources = list(ocr_tokens)
326
+
327
+ # Join only nearby OCR lines; never concatenate the whole document blindly.
328
+ for group_size in (2, 3):
329
+ for start in range(len(ocr_tokens) - group_size + 1):
330
+ sources.append("".join(ocr_tokens[start:start + group_size]))
331
+
332
+ seen: set[str] = set()
333
+ for source in sources:
334
+ for block in windows_of_10(source):
335
+ if block in seen:
336
+ continue
337
+ seen.add(block)
338
+
339
+ normalized = normalize_pan_candidate(block)
340
+ if normalized:
341
+ return normalized
342
+
343
+ return None
344
+
345
+
346
+ def mask_pan(pan: str) -> str:
347
+ return f"{pan[:5]}****{pan[-1]}"
348
 
 
349
 
350
+ class PanKycEngine:
351
+ def __init__(self) -> None:
352
+ self.device_detector: YOLO | None = None
353
+ self.pan_detector: YOLO | None = None
354
+ self.ocr_reader: PaddleOCR | None = None
355
+ self.yolo_device: int | str = "cpu"
356
+ self.loaded = False
357
+ self._inference_lock = threading.Lock()
358
+
359
+ def load_models(self) -> None:
360
+ if self.loaded:
361
+ return
362
+
363
+ ENGINE_LOGGER.info("Loading PAN KYC models...")
364
+ self.yolo_device = 0 if torch.cuda.is_available() else "cpu"
365
+
366
+ self.device_detector = YOLO("yolov8n.pt")
367
+
368
+ pan_model_path = download_verified_pan_checkpoint()
369
+ with allow_legacy_checkpoint_load():
370
+ self.pan_detector = YOLO(pan_model_path)
371
+
372
+ self.ocr_reader = build_ocr_reader()
373
+ self.loaded = True
374
+ ENGINE_LOGGER.info("Models loaded. YOLO device=%s", self.yolo_device)
375
+
376
+ def _require_loaded(self) -> None:
377
+ if not self.loaded:
378
+ raise RuntimeError("Models are not loaded.")
379
+ if self.device_detector is None or self.pan_detector is None or self.ocr_reader is None:
380
+ raise RuntimeError("One or more models are unavailable.")
381
+
382
+ def _run_device_gate(self, image_bgr: np.ndarray) -> dict[str, Any]:
383
+ assert self.device_detector is not None
384
+ image_height, image_width = image_bgr.shape[:2]
385
+ image_area = max(1, image_height * image_width)
386
+
387
+ results = self.device_detector.predict(
388
+ image_bgr,
389
+ verbose=False,
390
+ device=self.yolo_device,
391
+ )
392
+ boxes = results[0].boxes
393
+
394
+ best_device: dict[str, Any] | None = None
395
+ if boxes is not None:
396
+ for class_tensor, confidence_tensor, coordinates_tensor in zip(
397
+ boxes.cls,
398
+ boxes.conf,
399
+ boxes.xyxy,
400
+ ):
401
+ class_id = int(class_tensor.item())
402
+ if class_id not in DEVICE_CLASSES:
403
+ continue
404
+
405
+ confidence = float(confidence_tensor.item())
406
+ x1, y1, x2, y2 = coordinates_tensor.tolist()
407
+ area_ratio = max(0.0, (x2 - x1) * (y2 - y1)) / image_area
408
+
409
+ if (
410
+ confidence >= DEVICE_CONFIDENCE_THRESHOLD
411
+ and area_ratio >= DEVICE_MIN_AREA_RATIO
412
+ ):
413
+ candidate = {
414
+ "name": str(self.device_detector.names[class_id]),
415
+ "class_id": class_id,
416
+ "confidence": round(confidence, 4),
417
+ "frame_area_ratio": round(area_ratio, 4),
418
+ }
419
+ if best_device is None or confidence > best_device["confidence"]:
420
+ best_device = candidate
421
+
422
+ return {
423
+ "passed": best_device is None,
424
+ "possible_device_presentation": best_device,
425
+ "note": "Heuristic only; this does not prove or disprove a spoof attack.",
426
+ }
427
+
428
+ def _run_pan_visual_gate(
429
+ self,
430
+ image_bgr: np.ndarray,
431
+ ) -> tuple[dict[str, Any], np.ndarray]:
432
+ assert self.pan_detector is not None
433
+ results = self.pan_detector.predict(
434
+ image_bgr,
435
+ verbose=False,
436
+ device=self.yolo_device,
437
+ )
438
+ boxes = results[0].boxes
439
+
440
+ best_confidence = 0.0
441
+ detected_card = image_bgr
442
+
443
+ if boxes is not None and len(boxes) > 0:
444
+ best_index = int(torch.argmax(boxes.conf).item())
445
+ best_confidence = float(boxes.conf[best_index].item())
446
+ if best_confidence >= PAN_DETECTION_THRESHOLD:
447
+ detected_card = crop_with_padding(
448
+ image_bgr,
449
+ boxes.xyxy[best_index].tolist(),
450
+ )
451
+
452
+ passed = best_confidence >= PAN_DETECTION_THRESHOLD
453
+ return (
454
+ {
455
+ "passed": passed,
456
+ "confidence": round(best_confidence, 4),
457
+ "threshold": PAN_DETECTION_THRESHOLD,
458
+ "note": "A detector match does not establish document authenticity.",
459
+ },
460
+ detected_card,
461
+ )
462
+
463
+ def _run_ocr_gate(
464
+ self,
465
+ card_bgr: np.ndarray,
466
+ full_image_bgr: np.ndarray,
467
+ debug: bool,
468
+ ) -> tuple[dict[str, Any], list[str]]:
469
+ assert self.ocr_reader is not None
470
+ variants = build_ocr_variants(card_bgr, full_image_bgr)
471
+
472
+ combined_tokens: list[str] = []
473
+ seen: set[str] = set()
474
+ successful_runs = 0
475
+ failures: list[str] = []
476
+ variant_counts: dict[str, int] = {}
477
+
478
+ for variant_name, variant_image in variants:
479
+ try:
480
+ variant_tokens = extract_ocr_tokens(self.ocr_reader, variant_image)
481
+ successful_runs += 1
482
+ variant_counts[variant_name] = len(variant_tokens)
483
+ except Exception as error: # Keep trying the remaining variants.
484
+ ENGINE_LOGGER.exception("OCR failed for variant %s", variant_name)
485
+ failures.append(f"{variant_name}: {type(error).__name__}: {error}")
486
+ continue
487
+
488
+ for token in variant_tokens:
489
+ key = re.sub(r"\s+", " ", token.strip().upper())
490
+ if key and key not in seen:
491
+ seen.add(key)
492
+ combined_tokens.append(token)
493
+
494
+ if find_pan_number(combined_tokens):
495
+ break
496
+
497
+ gate: dict[str, Any] = {
498
+ "passed": successful_runs > 0 and bool(combined_tokens),
499
+ "engine_ran_successfully": successful_runs > 0,
500
+ "successful_variant_runs": successful_runs,
501
+ "retained_line_count": len(combined_tokens),
502
+ "variant_line_counts": variant_counts,
503
+ }
504
+ if debug:
505
+ gate["ocr_tokens"] = combined_tokens
506
+ gate["failures"] = failures
507
+ elif failures:
508
+ gate["failure_count"] = len(failures)
509
+
510
+ return gate, combined_tokens
511
+
512
+ @staticmethod
513
+ def _base_response(
514
+ request_id: str,
515
+ filename: str,
516
+ width: int,
517
+ height: int,
518
+ ) -> dict[str, Any]:
519
+ return {
520
+ "request_id": request_id,
521
+ "filename": filename,
522
+ "image": {"width": width, "height": height},
523
+ "decision": None,
524
+ "status": None,
525
+ "failed_gate": None,
526
+ "reason": None,
527
+ "result": None,
528
+ "gates": {},
529
+ "disclaimer": (
530
+ "This endpoint performs preliminary image screening only. "
531
+ "It does not prove that a PAN card is genuine, unedited, or physically present."
532
+ ),
533
+ }
534
+
535
+ def analyze_bytes(
536
+ self,
537
+ image_bytes: bytes,
538
+ filename: str,
539
+ *,
540
+ include_full_pan: bool = False,
541
+ debug: bool = False,
542
+ ) -> dict[str, Any]:
543
+ self._require_loaded()
544
+ started = time.perf_counter()
545
+ request_id = uuid.uuid4().hex
546
+
547
+ image_bgr, width, height = decode_image(image_bytes)
548
+ response = self._base_response(request_id, filename, width, height)
549
+
550
+ # PaddleOCR and model objects are kept behind one lock for predictable
551
+ # behaviour on small CPU Spaces. Scale horizontally for real traffic.
552
+ with self._inference_lock:
553
+ gate1 = self._run_device_gate(image_bgr)
554
+ response["gates"]["gate_1_device_risk"] = gate1
555
+
556
+ if not gate1["passed"]:
557
+ response.update(
558
+ decision="rejected",
559
+ status="rejected_gate_1_device_risk",
560
+ failed_gate=1,
561
+ reason="A large phone, laptop, or TV was detected in the frame.",
562
+ )
563
+ response["processing_ms"] = round((time.perf_counter() - started) * 1000, 2)
564
+ return response
565
+
566
+ gate2, card_bgr = self._run_pan_visual_gate(image_bgr)
567
+ response["gates"]["gate_2_pan_visual"] = gate2
568
+
569
+ if not gate2["passed"]:
570
+ response.update(
571
+ decision="rejected",
572
+ status="rejected_gate_2_pan_not_detected",
573
+ failed_gate=2,
574
+ reason="No PAN-card-like region reached the configured confidence threshold.",
575
+ )
576
+ response["processing_ms"] = round((time.perf_counter() - started) * 1000, 2)
577
+ return response
578
+
579
+ gate3, ocr_tokens = self._run_ocr_gate(card_bgr, image_bgr, debug)
580
+ response["gates"]["gate_3_ocr"] = gate3
581
+
582
+ if not gate3["engine_ran_successfully"]:
583
+ response.update(
584
+ decision="error",
585
+ status="processing_error_gate_3_ocr",
586
+ failed_gate=3,
587
+ reason="The OCR engine failed before completing any OCR attempt.",
588
+ )
589
+ response["processing_ms"] = round((time.perf_counter() - started) * 1000, 2)
590
+ return response
591
+
592
+ if not ocr_tokens:
593
+ response.update(
594
+ decision="rejected",
595
+ status="rejected_gate_3_no_text",
596
+ failed_gate=3,
597
+ reason="OCR completed but returned no sufficiently confident text.",
598
+ )
599
+ response["processing_ms"] = round((time.perf_counter() - started) * 1000, 2)
600
+ return response
601
+
602
+ detected_pan = find_pan_number(ocr_tokens)
603
+ gate4 = {
604
+ "passed": detected_pan is not None,
605
+ "format": "AAAAA9999A",
606
+ "max_ocr_corrections": MAX_OCR_CORRECTIONS,
607
+ }
608
+ response["gates"]["gate_4_pan_validation"] = gate4
609
+
610
+ if detected_pan is None:
611
+ response.update(
612
+ decision="rejected",
613
+ status="rejected_gate_4_pan_not_found",
614
+ failed_gate=4,
615
+ reason="OCR text was found, but no valid PAN-format candidate was recovered.",
616
+ )
617
+ response["processing_ms"] = round((time.perf_counter() - started) * 1000, 2)
618
+ return response
619
+
620
+ entity_code = detected_pan[3]
621
+ response.update(
622
+ decision="accepted",
623
+ status="accepted_for_further_kyc_checks",
624
+ failed_gate=None,
625
+ reason="PAN format and entity character passed preliminary screening.",
626
+ result={
627
+ "pan_number": detected_pan if include_full_pan else mask_pan(detected_pan),
628
+ "pan_is_masked": not include_full_pan,
629
+ "masked_pan": mask_pan(detected_pan),
630
+ "entity_code": entity_code,
631
+ "classification": PAN_ENTITY_MAP[entity_code],
632
+ "routing": (
633
+ "PERSONAL_ROUTE" if entity_code == "P" else "BUSINESS_ENTITY_ROUTE"
634
+ ),
635
+ "authenticity_proven": False,
636
+ },
637
+ )
638
+
639
+ response["processing_ms"] = round((time.perf_counter() - started) * 1000, 2)
640
+ return response
641
+
642
+
643
+ # ========================= FASTAPI APPLICATION =========================
644
+
645
+ import hmac
646
+ import logging
647
+ import os
648
+ from contextlib import asynccontextmanager
649
+ from pathlib import Path
650
+ from typing import Annotated
651
+
652
+ from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, UploadFile
653
+ from fastapi.middleware.cors import CORSMiddleware
654
+ from fastapi.responses import JSONResponse
655
+ from starlette.concurrency import run_in_threadpool
656
+
657
+
658
+ logging.basicConfig(
659
+ level=os.getenv("LOG_LEVEL", "INFO").upper(),
660
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
661
+ )
662
+ API_LOGGER = logging.getLogger("pan_kyc_api")
663
+
664
+ MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "10"))
665
+ MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
666
+ API_KEY = os.getenv("API_KEY", "").strip()
667
+
668
+
669
+ def get_allowed_origins() -> list[str]:
670
+ raw = os.getenv("ALLOWED_ORIGINS", "*")
671
+ origins = [origin.strip() for origin in raw.split(",") if origin.strip()]
672
+ return origins or ["*"]
673
+
674
+
675
+ @asynccontextmanager
676
+ async def lifespan(app: FastAPI):
677
+ engine = PanKycEngine()
678
+ await run_in_threadpool(engine.load_models)
679
+ app.state.engine = engine
680
+ yield
681
+
682
+
683
+ app = FastAPI(
684
+ title="PAN KYC Screening API",
685
+ version="1.0.0",
686
+ description=(
687
+ "Preliminary PAN-image screening with a device-risk heuristic, "
688
+ "PAN-region detection, PaddleOCR, PAN format validation, and entity routing."
689
+ ),
690
+ lifespan=lifespan,
691
+ )
692
+
693
+ origins = get_allowed_origins()
694
  app.add_middleware(
695
  CORSMiddleware,
696
+ allow_origins=origins,
697
+ allow_credentials=origins != ["*"],
698
+ allow_methods=["GET", "POST"],
699
  allow_headers=["*"],
700
  )
701
 
702
+
703
+ def require_api_key(
704
+ x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
705
+ ) -> None:
706
+ """Require X-API-Key only when the API_KEY Space secret is configured."""
707
+ if not API_KEY:
708
+ return
709
+ if x_api_key is None or not hmac.compare_digest(x_api_key, API_KEY):
710
+ raise HTTPException(status_code=401, detail="Missing or invalid X-API-Key header.")
711
+
712
+
713
+ @app.get("/")
714
+ def root() -> dict:
715
+ return {
716
+ "service": "PAN KYC Screening API",
717
+ "status": "running",
718
+ "docs": "/docs",
719
+ "health": "/health",
720
+ "endpoint": "POST /analyze-pan",
721
+ }
722
+
723
+
724
+ @app.get("/health")
725
+ def health(request: Request) -> dict:
726
+ engine: PanKycEngine | None = getattr(request.app.state, "engine", None)
727
+ return {
728
+ "status": "ok" if engine and engine.loaded else "starting",
729
+ "models_loaded": bool(engine and engine.loaded),
730
+ "yolo_device": engine.yolo_device if engine else None,
731
+ }
732
+
733
+
734
+ @app.post("/analyze-pan", dependencies=[Depends(require_api_key)])
735
+ async def analyze_pan(
736
+ request: Request,
737
+ file: Annotated[UploadFile, File(description="PAN image: JPG, JPEG, PNG, or WEBP")],
738
+ include_full_pan: Annotated[
739
+ bool,
740
+ Query(description="Return the full detected PAN instead of a masked PAN."),
741
+ ] = False,
742
+ debug: Annotated[
743
+ bool,
744
+ Query(description="Include OCR tokens and variant failures. Use only for testing."),
745
+ ] = False,
746
+ ):
747
+ content_type = (file.content_type or "").lower()
748
+ if content_type and not (
749
+ content_type.startswith("image/") or content_type == "application/octet-stream"
750
+ ):
751
+ raise HTTPException(status_code=415, detail="Upload must be an image file.")
752
+
753
+ image_bytes = await file.read(MAX_UPLOAD_BYTES + 1)
754
+ await file.close()
755
+
756
+ if len(image_bytes) > MAX_UPLOAD_BYTES:
757
+ raise HTTPException(
758
+ status_code=413,
759
+ detail=f"Image exceeds the {MAX_UPLOAD_MB} MB upload limit.",
760
+ )
761
+
762
+ safe_filename = Path(file.filename or "uploaded-image").name
763
+ engine: PanKycEngine = request.app.state.engine
764
+
765
  try:
766
+ result = await run_in_threadpool(
767
+ engine.analyze_bytes,
768
+ image_bytes,
769
+ safe_filename,
770
+ include_full_pan=include_full_pan,
771
+ debug=debug,
772
+ )
773
+ except InvalidImageError as error:
774
+ raise HTTPException(status_code=422, detail=str(error)) from error
775
+ except Exception as error:
776
+ API_LOGGER.exception("Unexpected PAN analysis failure")
777
+ raise HTTPException(
778
+ status_code=503,
779
+ detail=f"PAN analysis service failed: {type(error).__name__}",
780
+ ) from error
781
+
782
+ # Return the detailed internal report only when debug=true.
783
+ if debug:
784
+ status_code = 503 if result.get("decision") == "error" else 200
785
+ return JSONResponse(status_code=status_code, content=result)
786
+
787
+ status = result.get("status")
788
+ request_id = result.get("request_id")
789
+
790
+ response_map = {
791
+ "rejected_gate_1_device_risk": (
792
+ "DEVICE_PRESENTATION_DETECTED",
793
+ "A phone, laptop, or TV was detected in the uploaded image.",
794
+ ),
795
+ "rejected_gate_2_pan_not_detected": (
796
+ "PAN_CARD_NOT_DETECTED",
797
+ "Uploaded image was not recognized as a PAN card.",
798
+ ),
799
+ "rejected_gate_3_no_text": (
800
+ "PAN_TEXT_NOT_READABLE",
801
+ "PAN card text could not be read clearly. Upload a clearer image.",
802
+ ),
803
+ "rejected_gate_4_pan_not_found": (
804
+ "PAN_NUMBER_NOT_FOUND",
805
+ "A PAN-like card was detected, but a valid PAN number was not found.",
806
+ ),
807
+ "processing_error_gate_3_ocr": (
808
+ "OCR_PROCESSING_ERROR",
809
+ "The OCR service could not process the image. Please try again.",
810
+ ),
811
+ }
812
+
813
+ if result.get("decision") == "accepted":
814
+ pan_result = result.get("result") or {}
815
+ compact_response = {
816
+ "request_id": request_id,
817
+ "success": True,
818
+ "valid_pan": True,
819
+ "status": "accepted",
820
+ "code": "VALID_PAN",
821
+ "message": "PAN card detected and PAN number validated.",
822
+ "data": {
823
+ "pan_number": pan_result.get("pan_number"),
824
+ "is_masked": pan_result.get("pan_is_masked", True),
825
+ "masked_pan": pan_result.get("masked_pan"),
826
+ "entity_code": pan_result.get("entity_code"),
827
+ "entity_type": pan_result.get("classification"),
828
+ "kyc_route": pan_result.get("routing"),
829
+ },
830
+ }
831
+ return JSONResponse(status_code=200, content=compact_response)
832
+
833
+ code, message = response_map.get(
834
+ status,
835
+ ("PAN_VALIDATION_FAILED", result.get("reason") or "PAN validation failed."),
836
+ )
837
+
838
+ is_processing_error = result.get("decision") == "error"
839
+ compact_response = {
840
+ "request_id": request_id,
841
+ "success": not is_processing_error,
842
+ "valid_pan": False,
843
+ "status": "error" if is_processing_error else "rejected",
844
+ "code": code,
845
+ "message": message,
846
+ "data": None,
847
+ }
848
+
849
+ return JSONResponse(
850
+ status_code=503 if is_processing_error else 200,
851
+ content=compact_response,
852
+ )