Monike123's picture
Deploy DocVerify FastAPI backend (EasyOCR + Gemini 3 Flash)
0e39d80
Raw
History Blame Contribute Delete
11.2 kB
"""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