Spaces:
Sleeping
Sleeping
| """ | |
| KYC Document Validator — FastAPI service (PAN + Aadhaar). | |
| ONE model, loaded once, serves both document types. Runs fully locally: | |
| images never leave this server. | |
| Run: | |
| pip install fastapi "uvicorn[standard]" python-multipart transformers \ | |
| accelerate qwen-vl-utils ultralytics opencv-python-headless pillow torch | |
| uvicorn app:app --host 0.0.0.0 --port 8000 --workers 1 | |
| """ | |
| import os | |
| os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1") | |
| # os.environ.setdefault("HF_HUB_OFFLINE", "1") # enable AFTER first download | |
| import re | |
| import json | |
| import threading | |
| from contextlib import asynccontextmanager | |
| from typing import Optional | |
| import numpy as np | |
| import cv2 | |
| import torch | |
| from PIL import Image | |
| from fastapi import FastAPI, UploadFile, File, Form, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| from ultralytics import YOLO | |
| from ultralytics.nn.tasks import DetectionModel | |
| from transformers import Qwen2VLForConditionalGeneration, AutoProcessor | |
| from qwen_vl_utils import process_vision_info | |
| MODEL_ID = "Qwen/Qwen2-VL-2B-Instruct" | |
| _gpu_lock = threading.Lock() | |
| _state = {} | |
| # --------------------------------------------------------------------- | |
| # PAN | |
| # --------------------------------------------------------------------- | |
| PAN_ENTITY_MAP = { | |
| 'P': 'Person (Individual)', 'C': 'Company', 'F': 'Firm / Limited Liability Partnership (LLP)', | |
| 'H': 'Hindu Undivided Family (HUF)', 'T': 'Trust', 'A': 'Association of Persons (AOP)', | |
| 'B': 'Body of Individuals (BOI)', 'G': 'Government Agency', 'L': 'Local Authority', | |
| 'J': 'Artificial Juridical Person' | |
| } | |
| PAN_REGEX = re.compile(r'^[A-Z]{5}[0-9]{4}[A-Z]$') | |
| to_letter = str.maketrans({'0': 'O', '1': 'I', '2': 'Z', '5': 'S', '8': 'B'}) | |
| to_number = str.maketrans({'O': '0', 'I': '1', 'Z': '2', 'S': '5', 'B': '8', 'G': '6', 'Q': '0'}) | |
| def validate_pan(s): | |
| s = re.sub(r'[^A-Z0-9]', '', (s or '').upper()) | |
| if len(s) != 10: | |
| return None | |
| if PAN_REGEX.match(s): | |
| return s | |
| fixed = (s[0:5].translate(to_letter) | |
| + s[5:9].translate(to_number) | |
| + s[9:10].translate(to_letter)) | |
| return fixed if PAN_REGEX.match(fixed) else None | |
| # --------------------------------------------------------------------- | |
| # AADHAAR — validated with the Verhoeff checksum (last digit is a check digit) | |
| # --------------------------------------------------------------------- | |
| _VERHOEFF_D = [ | |
| [0,1,2,3,4,5,6,7,8,9],[1,2,3,4,0,6,7,8,9,5],[2,3,4,0,1,7,8,9,5,6], | |
| [3,4,0,1,2,8,9,5,6,7],[4,0,1,2,3,9,5,6,7,8],[5,9,8,7,6,0,4,3,2,1], | |
| [6,5,9,8,7,1,0,4,3,2],[7,6,5,9,8,2,1,0,4,3],[8,7,6,5,9,3,2,1,0,4], | |
| [9,8,7,6,5,4,3,2,1,0], | |
| ] | |
| _VERHOEFF_P = [ | |
| [0,1,2,3,4,5,6,7,8,9],[1,5,7,6,2,8,3,0,9,4],[5,8,0,3,7,9,6,1,4,2], | |
| [8,9,1,6,0,4,3,5,2,7],[9,4,5,3,1,2,6,8,7,0],[4,2,8,6,5,7,3,9,0,1], | |
| [2,7,9,3,8,0,6,4,1,5],[7,0,4,6,9,1,3,2,5,8], | |
| ] | |
| def _verhoeff_ok(num_str): | |
| c = 0 | |
| for i, ch in enumerate(reversed(num_str)): | |
| c = _VERHOEFF_D[c][_VERHOEFF_P[i % 8][int(ch)]] | |
| return c == 0 | |
| def validate_aadhaar(s): | |
| s = re.sub(r'\D', '', s or '') | |
| if len(s) != 12: | |
| return None | |
| if s[0] in '01': # Aadhaar never starts with 0 or 1 | |
| return None | |
| return s if _verhoeff_ok(s) else None | |
| def mask_aadhaar(s): | |
| return "XXXX XXXX " + s[-4:] | |
| # --------------------------------------------------------------------- | |
| # DOC CONFIGS (prompt + validator per type) | |
| # --------------------------------------------------------------------- | |
| PAN_PROMPT = ( | |
| "Look at this image. If it is an Indian PAN card, extract its fields. " | |
| "Return ONLY a JSON object, no other text:\n" | |
| '{"is_pan_card": true or false, "pan_number": "", "name": "", ' | |
| '"fathers_name": "", "date_of_birth": ""}\n' | |
| "The PAN number is exactly 10 characters: 5 letters, 4 digits, 1 letter " | |
| "(format ABCDE1234F). Copy it exactly. If not a PAN card, set is_pan_card to false." | |
| ) | |
| AADHAAR_PROMPT = ( | |
| "Look at this image. If it is an Indian Aadhaar card (UIDAI), extract its fields. " | |
| "Return ONLY a JSON object, no other text:\n" | |
| '{"is_aadhaar_card": true or false, "aadhaar_number": "", "name": "", ' | |
| '"date_of_birth": "", "gender": "", "address": ""}\n' | |
| "The Aadhaar number is exactly 12 digits, usually in three groups of four " | |
| "(example 1234 5678 9012). Copy the digits exactly. " | |
| "If not an Aadhaar card, set is_aadhaar_card to false." | |
| ) | |
| DOC_CONFIGS = { | |
| "pan": {"prompt": PAN_PROMPT, "type_key": "is_pan_card", "num_field": "pan_number", "validate": validate_pan}, | |
| "aadhaar": {"prompt": AADHAAR_PROMPT, "type_key": "is_aadhaar_card", "num_field": "aadhaar_number", "validate": validate_aadhaar}, | |
| } | |
| SPOOF_CLASSES = [62, 63, 67] # COCO: tv(monitor), laptop, cell phone | |
| SPOOF_CONFIDENCE = 0.35 | |
| # --------------------------------------------------------------------- | |
| # MODEL LOADING (once, at startup) | |
| # --------------------------------------------------------------------- | |
| def _load_models(): | |
| torch.serialization.add_safe_globals([DetectionModel]) | |
| _orig = torch.load | |
| torch.load = lambda *a, **k: _orig(*a, **{**k, 'weights_only': False}) | |
| spoof = YOLO('yolov8n.pt') | |
| torch.load = _orig | |
| use_cuda = torch.cuda.is_available() | |
| dtype = torch.bfloat16 if use_cuda else torch.float32 | |
| vlm = Qwen2VLForConditionalGeneration.from_pretrained( | |
| MODEL_ID, torch_dtype=dtype, | |
| device_map="auto" if use_cuda else None) | |
| if not use_cuda: | |
| vlm = vlm.to("cpu") | |
| proc = AutoProcessor.from_pretrained(MODEL_ID) | |
| _state.update(spoof=spoof, vlm=vlm, proc=proc) | |
| def _warmup(): | |
| try: | |
| _run_vlm(Image.new("RGB", (64, 64), (255, 255, 255)), PAN_PROMPT) | |
| except Exception: | |
| pass | |
| async def lifespan(app: FastAPI): | |
| print("Loading models (one time)...") | |
| _load_models() | |
| _warmup() | |
| print("Ready. Serving PAN + Aadhaar against one resident model.") | |
| yield | |
| _state.clear() | |
| app = FastAPI(title="KYC Document Validator", lifespan=lifespan) | |
| # For testing, allow any origin. In production, replace ["*"] with your | |
| # frontend's exact origin, e.g. ["https://kyc.yourcompany.com"]. | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["POST", "GET"], | |
| allow_headers=["*"], | |
| ) | |
| # --------------------------------------------------------------------- | |
| # PIPELINE | |
| # --------------------------------------------------------------------- | |
| def _detect_spoof(bgr): | |
| res = _state["spoof"].predict(bgr, verbose=False) | |
| for box in res[0].boxes: | |
| cid, conf = int(box.cls[0].item()), box.conf[0].item() | |
| if cid in SPOOF_CLASSES and conf > SPOOF_CONFIDENCE: | |
| return _state["spoof"].names[cid], float(conf) | |
| return None, 0.0 | |
| def _run_vlm(pil_img, prompt): | |
| proc, vlm = _state["proc"], _state["vlm"] | |
| messages = [{"role": "user", "content": [ | |
| {"type": "image", "image": pil_img}, | |
| {"type": "text", "text": prompt}, | |
| ]}] | |
| text = proc.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) | |
| image_inputs, video_inputs = process_vision_info(messages) | |
| inputs = proc(text=[text], images=image_inputs, videos=video_inputs, | |
| padding=True, return_tensors="pt").to(vlm.device) | |
| out = vlm.generate(**inputs, max_new_tokens=256, do_sample=False) | |
| trimmed = [o[len(i):] for i, o in zip(inputs.input_ids, out)] | |
| raw = proc.batch_decode(trimmed, skip_special_tokens=True)[0] | |
| raw = re.sub(r'```json|```', '', raw).strip() | |
| m = re.search(r'\{.*\}', raw, re.DOTALL) | |
| try: | |
| return json.loads(m.group()) if m else {} | |
| except json.JSONDecodeError: | |
| return {} | |
| class KycResult(BaseModel): | |
| document_type: str | |
| accepted: bool | |
| status: str | |
| reason: Optional[str] = None | |
| number: Optional[str] = None # PAN: full. Aadhaar: only if return_full_number=True | |
| number_masked: Optional[str] = None # Aadhaar masked (XXXX XXXX 1234) | |
| name: Optional[str] = None | |
| date_of_birth: Optional[str] = None | |
| fathers_name: Optional[str] = None # PAN only | |
| gender: Optional[str] = None # Aadhaar only | |
| classification: Optional[str] = None # PAN entity type | |
| routing: Optional[str] = None | |
| def _process(bgr, doc_type, return_full_number=False) -> KycResult: | |
| cfg = DOC_CONFIGS[doc_type] | |
| with _gpu_lock: | |
| device, _ = _detect_spoof(bgr) | |
| if device: | |
| return KycResult(document_type=doc_type, accepted=False, | |
| status="REJECTED_GATE1_SPOOF", | |
| reason=f"Detected '{device}' — looks like a photo of a screen.") | |
| pil = Image.fromarray(cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)) | |
| data = _run_vlm(pil, cfg["prompt"]) | |
| # is this actually the document type the frontend expected at this step? | |
| if not data.get(cfg["type_key"]): | |
| return KycResult(document_type=doc_type, accepted=False, | |
| status="REJECTED_TYPE_MISMATCH", | |
| reason=f"Expected a {doc_type.upper()} card, but the image isn't one.") | |
| number = cfg["validate"](data.get(cfg["num_field"])) | |
| if not number: | |
| return KycResult(document_type=doc_type, accepted=False, | |
| status="REJECTED_DATA_CHECK", | |
| reason=f"No valid {doc_type.upper()} number could be read.") | |
| if doc_type == "pan": | |
| ec = number[3] | |
| return KycResult(document_type="pan", accepted=True, status="ACCEPTED", | |
| number=number, name=data.get("name"), fathers_name=data.get("fathers_name"), | |
| date_of_birth=data.get("date_of_birth"), | |
| classification=PAN_ENTITY_MAP.get(ec, "Unknown Entity Classification"), | |
| routing="PERSONAL ROUTE" if ec == 'P' else "BUSINESS/ENTITY ROUTE") | |
| else: # aadhaar — checksum-verified; default to MASKED for privacy | |
| return KycResult(document_type="aadhaar", accepted=True, status="ACCEPTED", | |
| number=number if return_full_number else None, | |
| number_masked=mask_aadhaar(number), | |
| name=data.get("name"), date_of_birth=data.get("date_of_birth"), | |
| gender=data.get("gender")) | |
| # --------------------------------------------------------------------- | |
| # ENDPOINTS | |
| # --------------------------------------------------------------------- | |
| def health(): | |
| return {"status": "ok", "model_loaded": "vlm" in _state} | |
| def validate_document(doc_type: str = Form(...), file: UploadFile = File(...), | |
| return_full_number: bool = Form(False)): | |
| doc_type = (doc_type or "").lower().strip() | |
| if doc_type not in DOC_CONFIGS: | |
| raise HTTPException(400, "doc_type must be 'pan' or 'aadhaar'") | |
| img = cv2.imdecode(np.frombuffer(file.file.read(), np.uint8), cv2.IMREAD_COLOR) | |
| if img is None: | |
| raise HTTPException(400, "Could not decode image.") | |
| return _process(img, doc_type, return_full_number) | |
| # convenience aliases so the frontend can call the obvious one per step | |
| def validate_pan_endpoint(file: UploadFile = File(...)): | |
| img = cv2.imdecode(np.frombuffer(file.file.read(), np.uint8), cv2.IMREAD_COLOR) | |
| if img is None: | |
| raise HTTPException(400, "Could not decode image.") | |
| return _process(img, "pan") | |
| def validate_aadhaar_endpoint(file: UploadFile = File(...), | |
| return_full_number: bool = Form(False)): | |
| img = cv2.imdecode(np.frombuffer(file.file.read(), np.uint8), cv2.IMREAD_COLOR) | |
| if img is None: | |
| raise HTTPException(400, "Could not decode image.") | |
| return _process(img, "aadhaar", return_full_number) | |