import re from datetime import datetime, date from difflib import SequenceMatcher # ── Country-specific ID number validation ────────────────────────────── # MRZ/VLM nationality codes (ISO 3166-1 alpha-3) → validation rules. # SA uses Luhn check; others use regex only. COUNTRY_ID_FORMATS = { "ZAF": {"regex": r"^[0-9]{13}$", "name": "South Africa", "has_luhn": True}, "NGA": {"regex": r"^[0-9]{11}$", "name": "Nigeria", "has_luhn": False}, "KEN": {"regex": r"^[0-9]{1,9}$", "name": "Kenya", "has_luhn": False}, "ZWE": {"regex": r"^[0-9]{8,9}[A-Za-z]\d{2}$", "name": "Zimbabwe", "has_luhn": False}, "UGA": {"regex": r"^[A-Z0-9]{14}$", "name": "Uganda", "has_luhn": False}, "ZMB": {"regex": r"^[0-9]{10}$", "name": "Zambia", "has_luhn": False}, "CIV": {"regex": r"^[A-Z]{2}[0-9]+$", "name": "Côte d'Ivoire", "has_luhn": False}, } def validate_id_for_country(id_number: str, country_code: str) -> dict: """Validate an ID number against country-specific format rules. Args: id_number: The ID number to validate. country_code: ISO 3166-1 alpha-3 country code (e.g., 'ZAF', 'NGA'). Returns: Dict with 'valid' (bool), 'country' (str), 'method' (str). """ if not id_number or not country_code: return {"valid": False, "country": country_code, "method": "unknown"} fmt = COUNTRY_ID_FORMATS.get(country_code.upper()) if not fmt: # Unknown country — can't validate, not a failure return {"valid": True, "country": country_code, "method": "no_rules"} # Regex check if not re.match(fmt["regex"], id_number): return {"valid": False, "country": fmt["name"], "method": "regex"} # SA gets additional Luhn validation if fmt["has_luhn"]: luhn_ok = validate_luhn(id_number) return {"valid": luhn_ok, "country": fmt["name"], "method": "luhn" if luhn_ok else "luhn_failed"} return {"valid": True, "country": fmt["name"], "method": "regex"} # OCR commonly confuses these characters with digits. # Conservative set — only substitutions that are visually unambiguous. # Aggressive subs like S→5, B→8, G→6, D→0 create false ID numbers from normal text. OCR_DIGIT_SUBS = str.maketrans({ "O": "0", "o": "0", "I": "1", "l": "1", "|": "1", "Q": "0", }) FIELD_LABELS = { "id_number": ["IDENTITY NUMBER", "IDENTITY NO", "ID NUMBER", "ID NO", "I.D. NO", "I.D. NUMBER"], "surname": ["SURNAME", "LAST NAME"], "names": ["NAMES", "FIRST NAMES", "FORENAMES"], "date_of_birth": ["DATE OF BIRTH", "BIRTH DATE", "DOB"], "sex": ["SEX", "GENDER"], "country_of_birth": ["COUNTRY OF BIRTH", "PLACE OF BIRTH"], "nationality": ["NATIONALITY"], "citizenship_status": ["STATUS", "CITIZENSHIP", "S.A. CITIZEN", "SA CITIZEN"], } def validate_luhn(id_number: str) -> bool: """Validate a 13-digit SA ID number using the SA-specific Luhn algorithm. SA uses a non-standard Luhn variant: 1. Sum digits at odd positions (1st, 3rd, 5th... 1-indexed). 2. Concatenate digits at even positions into one number, multiply by 2, then sum the individual digits of the result. 3. Total = sum_odd + sum_even_digits. 4. Check digit = (10 - (total % 10)) % 10. """ if not id_number or len(id_number) != 13 or not id_number.isdigit(): return False digits = [int(d) for d in id_number] # Sum of odd-positioned digits (0, 2, 4, 6, 8, 10 in 0-indexed = 1st, 3rd, 5th...) odd_sum = sum(digits[i] for i in range(0, 12, 2)) # Concatenate even-positioned digits (1, 3, 5, 7, 9, 11 in 0-indexed) and multiply by 2 even_concat = int("".join(str(digits[i]) for i in range(1, 12, 2))) even_doubled = even_concat * 2 even_sum = sum(int(d) for d in str(even_doubled)) total = odd_sum + even_sum check_digit = (10 - (total % 10)) % 10 return check_digit == digits[12] def parse_id_number(id_number: str) -> dict: """Extract encoded fields from a valid SA ID number. SA ID format: YYMMDD SSSS C A Z - YYMMDD: Date of birth - SSSS: Gender (0000-4999=Female, 5000-9999=Male) - C: Citizenship (0=SA citizen, 1=permanent resident) - A: Usually 8 or 9 - Z: Luhn check digit """ result = { "date_of_birth": None, "sex": None, "citizenship": None, "is_valid": False, } if not id_number or len(id_number) != 13 or not id_number.isdigit(): return result result["is_valid"] = validate_luhn(id_number) # Parse date of birth yy = int(id_number[0:2]) mm = int(id_number[2:4]) dd = int(id_number[4:6]) # Century heuristic: if YY > current 2-digit year, assume 1900s current_yy = datetime.now().year % 100 year = 1900 + yy if yy > current_yy else 2000 + yy try: dob = date(year, mm, dd) result["date_of_birth"] = dob.isoformat() except ValueError: pass # Invalid date encoded in ID # Parse gender gender_seq = int(id_number[6:10]) result["sex"] = "Male" if gender_seq >= 5000 else "Female" # Parse citizenship citizenship_digit = int(id_number[10]) result["citizenship"] = "SA Citizen" if citizenship_digit == 0 else "Permanent Resident" return result def extract_id_number(text_blocks: list) -> tuple: """Find a 13-digit ID number in OCR text blocks. Tries raw text first, then applies OCR error corrections. Returns (id_number, confidence) or (None, 0). """ candidates = [] for item in text_blocks: if len(item) >= 3: _, text, confidence = item[0], item[1], item[2] else: text = item if isinstance(item, str) else str(item) confidence = 0.0 # Clean the text cleaned = re.sub(r"[\s\-\.]", "", text) # Try finding 13 consecutive digits directly matches = re.findall(r"\d{13}", cleaned) for match in matches: if validate_luhn(match): candidates.append((match, confidence)) # Apply OCR substitutions and try again substituted = cleaned.translate(OCR_DIGIT_SUBS) # Extract only digit characters after substitution digits_only = re.sub(r"[^0-9]", "", substituted) # Slide through looking for 13-digit sequences for i in range(len(digits_only) - 12): seq = digits_only[i:i + 13] if validate_luhn(seq) and seq not in [c[0] for c in candidates]: candidates.append((seq, confidence * 0.9)) # Slightly lower confidence for substituted if candidates: # Return the highest confidence match candidates.sort(key=lambda x: x[1], reverse=True) return candidates[0] return None, 0.0 def _bbox_center_y(bbox): """Get vertical center of a bounding box.""" return (bbox[0][1] + bbox[2][1]) / 2 def _bbox_center_x(bbox): """Get horizontal center of a bounding box.""" return (bbox[0][0] + bbox[2][0]) / 2 def _bbox_right(bbox): """Get right edge of a bounding box.""" return bbox[1][0] def _bbox_left(bbox): """Get left edge of a bounding box.""" return bbox[0][0] def _find_value_for_label(label_idx, results, y_tolerance=25, x_tolerance=50, skip_indices=None): """Find the OCR text value associated with a label. Looks to the right of the label first, then below it. Skips indices in skip_indices (other known labels) to avoid picking labels as values. """ if skip_indices is None: skip_indices = set() label_bbox = results[label_idx][0] label_y = _bbox_center_y(label_bbox) label_right = _bbox_right(label_bbox) label_left = _bbox_left(label_bbox) label_width = label_right - label_left label_height = label_bbox[2][1] - label_bbox[0][1] right_candidates = [] below_candidates = [] # Max horizontal gap for "right of label" — prevent picking text far away max_right_gap = max(label_width * 0.5, 100) # Scale below-label tolerances with label dimensions below_y_max = max(80, label_height * 5) effective_x_tolerance = max(x_tolerance, label_width * 0.4) for i, (bbox, text, conf) in enumerate(results): if i == label_idx or i in skip_indices: continue text_y = _bbox_center_y(bbox) text_left = _bbox_left(bbox) # Right of label: same vertical level, starts after label ends, not too far gap = text_left - label_right if abs(text_y - label_y) < y_tolerance and gap > -10 and gap < max_right_gap: right_candidates.append((gap, text, conf)) # Below label: lower vertical position, roughly same horizontal area elif 5 < (text_y - label_y) < below_y_max and abs(text_left - label_left) < effective_x_tolerance: distance = text_y - label_y below_candidates.append((distance, text, conf)) # Prefer right-of-label, fall back to below if right_candidates: right_candidates.sort(key=lambda x: x[0]) return right_candidates[0][1], right_candidates[0][2] if below_candidates: below_candidates.sort(key=lambda x: x[0]) return below_candidates[0][1], below_candidates[0][2] return None, 0.0 def _match_label(text, label_variants): """Check if text matches any label variant.""" text_upper = text.upper().strip() if len(text_upper) < 2: return False for variant in label_variants: # Exact match if text_upper == variant: return True # Substring match — require at least 50% overlap to avoid false positives if variant in text_upper or text_upper in variant: min_len = min(len(text_upper), len(variant)) max_len = max(len(text_upper), len(variant)) if min_len >= 3 and min_len / max_len > 0.5: return True # Fuzzy match for OCR errors in labels (>= 5 chars, >= 80% similarity) if len(text_upper) >= 5 and len(variant) >= 5: ratio = SequenceMatcher(None, text_upper, variant).ratio() if ratio >= 0.8: return True return False def extract_fields(ocr_results: list) -> dict: """Extract structured SA ID fields from EasyOCR output. Two-pass approach: 1. Identify all label positions in the OCR results. 2. For each label, find the nearest non-label text as the value. This prevents picking other labels (e.g. "Nationality") as values. Args: ocr_results: List of (bounding_box, text, confidence) from EasyOCR. Returns: Dict with extracted fields and confidence scores. """ fields = {} confidence = {} # Pass 1: Identify which OCR results are labels label_indices = {} # field_name -> index in ocr_results for field_name, label_variants in FIELD_LABELS.items(): for i, (bbox, text, conf) in enumerate(ocr_results): if _match_label(text, label_variants): label_indices[field_name] = i break # Set of all label indices — these should be skipped when looking for values all_label_idxs = set(label_indices.values()) # Pass 2: Find values for each identified label for field_name, label_idx in label_indices.items(): if field_name == "id_number": continue # Handled separately with OCR error correction value, value_conf = _find_value_for_label( label_idx, ocr_results, skip_indices=all_label_idxs ) if value: fields[field_name] = value.strip() confidence[field_name] = round(value_conf, 2) # Extract ID number using specialized logic (handles OCR errors) id_number, id_conf = extract_id_number(ocr_results) if id_number: fields["id_number"] = id_number confidence["id_number"] = round(id_conf, 2) # Normalize sex field if "sex" in fields: sex_upper = fields["sex"].upper().strip() if sex_upper in ("M", "MALE"): fields["sex"] = "Male" elif sex_upper in ("F", "FEMALE"): fields["sex"] = "Female" return {"fields": fields, "confidence": confidence} def _check_date_transposition(ocr_date: str, id_date: str) -> dict: """Check if day and month are swapped between OCR date and ID-encoded date. Common in SA because DD/MM and MM/DD formats are both used. Inspired by Smile Identity's verifyDOB approach. Returns dict with match status and details. """ result = {"exact_match": False, "transposed_match": False, "corrected_date": None} if not ocr_date or not id_date: return result normalized = _normalize_date(ocr_date) if not normalized: return result if normalized == id_date: result["exact_match"] = True return result # Try swapping day and month in the OCR date try: ocr_parts = normalized.split("-") if len(ocr_parts) == 3: year, month, day = ocr_parts swapped = f"{year}-{day}-{month}" # Only valid if both day and month are <= 12 (ambiguous dates) if int(day) <= 12 and int(month) <= 12: swapped_date = date(int(year), int(day), int(month)) swapped_str = swapped_date.isoformat() if swapped_str == id_date: result["transposed_match"] = True result["corrected_date"] = id_date except (ValueError, IndexError): pass return result def cross_validate(fields: dict, parsed_id: dict) -> dict: """Compare OCR-extracted fields against ID-number-encoded values. Returns validation results dict. """ validation = { "luhn_valid": parsed_id.get("is_valid", False), "dob_cross_check": None, "dob_transposed": False, "gender_cross_check": None, "citizenship_cross_check": None, } # DOB cross-check with transposition detection if parsed_id.get("date_of_birth") and fields.get("date_of_birth"): ocr_dob = fields["date_of_birth"].strip() id_dob = parsed_id["date_of_birth"] transposition = _check_date_transposition(ocr_dob, id_dob) if transposition["exact_match"]: validation["dob_cross_check"] = True elif transposition["transposed_match"]: # Day/month were swapped — auto-correct to the ID-encoded date validation["dob_cross_check"] = True validation["dob_transposed"] = True fields["date_of_birth"] = transposition["corrected_date"] else: normalized = _normalize_date(ocr_dob) validation["dob_cross_check"] = normalized == id_dob if normalized else None # Gender cross-check if parsed_id.get("sex") and fields.get("sex"): validation["gender_cross_check"] = ( fields["sex"].upper().strip() == parsed_id["sex"].upper() ) # Citizenship cross-check if parsed_id.get("citizenship") and fields.get("citizenship_status"): ocr_cit = fields["citizenship_status"].upper().strip() id_cit = parsed_id["citizenship"].upper() # Flexible matching: "SA CITIZEN" matches "CITIZEN", "RSA" etc. validation["citizenship_cross_check"] = ( "CITIZEN" in ocr_cit and "CITIZEN" in id_cit ) or ( "RESIDENT" in ocr_cit and "RESIDENT" in id_cit ) return validation def _normalize_date(date_str: str) -> str | None: """Try to parse various date formats into YYYY-MM-DD.""" date_str = date_str.strip() formats = [ "%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y", "%Y/%m/%d", "%d %b %Y", "%d %B %Y", "%Y %m %d", "%d %m %Y", ] for fmt in formats: try: return datetime.strptime(date_str, fmt).date().isoformat() except ValueError: continue return None