Kevin-KES
Deploy current main to HF Space
50c6ee2
Raw
History Blame Contribute Delete
26.5 kB
#Imports
import logging
import os
import threading
import cv2
import numpy as np
from engine.id_parser import (
extract_fields, extract_id_number, parse_id_number,
cross_validate, validate_luhn, validate_id_for_country,
)
logger = logging.getLogger(__name__)
# Suppress PaddleOCR network checks on startup
os.environ.setdefault("PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK", "True")
# Disable OneDNN/PIR to avoid unsupported op errors on some CPUs
os.environ.setdefault("FLAGS_use_mkldnn", "0")
os.environ.setdefault("FLAGS_enable_pir_api", "0")
os.environ.setdefault("FLAGS_enable_pir_in_executor", "0")
SUPPORTED_DOC_TYPES = {"sa_id_card", "sa_id_book", "passport"}
_ocr = None
_ocr_lock = threading.Lock()
# Text fragments that come from phone watermarks / UI, not the ID itself
_WATERMARK_PATTERNS = [
"BLACKVIEW", "SAMSUNG", "HUAWEI", "XIAOMI", "REDMI", "OPPO", "VIVO",
"TECNO", "INFINIX", "REALME", "MOTOROLA", "NOKIA", "LG",
"TAB 60", "TAB 70", "TAB 80",
"REPLY", "WHATSAPP", "TELEGRAM", "MAMA BOYZ",
"CAMSCANNER", "SCREENSHOT",
]
# Minimum resolution (long edge) per doc_type
_MIN_RESOLUTION = {
"sa_id_card": 1500,
"sa_id_book": 1500,
"passport": 2200,
}
def _get_ocr():
"""Lazy-initialize PaddleOCR. First call downloads models (~100MB)."""
global _ocr
if _ocr is not None:
return _ocr
with _ocr_lock:
if _ocr is not None:
return _ocr
logger.info("Initializing PaddleOCR (first load downloads models)...")
import paddle
paddle.set_flags({
"FLAGS_use_mkldnn": 0,
"FLAGS_enable_pir_api": 0,
"FLAGS_enable_pir_in_executor": 0,
})
from paddleocr import PaddleOCR
_ocr = PaddleOCR(lang="en", enable_mkldnn=False)
logger.info("PaddleOCR initialized")
return _ocr
# ── Image quality checks ────────────────────────────────────────────────
def _estimate_brightness(gray: np.ndarray) -> float:
return float(np.mean(gray))
def _estimate_blur(gray: np.ndarray) -> float:
"""Laplacian variance — lower = blurrier. Typical sharp image > 100."""
return float(cv2.Laplacian(gray, cv2.CV_64F).var())
def check_image_quality(image_path: str, doc_type: str = "sa_id_card") -> dict:
"""Pre-flight quality checks before OCR.
Returns dict with quality metrics and an 'usable' flag.
"""
img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
if img is None:
return {"usable": False, "reason": "unreadable", "resolution_ok": False}
h, w = img.shape[:2]
brightness = _estimate_brightness(img)
blur_score = _estimate_blur(img)
long_edge = max(h, w)
min_res = _MIN_RESOLUTION.get(doc_type, 1500)
issues = []
if long_edge < 300:
issues.append("too_small")
if brightness < 30:
issues.append("too_dark")
if brightness > 245:
issues.append("too_bright")
if blur_score < 15:
issues.append("too_blurry")
return {
"usable": len(issues) == 0,
"resolution_ok": long_edge >= min_res,
"width": w,
"height": h,
"brightness": round(brightness, 1),
"blur_score": round(blur_score, 1),
"issues": issues,
}
# ── PaddleOCR execution ─────────────────────────────────────────────────
def _convert_paddle_results(paddle_result) -> tuple[list, int]:
"""Convert PaddleOCR v3.4 result to (bbox, text, conf) tuples."""
r = paddle_result[0]
rotation = 0
if "doc_preprocessor_res" in r:
rotation = r["doc_preprocessor_res"].get("angle", 0) or 0
results = []
polys = r.get("dt_polys", [])
texts = r.get("rec_texts", [])
scores = r.get("rec_scores", [])
for poly, text, score in zip(polys, texts, scores):
bbox = poly.tolist() if hasattr(poly, "tolist") else list(poly)
results.append((bbox, text, float(score)))
return results, rotation
def run_ocr(image_path: str) -> tuple[list, int]:
"""Run PaddleOCR on an image file. Returns (results_list, rotation_degrees)."""
ocr = _get_ocr()
paddle_result = ocr.ocr(image_path)
return _convert_paddle_results(paddle_result)
def run_ocr_on_array(img_bgr: np.ndarray) -> list:
"""Run PaddleOCR on a BGR numpy array (for fallback passes)."""
ocr = _get_ocr()
paddle_result = ocr.ocr(img_bgr)
results, _ = _convert_paddle_results(paddle_result)
return results
# ── Watermark / UI text filtering ───────────────────────────────────────
def _is_watermark(text: str) -> bool:
"""Check if an OCR text block is a phone watermark or UI element."""
upper = text.upper().strip()
if len(upper) < 2:
return False
for pattern in _WATERMARK_PATTERNS:
if pattern in upper:
return True
if len(upper) >= 10 and upper[:4].isdigit() and "-" in upper[:10]:
return True
return False
def _filter_watermarks(ocr_results: list) -> list:
"""Remove phone watermarks and UI text from OCR results."""
return [r for r in ocr_results if not _is_watermark(r[1])]
# ── Side classification ─────────────────────────────────────────────────
def classify_id_side(ocr_results: list) -> str:
text_upper = " ".join(text.upper() for _, text, _ in ocr_results)
front_keywords = [
"SURNAME", "NAMES", "IDENTITY NUMBER", "DATE OF BIRTH",
"SEX", "NATIONALITY", "STATUS", "CITIZEN", "REPUBLIC OF SOUTH AFRICA",
"NATIONAL IDENTITY", "FORENAMES", "COUNTRY OF BIRTH",
"VAN/SURNAME", "VOORNAME", "GEBOORTEDATUM", "I.D.NO", "I D NO",
"BURGER", "IDENTITEITSNOMMER",
]
back_keywords = [
"CONDITIONS", "DATE OF ISSUE", "DEPARTMENT OF HOME AFFAIRS",
"IDENTIFICATION ACT", "IF FOUND", "ENQUIRY", "VERIFICATION",
"ACT 68", "DATUM UITGEREIK", "KONDISIES",
"REGISTERED RESIDENTIAL", "POSTAL ADDRESS", "POSADRES",
]
front_score = sum(1 for kw in front_keywords if kw in text_upper)
back_score = sum(1 for kw in back_keywords if kw in text_upper)
if back_score > front_score and front_score < 2:
return "back"
return "front"
# ── Confidence thresholds ────────────────────────────────────────────────
CONFIDENCE_PASS = 0.80
CONFIDENCE_FAIL = 0.40
def _compute_overall_result(fields: dict, confidence: dict, checks: dict, extraction_method: str = "") -> str:
"""Determine pass / inconclusive / fail based on extracted data and checks."""
has_id = bool(fields.get("id_number") or fields.get("passport_number"))
has_name = bool(fields.get("surname") or fields.get("names") or fields.get("given_names"))
# Count failed checks
failed = sum(1 for v in checks.values() if v == "failed")
# VLM doesn't return per-field confidence — if VLM extracted key fields, trust it
if not confidence and "vlm" in (extraction_method or ""):
if has_id and has_name and failed == 0:
return "pass"
if has_id or has_name:
return "inconclusive" if failed <= 1 else "fail"
return "fail"
if not confidence:
return "fail"
avg_conf = sum(confidence.values()) / len(confidence)
if avg_conf < CONFIDENCE_FAIL or failed >= 3:
return "fail"
if has_id and has_name and avg_conf >= CONFIDENCE_PASS and failed == 0:
return "pass"
return "inconclusive"
# ── Fallback preprocessing for difficult images ─────────────────────────
def _gamma_correct(image: np.ndarray, gamma: float) -> np.ndarray:
inv_gamma = 1.0 / gamma
table = np.array([((i / 255.0) ** inv_gamma) * 255 for i in range(256)]).astype("uint8")
return cv2.LUT(image, table)
def _preprocess_color_segmented(img_bgr: np.ndarray) -> np.ndarray:
"""Fallback specifically for old green ID books."""
hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
lower_green = np.array([35, 20, 20])
upper_green = np.array([85, 255, 255])
green_mask = cv2.inRange(hsv, lower_green, upper_green)
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
result = gray.copy()
result[green_mask > 0] = np.clip(gray[green_mask > 0].astype(np.int16) + 80, 0, 255).astype(np.uint8)
clahe = cv2.createCLAHE(clipLimit=4.0, tileGridSize=(8, 8))
enhanced = clahe.apply(result)
_, binary = cv2.threshold(enhanced, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
return binary
def _preprocess_high_contrast(img_bgr: np.ndarray) -> np.ndarray:
"""Fallback: contrast stretch + Otsu binarization."""
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
brightness = _estimate_brightness(gray)
if brightness < 120:
gamma = max(0.3, brightness / 150.0)
gray = _gamma_correct(gray, gamma)
normalized = cv2.normalize(gray, None, 0, 255, cv2.NORM_MINMAX)
clahe = cv2.createCLAHE(clipLimit=5.0, tileGridSize=(4, 4))
enhanced = clahe.apply(normalized)
_, binary = cv2.threshold(enhanced, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
return binary
# ── Merge helper ─────────────────────────────────────────────────────────
def _merge_fields(*sources: dict | None) -> dict:
"""Merge fields from multiple sources. First non-None value wins."""
merged = {}
for source in sources:
if not source:
continue
for key, value in source.items():
if key == "source":
continue
if value and key not in merged:
merged[key] = value
return merged
# ── SA ID extraction (PaddleOCR + id_parser) ─────────────────────────────
def _extract_sa_id_paddleocr(image_path: str) -> tuple[dict, dict, list]:
"""Run PaddleOCR + id_parser extraction for SA ID documents.
Returns (fields, confidence, raw_text).
"""
results, rotation = run_ocr(image_path)
results = _filter_watermarks(results)
raw_text = [text for _, text, _ in results]
# Classify front vs back
side = classify_id_side(results)
if side == "back":
logger.info("Back of ID card detected in front image")
return {}, {}, raw_text
extracted = extract_fields(results)
fields = extracted["fields"]
confidence = extracted["confidence"]
id_number = fields.get("id_number")
id_valid = id_number and validate_luhn(id_number)
# Fallback preprocessing if ID not found
if not id_valid:
img_bgr = cv2.imread(image_path)
if img_bgr is not None:
for pass_name, preprocess_fn in [
("color_segmented", _preprocess_color_segmented),
("high_contrast", _preprocess_high_contrast),
]:
if id_valid:
break
logger.info("Fallback pass '%s'", pass_name)
try:
processed = preprocess_fn(img_bgr)
pass_results = run_ocr_on_array(processed)
pass_results = _filter_watermarks(pass_results)
pass_extracted = extract_fields(pass_results)
if pass_extracted["fields"].get("id_number"):
pass_id = pass_extracted["fields"]["id_number"]
if validate_luhn(pass_id):
fields["id_number"] = pass_id
confidence["id_number"] = pass_extracted["confidence"].get("id_number", 0)
id_valid = True
for key, value in pass_extracted["fields"].items():
if key not in fields and value:
fields[key] = value
confidence[key] = pass_extracted["confidence"].get(key, 0)
raw_text = list(set(raw_text + [t for _, t, _ in pass_results]))
except Exception as e:
logger.warning("Fallback pass '%s' failed: %s", pass_name, e)
return fields, confidence, raw_text
# ── Build checks object ──────────────────────────────────────────────────
def _build_checks(
doc_type: str,
quality: dict,
fields: dict,
validation: dict | None = None,
barcode_data: dict | None = None,
mrz_data: dict | None = None,
vlm_fields: dict | None = None,
ocr_fields: dict | None = None,
) -> dict:
"""Build per-check pass/fail status dict."""
checks = {}
# Image quality
checks["image_quality"] = "passed" if quality.get("resolution_ok", False) else "failed"
# ID number validation (country-specific)
if doc_type in ("sa_id_card", "sa_id_book"):
id_num = fields.get("id_number")
if id_num:
country_check = validate_id_for_country(id_num, "ZAF")
checks["id_number_valid"] = "passed" if country_check["valid"] else "failed"
else:
checks["id_number_valid"] = "failed"
elif doc_type == "passport":
# For passports, validate ID number by detected nationality
nationality = fields.get("nationality") or (mrz_data or {}).get("nationality")
id_num = fields.get("id_number")
if id_num and nationality:
country_check = validate_id_for_country(id_num, nationality)
checks["id_number_valid"] = "passed" if country_check["valid"] else "failed"
else:
checks["id_number_valid"] = "not_applicable"
# MRZ validation
if doc_type == "passport":
if mrz_data:
checks["mrz_valid"] = "passed" if mrz_data.get("mrz_valid") else "failed"
else:
checks["mrz_valid"] = "failed"
else:
checks["mrz_valid"] = "not_applicable"
# Barcode validation
if doc_type == "sa_id_card":
if barcode_data:
checks["barcode_valid"] = "passed"
else:
checks["barcode_valid"] = "not_applicable" # back image was optional
else:
checks["barcode_valid"] = "not_applicable"
# Data crosscheck (compare machine-readable vs VLM/OCR)
machine_data = barcode_data or mrz_data
ai_data = vlm_fields or ocr_fields
if machine_data and ai_data:
mismatches = 0
for key in ("surname", "date_of_birth", "sex"):
m_val = (machine_data.get(key) or "").upper().strip()
a_val = (ai_data.get(key) or "").upper().strip()
if m_val and a_val and m_val != a_val:
mismatches += 1
checks["data_crosscheck"] = "passed" if mismatches == 0 else "failed"
else:
checks["data_crosscheck"] = "not_applicable"
# DOB / gender crosscheck (SA docs: OCR vs ID number encoding)
if doc_type in ("sa_id_card", "sa_id_book") and validation:
checks["dob_crosscheck"] = (
"passed" if validation.get("dob_cross_check") is True
else "failed" if validation.get("dob_cross_check") is False
else "not_applicable"
)
checks["gender_crosscheck"] = (
"passed" if validation.get("gender_cross_check") is True
else "failed" if validation.get("gender_cross_check") is False
else "not_applicable"
)
else:
checks["dob_crosscheck"] = "not_applicable"
checks["gender_crosscheck"] = "not_applicable"
return checks
# ── Main pipeline ───────────────────────────────────────────────────────
def process_id_image(front_path: str, doc_type: str, back_path: str | None = None) -> dict:
"""Full document processing pipeline.
Supports 3 document types with different extraction strategies:
- sa_id_card: barcode (back) → VLM (front) → PaddleOCR (front)
- sa_id_book: VLM (front) → PaddleOCR (front)
- passport: MRZ (front) → VLM (front) → PaddleOCR (front)
Returns structured result with fields, checks, and metadata.
"""
if doc_type not in SUPPORTED_DOC_TYPES:
raise ValueError(f"Unsupported doc_type: {doc_type}. Must be one of {SUPPORTED_DOC_TYPES}")
# Step 0: Quality gate
quality = check_image_quality(front_path, doc_type)
if not quality["usable"]:
logger.warning("Image quality check failed: %s", quality["issues"])
checks = {"image_quality": "failed"}
return _build_fail_response(doc_type, quality, checks)
# Route to doc-type-specific pipeline
if doc_type == "sa_id_card":
return _process_sa_id_card(front_path, back_path, quality)
elif doc_type == "sa_id_book":
return _process_sa_id_book(front_path, quality)
elif doc_type == "passport":
return _process_passport(front_path, quality)
def _process_sa_id_card(front_path: str, back_path: str | None, quality: dict) -> dict:
"""Pipeline: barcode (back) → VLM (front) → PaddleOCR + id_parser (front)."""
barcode_data = None
vlm_fields = None
ocr_fields = None
confidence = {}
raw_text = []
extraction_method = "paddleocr"
# Step 1: VLM extraction on front
try:
from engine.vlm_extractor import extract_fields_vlm
vlm_fields = extract_fields_vlm(front_path, "sa_id_card")
if vlm_fields:
extraction_method = "vlm"
except Exception as e:
logger.warning("VLM extraction failed: %s", e)
# Step 3: PaddleOCR fallback (only if VLM didn't return fields)
if not vlm_fields:
ocr_fields, confidence, raw_text = _extract_sa_id_paddleocr(front_path)
extraction_method = "paddleocr"
# Step 4: Merge fields (barcode > VLM > PaddleOCR)
fields = _merge_fields(barcode_data, vlm_fields, ocr_fields)
# Step 5: Cross-validate with SA ID number encoding
validation = {"luhn_valid": False}
id_number = fields.get("id_number")
if id_number:
parsed = parse_id_number(id_number)
validation = cross_validate(fields, parsed)
# Fill missing fields from ID number encoding
if not fields.get("date_of_birth") and parsed.get("date_of_birth"):
fields["date_of_birth"] = parsed["date_of_birth"]
if not fields.get("sex") and parsed.get("sex"):
fields["sex"] = parsed["sex"]
if not fields.get("citizenship_status") and parsed.get("citizenship"):
fields["citizenship_status"] = parsed["citizenship"]
# Step 6: Build checks
checks = _build_checks(
"sa_id_card", quality, fields, validation,
barcode_data=barcode_data, vlm_fields=vlm_fields, ocr_fields=ocr_fields,
)
overall_result = _compute_overall_result(fields, confidence, checks, extraction_method)
return {
"doc_type": "sa_id_card",
"fields": {
"id_number": fields.get("id_number"),
"surname": fields.get("surname"),
"names": fields.get("names"),
"date_of_birth": fields.get("date_of_birth"),
"sex": fields.get("sex"),
"nationality": fields.get("nationality"),
"country_of_birth": fields.get("country_of_birth"),
"citizenship_status": fields.get("citizenship_status"),
},
"barcode_data": barcode_data,
"mrz_data": None,
"extraction_method": extraction_method,
"checks": checks,
"validation": validation,
"confidence": confidence,
"quality": quality,
"overall_result": overall_result,
"raw_text": raw_text,
}
def _process_sa_id_book(front_path: str, quality: dict) -> dict:
"""Pipeline: VLM (front) → PaddleOCR + id_parser (front)."""
vlm_fields = None
ocr_fields = None
confidence = {}
raw_text = []
extraction_method = "paddleocr"
# Step 1: VLM extraction
try:
from engine.vlm_extractor import extract_fields_vlm
vlm_fields = extract_fields_vlm(front_path, "sa_id_book")
if vlm_fields:
extraction_method = "vlm"
except Exception as e:
logger.warning("VLM extraction failed: %s", e)
# Step 2: PaddleOCR fallback (only if VLM didn't return fields)
if not vlm_fields:
ocr_fields, confidence, raw_text = _extract_sa_id_paddleocr(front_path)
extraction_method = "paddleocr"
# Step 3: Merge (VLM > PaddleOCR)
fields = _merge_fields(vlm_fields, ocr_fields)
# Step 4: Cross-validate
validation = {"luhn_valid": False}
id_number = fields.get("id_number")
if id_number:
parsed = parse_id_number(id_number)
validation = cross_validate(fields, parsed)
if not fields.get("date_of_birth") and parsed.get("date_of_birth"):
fields["date_of_birth"] = parsed["date_of_birth"]
if not fields.get("sex") and parsed.get("sex"):
fields["sex"] = parsed["sex"]
if not fields.get("citizenship_status") and parsed.get("citizenship"):
fields["citizenship_status"] = parsed["citizenship"]
# Step 5: Build checks
checks = _build_checks(
"sa_id_book", quality, fields, validation,
vlm_fields=vlm_fields, ocr_fields=ocr_fields,
)
overall_result = _compute_overall_result(fields, confidence, checks, extraction_method)
return {
"doc_type": "sa_id_book",
"fields": {
"id_number": fields.get("id_number"),
"surname": fields.get("surname"),
"names": fields.get("names"),
"date_of_birth": fields.get("date_of_birth"),
"sex": fields.get("sex"),
"nationality": fields.get("nationality"),
"country_of_birth": fields.get("country_of_birth"),
"citizenship_status": fields.get("citizenship_status"),
},
"barcode_data": None,
"mrz_data": None,
"extraction_method": extraction_method,
"checks": checks,
"validation": validation,
"confidence": confidence,
"quality": quality,
"overall_result": overall_result,
"raw_text": raw_text,
}
def _process_passport(front_path: str, quality: dict) -> dict:
"""Pipeline: MRZ (front) → VLM (front) → PaddleOCR (front)."""
mrz_data = None
vlm_fields = None
confidence = {}
raw_text = []
extraction_method = "paddleocr"
# Step 1: MRZ reading
try:
from engine.mrz_reader import read_mrz
mrz_data = read_mrz(front_path)
if mrz_data:
logger.info("MRZ extraction successful")
except Exception as e:
logger.warning("MRZ reading failed: %s", e)
# Step 2: VLM extraction
try:
from engine.vlm_extractor import extract_fields_vlm
vlm_fields = extract_fields_vlm(front_path, "passport")
if vlm_fields:
extraction_method = "mrz+vlm" if mrz_data else "vlm"
except Exception as e:
logger.warning("VLM extraction failed: %s", e)
# Step 3: PaddleOCR fallback
if not vlm_fields:
try:
results, _ = run_ocr(front_path)
results = _filter_watermarks(results)
raw_text = [text for _, text, _ in results]
except Exception as e:
logger.warning("PaddleOCR failed: %s", e)
extraction_method = "mrz+paddleocr" if mrz_data else "paddleocr"
# Step 4: Merge (MRZ > VLM > PaddleOCR raw)
fields = _merge_fields(mrz_data, vlm_fields)
# Step 5: If SA passport, validate ID number with Luhn
nationality = fields.get("nationality")
validation = {}
if nationality and nationality.upper() == "ZAF":
id_number = fields.get("id_number")
if id_number:
parsed = parse_id_number(id_number)
validation = cross_validate(fields, parsed)
# Step 6: Build checks
checks = _build_checks(
"passport", quality, fields, validation or {},
mrz_data=mrz_data, vlm_fields=vlm_fields,
)
overall_result = _compute_overall_result(fields, confidence, checks, extraction_method)
return {
"doc_type": "passport",
"fields": {
"passport_number": fields.get("passport_number"),
"surname": fields.get("surname"),
"given_names": fields.get("given_names"),
"date_of_birth": fields.get("date_of_birth"),
"sex": fields.get("sex"),
"nationality": fields.get("nationality"),
"expiry_date": fields.get("expiry_date"),
"issuing_country": fields.get("issuing_country"),
"id_number": fields.get("id_number"), # SA passports only
},
"barcode_data": None,
"mrz_data": mrz_data,
"extraction_method": extraction_method,
"checks": checks,
"validation": validation,
"confidence": confidence,
"quality": quality,
"overall_result": overall_result,
"raw_text": raw_text,
}
def _build_fail_response(doc_type: str, quality: dict, checks: dict) -> dict:
"""Build a standardized failure response."""
if doc_type == "passport":
fields = {
"passport_number": None, "surname": None, "given_names": None,
"date_of_birth": None, "sex": None, "nationality": None,
"expiry_date": None, "issuing_country": None, "id_number": None,
}
else:
fields = {
"id_number": None, "surname": None, "names": None,
"date_of_birth": None, "sex": None, "nationality": None,
"country_of_birth": None, "citizenship_status": None,
}
return {
"doc_type": doc_type,
"fields": fields,
"barcode_data": None,
"mrz_data": None,
"extraction_method": None,
"checks": checks,
"validation": {},
"confidence": {},
"quality": quality,
"overall_result": "fail",
"raw_text": [],
}