Spaces:
Sleeping
Sleeping
| """PAN card field extraction and validation — pure OCR-based.""" | |
| from __future__ import annotations | |
| import re | |
| from ml_utils.ocr import OcrResult, get_full_text, get_average_confidence | |
| from ml_utils.extract import find_by_regex, find_value_near_label, get_text_in_region | |
| # ── Patterns ──────────────────────────────────────────────────────────── | |
| PAN_RE = re.compile(r"[A-Z]{5}\d{4}[A-Z]", re.IGNORECASE) | |
| DOB_RE = re.compile(r"\b(\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4})\b") | |
| NAME_LABELS = ["name", "नाम", "naam"] | |
| FATHER_LABELS = ["father", "father's name", "पिता", "father name"] | |
| DOB_LABELS = ["date of birth", "dob", "d.o.b", "birth", "जन्म तिथि"] | |
| def _fix_pan_chars(value: str) -> str: | |
| """Fix common OCR misreads in PAN numbers.""" | |
| if len(value) < 10: | |
| return value | |
| chars = list(value[:10]) | |
| # Positions 0-4 should be letters | |
| for i in range(5): | |
| if chars[i].isdigit(): | |
| chars[i] = {"0": "O", "1": "I", "5": "S", "8": "B"}.get(chars[i], chars[i]) | |
| # Positions 5-8 should be digits | |
| for i in range(5, 9): | |
| if not chars[i].isdigit(): | |
| chars[i] = {"O": "0", "I": "1", "S": "5", "B": "8", "l": "1", "o": "0"}.get(chars[i], chars[i]) | |
| # Position 9 should be a letter | |
| if len(chars) == 10 and not chars[9].isalpha(): | |
| chars[9] = {"0": "O", "1": "I"}.get(chars[9], chars[9]) | |
| return "".join(chars) | |
| def _cleanup_pan_text(text: str) -> str: | |
| """Clean and extract PAN number from OCR text.""" | |
| compact = text.upper().replace(" ", "").replace("-", "").replace(".", "") | |
| match = PAN_RE.search(compact) | |
| if match: | |
| return _fix_pan_chars(match.group(0).upper()) | |
| # Try with OCR error correction | |
| for candidate in re.findall(r"[A-Z0-9]{10}", compact): | |
| fixed = _fix_pan_chars(candidate) | |
| if PAN_RE.fullmatch(fixed): | |
| return fixed | |
| if len(compact) >= 10: | |
| return _fix_pan_chars(compact[-10:]) | |
| return compact | |
| # ── Parser ────────────────────────────────────────────────────────────── | |
| def parse_pan_fields(ocr_results: list[OcrResult]) -> dict: | |
| """Extract PAN card fields from OCR results.""" | |
| full_text = get_full_text(ocr_results) | |
| fields: dict = {} | |
| # PAN Number — regex on all text | |
| pan_matches = find_by_regex(ocr_results, r"[A-Z0-9]{5}\s?[A-Z0-9]{4}\s?[A-Z0-9]") | |
| pan_found = False | |
| for raw_match, _ in pan_matches: | |
| cleaned = _cleanup_pan_text(raw_match) | |
| if PAN_RE.fullmatch(cleaned): | |
| fields["pan_number"] = cleaned | |
| pan_found = True | |
| break | |
| if not pan_found: | |
| # Try on full text | |
| compact = re.sub(r"\s", "", full_text.upper()) | |
| match = PAN_RE.search(compact) | |
| if match: | |
| fields["pan_number"] = _fix_pan_chars(match.group(0)) | |
| else: | |
| for candidate in re.findall(r"[A-Z0-9]{10}", compact): | |
| fixed = _fix_pan_chars(candidate) | |
| if PAN_RE.fullmatch(fixed): | |
| fields["pan_number"] = fixed | |
| break | |
| # Name | |
| name = find_value_near_label(ocr_results, NAME_LABELS) | |
| if name: | |
| cleaned = re.sub(r"[^A-Za-z\s.]", "", name).strip() | |
| if len(cleaned) >= 2 and not PAN_RE.search(cleaned): | |
| fields["name"] = cleaned | |
| if "name" not in fields: | |
| # PAN cards: name is usually the 2nd or 3rd line from top | |
| top_text = get_text_in_region(ocr_results, y_start_pct=0.15, y_end_pct=0.50) | |
| for line in top_text.split("\n"): | |
| cleaned = re.sub(r"[^A-Za-z\s.]", "", line).strip() | |
| if (len(cleaned) >= 4 and " " in cleaned | |
| and not any(k in cleaned.lower() for k in ["income", "tax", "govt", "india", "permanent"])): | |
| fields["name"] = cleaned | |
| break | |
| # Father's Name | |
| father = find_value_near_label(ocr_results, FATHER_LABELS) | |
| if father: | |
| cleaned = re.sub(r"[^A-Za-z\s.]", "", father).strip() | |
| if len(cleaned) >= 2: | |
| fields["father_name"] = cleaned | |
| # DOB | |
| dob = find_value_near_label(ocr_results, DOB_LABELS) | |
| if dob: | |
| dob_match = DOB_RE.search(dob) | |
| if dob_match: | |
| fields["dob"] = dob_match.group(1) | |
| if "dob" not in fields: | |
| all_dobs = DOB_RE.findall(full_text) | |
| if all_dobs: | |
| fields["dob"] = all_dobs[0] | |
| # Signature presence (bottom region text) | |
| bottom_text = get_text_in_region(ocr_results, y_start_pct=0.75, y_end_pct=1.0) | |
| fields["signature_present"] = bool( | |
| re.search(r"signature|sign|हस्ताक्षर", bottom_text, re.IGNORECASE) | |
| ) | |
| return fields | |
| # ── Validator ─────────────────────────────────────────────────────────── | |
| REQUIRED_FIELDS = {"pan_number", "name"} | |
| def validate_pan(fields: dict, ocr_results: list[OcrResult]) -> tuple[float, list[str]]: | |
| """Validate PAN extraction. Returns (validation_score_0_to_20, flags).""" | |
| flags: list[str] = [] | |
| checks_total = 0 | |
| checks_passed = 0 | |
| # Required fields | |
| for req in REQUIRED_FIELDS: | |
| checks_total += 1 | |
| if fields.get(req): | |
| checks_passed += 1 | |
| else: | |
| flags.append(f"MISSING_{req.upper()}") | |
| # PAN format validation | |
| pan = fields.get("pan_number", "") | |
| if pan: | |
| checks_total += 1 | |
| if PAN_RE.fullmatch(pan): | |
| checks_passed += 1 | |
| fields["pan_validated"] = True | |
| else: | |
| flags.append("INVALID_PAN_FORMAT") | |
| fields["pan_validated"] = False | |
| else: | |
| flags.append("NO_PAN_OCR") | |
| fields["pan_validated"] = False | |
| # DOB format | |
| dob = fields.get("dob", "") | |
| if dob: | |
| checks_total += 1 | |
| if DOB_RE.search(dob): | |
| checks_passed += 1 | |
| else: | |
| flags.append("INVALID_DOB_FORMAT") | |
| # OCR confidence | |
| avg_conf = get_average_confidence(ocr_results) | |
| if avg_conf < 0.4: | |
| flags.append("LOW_OCR_CONFIDENCE") | |
| validation_ratio = checks_passed / max(checks_total, 1) | |
| return validation_ratio * 20.0, flags | |