id-ocr-engine / engine /id_parser.py
Kevin-KES
Deploy current main to HF Space
50c6ee2
Raw
History Blame Contribute Delete
16 kB
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