| import os |
|
|
| |
| os.environ.setdefault("FLAGS_use_mkldnn", "1") |
| os.environ.setdefault("OMP_NUM_THREADS", "2") |
| os.environ.setdefault("MKL_NUM_THREADS", "2") |
|
|
| import contextlib |
| import hashlib |
| import io |
| import logging |
| import re |
| import tempfile |
| import threading |
| import time |
| from typing import Dict, List, Optional, Tuple |
|
|
| import cv2 |
| import numpy as np |
| import pypdfium2 as pdfium |
| import torch |
| from fastapi import FastAPI, File, UploadFile |
| from fastapi.middleware.cors import CORSMiddleware |
| from huggingface_hub import hf_hub_download |
| from PIL import Image, ImageOps |
| from ultralytics import YOLO |
|
|
| logging.getLogger("ppocr").setLevel(logging.ERROR) |
| logging.getLogger("paddleocr").setLevel(logging.ERROR) |
| from paddleocr import PaddleOCR |
|
|
| |
| |
| |
| APP_NAME = "2FAKYC PAN Validator" |
|
|
| |
| ENABLE_FALLBACK_OCR = True |
| ENABLE_FULL_IMAGE_FALLBACK = True |
| REJECT_TALL_SCREENSHOT_BAD_CROP = True |
|
|
| PAN_DETECTION_THRESHOLD = float(os.getenv("PAN_DETECTION_THRESHOLD", "0.50")) |
| YOLO_IMGSZ = int(os.getenv("YOLO_IMGSZ", "512")) |
| OCR_MIN_CONFIDENCE = float(os.getenv("OCR_MIN_CONFIDENCE", "0.30")) |
| MAX_OCR_CORRECTIONS = int(os.getenv("MAX_OCR_CORRECTIONS", "2")) |
| MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "12")) |
| MAX_PDF_PAGES = int(os.getenv("MAX_PDF_PAGES", "1")) |
|
|
| YOLO_DEVICE = "cpu" |
| torch.set_num_threads(2) |
| cv2.setNumThreads(2) |
|
|
| PAN_MODEL_REPO = "foduucom/pan-card-detection" |
| PAN_MODEL_REVISION = "5b6395bcfda0814d8817dc6a446fd70533f88a24" |
| PAN_MODEL_SHA256 = "a8721936f8585a53227445f997e1ebe10af5ba7faacd3602c01d65514c8dbbc8" |
|
|
| PAN_ENTITY_MAP = { |
| "P": "Individual", |
| "C": "Company", |
| "F": "Firm / LLP", |
| "H": "HUF", |
| "T": "Trust", |
| "A": "Association of Persons", |
| "B": "Body of Individuals", |
| "G": "Government Agency", |
| "L": "Local Authority", |
| "J": "Artificial Juridical Person", |
| } |
|
|
| LETTER_FIX = { |
| "0": "O", |
| "1": "I", |
| "2": "Z", |
| "5": "S", |
| "6": "G", |
| "8": "B", |
| } |
|
|
| DIGIT_FIX = { |
| "O": "0", |
| "Q": "0", |
| "D": "0", |
| "I": "1", |
| "L": "1", |
| "Z": "2", |
| "S": "5", |
| "G": "6", |
| "B": "8", |
| } |
|
|
| STRICT_PAN_REGEX = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$") |
|
|
| PAN_DIRECT_PATTERN = re.compile( |
| r"(?<![A-Z0-9])([A-Z0-9]{5}\s*-?\s*[0-9OQDILZSGBl]{4}\s*-?\s*[A-Z0-9])(?![A-Z0-9])", |
| re.IGNORECASE, |
| ) |
|
|
| BLOCKED_CONTEXT_WORDS = [ |
| "WHATSAPP", |
| "PHONE", |
| "MOBILE", |
| "CONTACT", |
| "EMAIL", |
| "MAIL", |
| "GMAIL", |
| "YAHOO", |
| "OUTLOOK", |
| "CALL", |
| "TEL", |
| ] |
|
|
| PAN_KEYWORDS = [ |
| "INCOME TAX", |
| "INCOME TAX DEPARTMENT", |
| "GOVT OF INDIA", |
| "GOVERNMENT OF INDIA", |
| "PERMANENT ACCOUNT NUMBER", |
| "PERMANENT ACCOUNT NUMBER CARD", |
| "PERMANENT ACCOUNT", |
| "ACCOUNT NUMBER", |
| "DATE OF BIRTH", |
| "DATE OF INCORPORATION", |
| "DATE OF FORMATION", |
| "INCORPORATION", |
| "FORMATION", |
| "SIGNATURE", |
| ] |
|
|
| NEGATIVE_DOCUMENT_KEYWORDS = [ |
| "AADHAAR", |
| "AADHAR", |
| "UNIQUE IDENTIFICATION", |
| "DRIVING LICENCE", |
| "DRIVER LICENSE", |
| "PASSPORT", |
| "VOTER", |
| "ELECTION COMMISSION", |
| ] |
|
|
| MODEL_LOCK = threading.Lock() |
|
|
| |
| |
| |
| app = FastAPI(title=APP_NAME) |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_credentials=False, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
|
|
| |
| |
| |
| def accepted_response(pan_number: str) -> Dict: |
| classification_code = pan_number[3] |
| classification_name = PAN_ENTITY_MAP[classification_code] |
|
|
| return { |
| "status": "accepted", |
| "message": "Valid PAN card.", |
| "data": { |
| "pan_number": pan_number, |
| "classification_code": classification_code, |
| "classification_name": classification_name, |
| "masked_pan": mask_pan_for_user_schema(pan_number), |
| "kyc_route": "standard", |
| }, |
| } |
|
|
|
|
| def rejected_response(message: str) -> Dict: |
| return { |
| "status": "rejected", |
| "message": str(message), |
| "data": {}, |
| } |
|
|
|
|
| |
| |
| |
| def sha256_file(path: str, chunk_size: int = 1024 * 1024) -> str: |
| digest = hashlib.sha256() |
| with open(path, "rb") as file: |
| while chunk := file.read(chunk_size): |
| digest.update(chunk) |
| return digest.hexdigest() |
|
|
|
|
| @contextlib.contextmanager |
| def allow_legacy_checkpoint_load(): |
| original_load = torch.load |
|
|
| def patched_load(*args, **kwargs): |
| kwargs["weights_only"] = False |
| return original_load(*args, **kwargs) |
|
|
| torch.load = patched_load |
| try: |
| yield |
| finally: |
| torch.load = original_load |
|
|
|
|
| print("Downloading PAN YOLO model...") |
| pan_model_path = hf_hub_download( |
| repo_id=PAN_MODEL_REPO, |
| filename="best.pt", |
| revision=PAN_MODEL_REVISION, |
| ) |
|
|
| actual_hash = sha256_file(pan_model_path) |
| if actual_hash != PAN_MODEL_SHA256: |
| raise RuntimeError( |
| "PAN model hash verification failed. " |
| f"Expected {PAN_MODEL_SHA256}, got {actual_hash}" |
| ) |
|
|
| print("Loading PAN YOLO detector...") |
| with allow_legacy_checkpoint_load(): |
| pan_detector = YOLO(pan_model_path) |
|
|
| print("Loading PaddleOCR...") |
| ocr_reader = PaddleOCR( |
| lang="en", |
| use_doc_orientation_classify=False, |
| use_doc_unwarping=False, |
| use_textline_orientation=False, |
| engine="paddle", |
| device="cpu", |
| enable_mkldnn=True, |
| cpu_threads=2, |
| text_rec_score_thresh=OCR_MIN_CONFIDENCE, |
| ) |
|
|
| print("Warming up models...") |
| dummy = np.ones((420, 680, 3), dtype=np.uint8) * 255 |
| try: |
| _ = pan_detector.predict( |
| dummy, |
| imgsz=YOLO_IMGSZ, |
| conf=0.25, |
| device=YOLO_DEVICE, |
| verbose=False, |
| ) |
| except Exception as error: |
| print("YOLO warmup warning:", error) |
|
|
| try: |
| _ = ocr_reader.predict(dummy) |
| except Exception as error: |
| print("OCR warmup warning:", error) |
|
|
| print("Models loaded successfully.") |
|
|
|
|
| |
| |
| |
| def compact_alnum(text: str) -> str: |
| return re.sub(r"[^A-Z0-9]", "", str(text).upper()) |
|
|
|
|
| def normalize_pan_candidate(raw_candidate: str) -> Optional[str]: |
| cleaned = compact_alnum(raw_candidate) |
|
|
| if len(cleaned) != 10: |
| return None |
|
|
| chars = list(cleaned) |
| corrections = 0 |
|
|
| letter_positions = {0, 1, 2, 3, 4, 9} |
| digit_positions = {5, 6, 7, 8} |
|
|
| for index in letter_positions: |
| char = chars[index] |
| if "A" <= char <= "Z": |
| continue |
| replacement = LETTER_FIX.get(char) |
| if replacement is None: |
| return None |
| chars[index] = replacement |
| corrections += 1 |
|
|
| for index in digit_positions: |
| char = chars[index] |
| if char.isdigit(): |
| continue |
| replacement = DIGIT_FIX.get(char) |
| if replacement is None: |
| return None |
| chars[index] = replacement |
| corrections += 1 |
|
|
| candidate = "".join(chars) |
|
|
| if corrections > MAX_OCR_CORRECTIONS: |
| return None |
| if not STRICT_PAN_REGEX.fullmatch(candidate): |
| return None |
| if candidate[3] not in PAN_ENTITY_MAP: |
| return None |
|
|
| return candidate |
|
|
|
|
| def has_blocked_context(text: str) -> bool: |
| compact = compact_alnum(text) |
| return any(word in compact for word in BLOCKED_CONTEXT_WORDS) |
|
|
|
|
| def find_pan_number(ocr_tokens: List[str]) -> Optional[str]: |
| """ |
| Safe PAN extraction. |
| Does not slide through long random text like Phone/Whatsapp:93448 35708. |
| """ |
| for token in ocr_tokens: |
| token = str(token).strip() |
| if not token: |
| continue |
| if has_blocked_context(token): |
| continue |
|
|
| for match in PAN_DIRECT_PATTERN.finditer(token): |
| candidate = normalize_pan_candidate(match.group(1)) |
| if candidate: |
| return candidate |
|
|
| compact = compact_alnum(token) |
| if len(compact) == 10: |
| candidate = normalize_pan_candidate(compact) |
| if candidate: |
| return candidate |
|
|
| |
| short_tokens = [] |
| for token in ocr_tokens: |
| token = str(token).strip() |
| if has_blocked_context(token): |
| continue |
| compact = compact_alnum(token) |
| if 1 <= len(compact) <= 10: |
| short_tokens.append(compact) |
|
|
| for start in range(len(short_tokens) - 1): |
| combined = short_tokens[start] + short_tokens[start + 1] |
| if len(combined) == 10: |
| candidate = normalize_pan_candidate(combined) |
| if candidate: |
| return candidate |
|
|
| return None |
|
|
|
|
| def mask_pan_for_user_schema(pan: str) -> str: |
| |
| return "XXXXX" + pan[5:9] + "X" |
|
|
|
|
| |
| |
| |
| def normalize_keyword_text(text: str) -> str: |
| text = str(text).upper() |
| text = re.sub(r"[^A-Z0-9\s]", " ", text) |
| text = re.sub(r"\s+", " ", text).strip() |
| return text |
|
|
|
|
| def compact_keyword_text(text: str) -> str: |
| return re.sub(r"[^A-Z0-9]", "", str(text).upper()) |
|
|
|
|
| def keyword_matches(full_text: str, keyword: str) -> bool: |
| normal_full = normalize_keyword_text(full_text) |
| compact_full = compact_keyword_text(full_text) |
| normal_keyword = normalize_keyword_text(keyword) |
| compact_keyword = compact_keyword_text(keyword) |
| return normal_keyword in normal_full or compact_keyword in compact_full |
|
|
|
|
| def calculate_pan_keyword_info(ocr_tokens: List[str]) -> Dict: |
| full_text = "\n".join([str(t) for t in ocr_tokens]) |
|
|
| matched_pan_keywords = [] |
| for keyword in PAN_KEYWORDS: |
| if keyword_matches(full_text, keyword): |
| matched_pan_keywords.append(keyword) |
|
|
| negative_matches = [] |
| for keyword in NEGATIVE_DOCUMENT_KEYWORDS: |
| if keyword_matches(full_text, keyword): |
| negative_matches.append(keyword) |
|
|
| return { |
| "matched_pan_keywords": matched_pan_keywords, |
| "negative_document_keywords": negative_matches, |
| "pan_keyword_score": len(matched_pan_keywords), |
| "negative_keyword_score": len(negative_matches), |
| } |
|
|
|
|
| |
| |
| |
| def extract_ocr_tokens(image_bgr: np.ndarray) -> List[str]: |
| tokens = [] |
| results = ocr_reader.predict(image_bgr) |
|
|
| for result in results: |
| payload = result.json |
| if callable(payload): |
| payload = payload() |
|
|
| data = payload.get("res", payload) |
| texts = data.get("rec_texts", []) |
| scores = data.get("rec_scores", []) |
|
|
| if len(scores) != len(texts): |
| scores = [1.0] * len(texts) |
|
|
| for text, score in zip(texts, scores): |
| text = str(text).strip() |
| if text and float(score) >= OCR_MIN_CONFIDENCE: |
| tokens.append(text) |
|
|
| return tokens |
|
|
|
|
| def image_bytes_to_bgr(file_bytes: bytes, filename: str) -> List[np.ndarray]: |
| ext = os.path.splitext(filename or "")[1].lower() |
|
|
| if ext == ".pdf": |
| images = [] |
| with tempfile.NamedTemporaryFile(suffix=".pdf", delete=True) as tmp: |
| tmp.write(file_bytes) |
| tmp.flush() |
| pdf = pdfium.PdfDocument(tmp.name) |
| pages_to_process = min(len(pdf), MAX_PDF_PAGES) |
| for page_index in range(pages_to_process): |
| page = pdf[page_index] |
| bitmap = page.render(scale=2.0) |
| pil_image = bitmap.to_pil().convert("RGB") |
| img_rgb = np.asarray(pil_image) |
| images.append(cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)) |
| return images |
|
|
| try: |
| pil_image = Image.open(io.BytesIO(file_bytes)) |
| pil_image = ImageOps.exif_transpose(pil_image).convert("RGB") |
| except Exception as error: |
| raise ValueError(f"Could not read uploaded file as image/PDF: {error}") |
|
|
| img_rgb = np.asarray(pil_image) |
| return [cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)] |
|
|
|
|
| def resize_input_for_speed(image_bgr: np.ndarray, max_side: int = 1280) -> np.ndarray: |
| height, width = image_bgr.shape[:2] |
| largest = max(height, width) |
|
|
| if largest <= max_side: |
| return image_bgr |
|
|
| scale = max_side / largest |
| new_width = int(width * scale) |
| new_height = int(height * scale) |
|
|
| return cv2.resize(image_bgr, (new_width, new_height), interpolation=cv2.INTER_AREA) |
|
|
|
|
| def crop_with_padding(image_bgr: np.ndarray, xyxy, padding_ratio: float = 0.04) -> np.ndarray: |
| height, width = image_bgr.shape[:2] |
| x1, y1, x2, y2 = [float(value) for value in xyxy] |
|
|
| pad_x = (x2 - x1) * padding_ratio |
| pad_y = (y2 - y1) * padding_ratio |
|
|
| x1 = max(0, int(x1 - pad_x)) |
| y1 = max(0, int(y1 - pad_y)) |
| x2 = min(width, int(x2 + pad_x)) |
| y2 = min(height, int(y2 + pad_y)) |
|
|
| crop = image_bgr[y1:y2, x1:x2] |
| return crop if crop.size else image_bgr |
|
|
|
|
| def resize_for_ocr_fast( |
| image_bgr: np.ndarray, |
| min_width: int = 750, |
| max_width: int = 1100, |
| max_side: int = 1100, |
| allow_upscale: bool = True, |
| ) -> np.ndarray: |
| height, width = image_bgr.shape[:2] |
| if width <= 0 or height <= 0: |
| return image_bgr |
|
|
| largest = max(height, width) |
|
|
| |
| if largest > max_side: |
| scale = max_side / largest |
| new_width = int(width * scale) |
| new_height = int(height * scale) |
| return cv2.resize(image_bgr, (new_width, new_height), interpolation=cv2.INTER_AREA) |
|
|
| |
| if allow_upscale and width < min_width: |
| scale = min_width / width |
| elif width > max_width: |
| scale = max_width / width |
| else: |
| return image_bgr |
|
|
| new_width = int(width * scale) |
| new_height = int(height * scale) |
| return cv2.resize(image_bgr, (new_width, new_height), interpolation=cv2.INTER_LINEAR) |
|
|
|
|
| def enhance_for_ocr_fast(image_bgr: np.ndarray, allow_upscale: bool = True) -> np.ndarray: |
| image_bgr = resize_for_ocr_fast(image_bgr, allow_upscale=allow_upscale) |
| gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY) |
| clahe = cv2.createCLAHE(clipLimit=1.8, tileGridSize=(8, 8)) |
| gray = clahe.apply(gray) |
| return cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) |
|
|
|
|
| def is_tall_mobile_screenshot(image_bgr: np.ndarray) -> bool: |
| height, width = image_bgr.shape[:2] |
| if width <= 0: |
| return False |
| return (height / width) >= 1.65 |
|
|
|
|
| |
| |
| |
| def get_box_metrics(image_bgr: np.ndarray, box) -> Dict: |
| height, width = image_bgr.shape[:2] |
| image_area = max(1, height * width) |
|
|
| x1, y1, x2, y2 = [float(v) for v in box] |
| box_w = max(1.0, x2 - x1) |
| box_h = max(1.0, y2 - y1) |
| box_area = box_w * box_h |
|
|
| return { |
| "area_ratio": round(box_area / image_area, 4), |
| "aspect_ratio": round(box_w / box_h, 4), |
| "box_width": round(box_w, 2), |
| "box_height": round(box_h, 2), |
| } |
|
|
|
|
| def is_bad_yolo_crop(image_bgr: np.ndarray, box) -> bool: |
| metrics = get_box_metrics(image_bgr, box) |
| area_ratio = metrics["area_ratio"] |
| aspect_ratio = metrics["aspect_ratio"] |
|
|
| |
| if area_ratio < 0.08: |
| return True |
|
|
| |
| if aspect_ratio < 1.15 or aspect_ratio > 3.20: |
| return True |
|
|
| return False |
|
|
|
|
| def run_pan_yolo(image_bgr: np.ndarray) -> Dict: |
| start = time.time() |
| results = pan_detector.predict( |
| image_bgr, |
| imgsz=YOLO_IMGSZ, |
| conf=0.25, |
| device=YOLO_DEVICE, |
| verbose=False, |
| ) |
| elapsed = round(time.time() - start, 3) |
|
|
| boxes = results[0].boxes |
|
|
| if boxes is None or len(boxes) == 0: |
| return { |
| "detected": False, |
| "confidence": 0.0, |
| "box": None, |
| "crop": None, |
| "bad_crop": True, |
| "metrics": None, |
| "elapsed_seconds": elapsed, |
| } |
|
|
| best_index = int(torch.argmax(boxes.conf).item()) |
| best_confidence = float(boxes.conf[best_index].item()) |
| best_box = boxes.xyxy[best_index].tolist() |
| metrics = get_box_metrics(image_bgr, best_box) |
|
|
| if best_confidence < PAN_DETECTION_THRESHOLD: |
| return { |
| "detected": False, |
| "confidence": round(best_confidence, 4), |
| "box": [round(float(x), 2) for x in best_box], |
| "crop": None, |
| "bad_crop": True, |
| "metrics": metrics, |
| "elapsed_seconds": elapsed, |
| } |
|
|
| bad_crop = is_bad_yolo_crop(image_bgr, best_box) |
| crop = image_bgr if bad_crop else crop_with_padding(image_bgr, best_box) |
|
|
| return { |
| "detected": True, |
| "confidence": round(best_confidence, 4), |
| "box": [round(float(x), 2) for x in best_box], |
| "crop": crop, |
| "bad_crop": bad_crop, |
| "metrics": metrics, |
| "elapsed_seconds": elapsed, |
| } |
|
|
|
|
| def run_fast_ocr_with_fallback( |
| card_bgr: np.ndarray, |
| full_image_bgr: Optional[np.ndarray] = None, |
| yolo_bad_crop: bool = False, |
| ) -> Dict: |
| combined_tokens = [] |
| seen = set() |
|
|
| def add_tokens(tokens: List[str]) -> None: |
| for token in tokens: |
| key = re.sub(r"\s+", " ", str(token).strip().upper()) |
| if key and key not in seen: |
| seen.add(key) |
| combined_tokens.append(token) |
|
|
| |
| first_image = enhance_for_ocr_fast(card_bgr, allow_upscale=not yolo_bad_crop) |
| tokens = extract_ocr_tokens(first_image) |
| add_tokens(tokens) |
| detected_pan = find_pan_number(combined_tokens) |
|
|
| |
| if ENABLE_FALLBACK_OCR and not detected_pan and not yolo_bad_crop: |
| h, w = first_image.shape[:2] |
| pan_region = first_image[int(h * 0.25):int(h * 0.85), 0:int(w * 0.95)] |
| if pan_region.size: |
| tokens = extract_ocr_tokens(pan_region) |
| add_tokens(tokens) |
| detected_pan = find_pan_number(combined_tokens) |
|
|
| |
| if ( |
| ENABLE_FULL_IMAGE_FALLBACK |
| and full_image_bgr is not None |
| and not detected_pan |
| and not yolo_bad_crop |
| ): |
| full_image = enhance_for_ocr_fast(full_image_bgr, allow_upscale=False) |
| tokens = extract_ocr_tokens(full_image) |
| add_tokens(tokens) |
| detected_pan = find_pan_number(combined_tokens) |
|
|
| return { |
| "tokens": combined_tokens, |
| "detected_pan": detected_pan, |
| } |
|
|
|
|
| |
| |
| |
| def analyze_pan_image(image_bgr: np.ndarray) -> Dict: |
| image_bgr = resize_input_for_speed(image_bgr, max_side=1280) |
| tall_screenshot = is_tall_mobile_screenshot(image_bgr) |
|
|
| |
| yolo_result = run_pan_yolo(image_bgr) |
|
|
| if not yolo_result["detected"]: |
| return rejected_response("Not a PAN card") |
|
|
| |
| if REJECT_TALL_SCREENSHOT_BAD_CROP and tall_screenshot and yolo_result["bad_crop"]: |
| return rejected_response("Not a PAN card") |
|
|
| |
| ocr_result = run_fast_ocr_with_fallback( |
| card_bgr=yolo_result["crop"], |
| full_image_bgr=image_bgr, |
| yolo_bad_crop=yolo_result["bad_crop"], |
| ) |
|
|
| ocr_tokens = ocr_result["tokens"] |
| detected_pan = ocr_result["detected_pan"] |
| keyword_info = calculate_pan_keyword_info(ocr_tokens) |
|
|
| if not ocr_tokens: |
| return rejected_response("Image is blurry or text is not readable") |
|
|
| if not detected_pan: |
| return rejected_response("PAN number not readable") |
|
|
| |
| if keyword_info["negative_keyword_score"] > 0 and keyword_info["pan_keyword_score"] == 0: |
| return rejected_response("Not a PAN card") |
|
|
| |
| if keyword_info["pan_keyword_score"] < 1: |
| return rejected_response("Not a PAN card") |
|
|
| return accepted_response(detected_pan) |
|
|
|
|
| |
| |
| |
| @app.get("/") |
| def root(): |
| return {"ok": True, "service": APP_NAME, "endpoint": "/predict"} |
|
|
|
|
| @app.get("/health") |
| def health(): |
| return { |
| "ok": True, |
| "models_loaded": True, |
| "yolo_imgsz": YOLO_IMGSZ, |
| "pan_detection_threshold": PAN_DETECTION_THRESHOLD, |
| } |
|
|
|
|
| @app.post("/predict") |
| async def predict(file: UploadFile = File(...)): |
| try: |
| file_bytes = await file.read() |
|
|
| if not file_bytes: |
| return rejected_response("No file uploaded") |
|
|
| if len(file_bytes) > MAX_UPLOAD_MB * 1024 * 1024: |
| return rejected_response(f"File too large. Max allowed size is {MAX_UPLOAD_MB} MB") |
|
|
| images_bgr = image_bytes_to_bgr(file_bytes, file.filename or "upload") |
|
|
| if not images_bgr: |
| return rejected_response("Could not read image") |
|
|
| |
| with MODEL_LOCK: |
| |
| last_rejection = rejected_response("Not a PAN card") |
| for image_bgr in images_bgr: |
| result = analyze_pan_image(image_bgr) |
| if result.get("status") == "accepted": |
| return result |
| last_rejection = result |
|
|
| return last_rejection |
|
|
| except Exception as error: |
| return rejected_response(f"Processing error: {type(error).__name__}: {error}") |