Spaces:
Sleeping
Sleeping
| """Aadhaar card field extraction โ improved accuracy, clean output.""" | |
| 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, find_keyword, get_text_in_region | |
| # โโ OCR character correction โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # EasyOCR commonly confuses these in names/numbers | |
| _NUM_FIXES = str.maketrans({ | |
| 'O': '0', 'o': '0', 'I': '1', 'l': '1', | |
| 'S': '5', 'Z': '2', 'B': '8', 'G': '6', | |
| }) | |
| def _fix_number(text: str) -> str: | |
| return text.translate(_NUM_FIXES) | |
| def _fix_name(text: str) -> str: | |
| """Title-case and remove noise from a name string.""" | |
| cleaned = re.sub(r"[^A-Za-z\s.\-']", "", text).strip() | |
| # Remove isolated single chars that are noise | |
| parts = [p for p in cleaned.split() if len(p) > 1 or p == "A"] | |
| return " ".join(p.capitalize() for p in parts) | |
| # โโ Patterns โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| AADHAAR_RE = re.compile(r"\b(\d[\dO]{3}\s?[\dO]{4}\s?[\dO]{4})\b") | |
| DOB_RE = re.compile(r"\b(\d{1,2}[/\-\.]\d{1,2}[/\-\.]\d{2,4})\b") | |
| YEAR_RE = re.compile(r"\b(19\d{2}|20[01]\d)\b") | |
| PINCODE_RE = re.compile(r"\b([1-9]\d{5})\b") | |
| GENDER_RE = re.compile(r"\b(Male|Female|Transgender|เคชเฅเคฐเฅเคท|เคฎเคนเคฟเคฒเคพ)\b", re.IGNORECASE) | |
| INDIAN_STATES = [ | |
| "andhra pradesh","arunachal pradesh","assam","bihar","chhattisgarh","goa", | |
| "gujarat","haryana","himachal pradesh","jharkhand","karnataka","kerala", | |
| "madhya pradesh","maharashtra","manipur","meghalaya","mizoram","nagaland", | |
| "odisha","punjab","rajasthan","sikkim","tamil nadu","telangana","tripura", | |
| "uttar pradesh","uttarakhand","west bengal","delhi","chandigarh", | |
| "jammu","kashmir","ladakh", | |
| ] | |
| NAME_LABELS = ["name", "เคจเคพเคฎ", "naam"] | |
| DOB_LABELS = ["dob", "date of birth", "birth", "year of birth", "เคเคจเฅเคฎ", "d.o.b", "เคเคจเฅเคฎเคคเคฟเคฅเคฟ"] | |
| ADDRESS_LABELS = ["address", "เคชเคคเคพ", "addr", "s/o", "d/o", "w/o", "c/o"] | |
| # Verhoeff tables | |
| _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]] | |
| _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]] | |
| _INV = [0,4,3,2,1,5,6,7,8,9] | |
| def _verhoeff_ok(number: str) -> bool: | |
| digits = [int(d) for d in number if d.isdigit()] | |
| if len(digits) != 12: | |
| return False | |
| c = 0 | |
| for i, d in enumerate(reversed(digits)): | |
| c = _D[c][_P[i % 8][d]] | |
| return c == 0 | |
| # โโ Field extraction โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def parse_aadhaar_fields(ocr_results: list[OcrResult]) -> dict: | |
| full_text = get_full_text(ocr_results) | |
| fields: dict = {} | |
| # โโ Aadhaar Number โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # Search OCR results with Oโ0 correction | |
| for r in ocr_results: | |
| corrected = _fix_number(r.text) | |
| m = AADHAAR_RE.search(corrected) | |
| if m: | |
| raw = re.sub(r"\s", "", m.group(1)).replace('O','0') | |
| if len(raw) == 12 and raw.isdigit() and raw[0] != '0': | |
| fields["aadhaar_number_raw"] = raw | |
| break | |
| # Masked Aadhaar fallback | |
| if "aadhaar_number_raw" not in fields: | |
| masked = re.search(r"[Xx]{4}\s?[Xx]{4}\s?(\d{4})", full_text) | |
| if masked: | |
| fields["aadhaar_number_raw"] = f"XXXX-XXXX-{masked.group(1)}" | |
| fields["is_masked"] = True | |
| # โโ Name โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| name = find_value_near_label(ocr_results, NAME_LABELS) | |
| if name: | |
| fixed = _fix_name(name) | |
| if len(fixed) >= 3 and re.search(r"[A-Za-z]", fixed): | |
| fields["name"] = fixed | |
| if "name" not in fields: | |
| # Fallback: first all-caps or title-case line in top 45% that looks like a name | |
| top = get_text_in_region(ocr_results, y_start_pct=0.1, y_end_pct=0.55) | |
| for line in top.split("\n"): | |
| cleaned = re.sub(r"[^A-Za-z\s.\-']", "", line).strip() | |
| parts = cleaned.split() | |
| if 2 <= len(parts) <= 5 and all(len(p) >= 2 for p in parts): | |
| # Looks like a name (2-5 words, each 2+ chars) | |
| candidate = _fix_name(cleaned) | |
| if candidate and not any(w.lower() in ("government","india","uidai","unique") for w in parts): | |
| fields["name"] = candidate | |
| break | |
| # โโ Date of Birth โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| dob_raw = find_value_near_label(ocr_results, DOB_LABELS) | |
| if dob_raw: | |
| m = DOB_RE.search(dob_raw) | |
| if m: | |
| fields["date_of_birth"] = m.group(1) | |
| if "date_of_birth" not in fields: | |
| all_dobs = DOB_RE.findall(full_text) | |
| if all_dobs: | |
| fields["date_of_birth"] = all_dobs[0] | |
| else: | |
| years = YEAR_RE.findall(full_text) | |
| if years: | |
| fields["year_of_birth"] = years[0] | |
| # โโ Gender โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| g_match = GENDER_RE.search(full_text) | |
| if g_match: | |
| g = g_match.group(1).strip() | |
| if g in ("เคชเฅเคฐเฅเคท",): | |
| fields["gender"] = "Male" | |
| elif g in ("เคฎเคนเคฟเคฒเคพ",): | |
| fields["gender"] = "Female" | |
| else: | |
| fields["gender"] = g.capitalize() | |
| # โโ Address โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| addr_block = find_keyword(ocr_results, ADDRESS_LABELS) | |
| if addr_block: | |
| from ml_utils.extract import get_nearby_text | |
| nearby = get_nearby_text(ocr_results, addr_block, direction="below", max_blocks=7) | |
| raw_addr = " ".join([addr_block.text] + [r.text for r in nearby]) | |
| raw_addr = re.sub(r"^(?:address|เคชเคคเคพ|s/o|d/o|w/o|c/o)\s*[:\-]?\s*", "", raw_addr, flags=re.IGNORECASE).strip() | |
| if len(raw_addr) > 10: | |
| fields["address"] = raw_addr | |
| else: | |
| bottom = get_text_in_region(ocr_results, y_start_pct=0.55, y_end_pct=1.0) | |
| if bottom and len(bottom) > 15: | |
| fields["address"] = bottom.strip() | |
| # โโ PIN Code โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| search_text = fields.get("address", full_text) | |
| pin_m = PINCODE_RE.search(search_text) | |
| if pin_m: | |
| fields["pincode"] = pin_m.group(1) | |
| # โโ State โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| lower_text = full_text.lower() | |
| for state in INDIAN_STATES: | |
| if state in lower_text: | |
| fields["state"] = state.title() | |
| break | |
| return fields | |
| # โโ Validator โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| REQUIRED = {"aadhaar_number_raw", "name", "date_of_birth"} | |
| def validate_aadhaar(fields: dict, ocr_results: list[OcrResult]) -> tuple[float, list[str]]: | |
| flags: list[str] = [] | |
| passed = 0 | |
| total = 0 | |
| for req in REQUIRED: | |
| total += 1 | |
| if fields.get(req): | |
| passed += 1 | |
| else: | |
| flags.append(f"MISSING_{req.upper()}") | |
| # Aadhaar number checks | |
| num = fields.get("aadhaar_number_raw", "") | |
| clean = re.sub(r"[\s\-X]", "", num) | |
| if clean and clean.isdigit(): | |
| total += 1 | |
| if len(clean) == 12: | |
| passed += 1 | |
| total += 1 | |
| if _verhoeff_ok(clean): | |
| passed += 1 | |
| else: | |
| flags.append("AADHAAR_CHECKSUM_MISMATCH") | |
| else: | |
| flags.append("AADHAAR_NUMBER_INCOMPLETE") | |
| # DOB format | |
| dob = fields.get("date_of_birth", "") | |
| if dob: | |
| total += 1 | |
| if DOB_RE.search(dob): | |
| passed += 1 | |
| else: | |
| flags.append("INVALID_DATE_FORMAT") | |
| # Pincode | |
| pin = fields.get("pincode", "") | |
| if pin: | |
| total += 1 | |
| if re.fullmatch(r"[1-9]\d{5}", pin): | |
| passed += 1 | |
| else: | |
| flags.append("INVALID_PINCODE") | |
| # OCR confidence | |
| avg_conf = get_average_confidence(ocr_results) | |
| if avg_conf < 0.40: | |
| flags.append("LOW_OCR_CONFIDENCE") | |
| ratio = passed / max(total, 1) | |
| return ratio * 20.0, flags | |
| # โโ Clean output for display โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # Fields to NEVER show to the user (internal/technical) | |
| _HIDDEN_FIELDS = {"is_masked", "aadhaar_number_raw", "aadhaar_masked"} | |
| # Human-readable label mapping | |
| FIELD_LABELS = { | |
| "name": "Full Name", | |
| "date_of_birth": "Date of Birth", | |
| "year_of_birth": "Year of Birth", | |
| "gender": "Gender", | |
| "aadhaar_number_display": "Aadhaar Number", | |
| "address": "Address", | |
| "pincode": "PIN Code", | |
| "state": "State", | |
| } | |
| def build_extracted_output(fields: dict) -> dict: | |
| """Return only user-facing fields with clean labels and redacted Aadhaar.""" | |
| output = {} | |
| # Aadhaar display โ always redact, show only last 4 digits | |
| raw = fields.get("aadhaar_number_raw", "") | |
| clean_raw = re.sub(r"[\s\-]", "", raw) | |
| if clean_raw and clean_raw.isdigit() and len(clean_raw) == 12: | |
| output["aadhaar_number_display"] = f"XXXX XXXX {clean_raw[-4:]}" | |
| elif fields.get("is_masked"): | |
| # Already masked from source | |
| m = re.search(r"(\d{4})$", raw) | |
| if m: | |
| output["aadhaar_number_display"] = f"XXXX XXXX {m.group(1)}" | |
| # Copy user-facing fields | |
| for key in ["name", "date_of_birth", "year_of_birth", "gender", "address", "pincode", "state"]: | |
| if fields.get(key): | |
| output[key] = fields[key] | |
| return output | |