File size: 6,457 Bytes
0e39d80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
"""PAN card field extraction and validation โ€” pure OCR-based."""

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, get_text_in_region

# โ”€โ”€ Patterns โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
PAN_RE = re.compile(r"[A-Z]{5}\d{4}[A-Z]", re.IGNORECASE)
DOB_RE = re.compile(r"\b(\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4})\b")
NAME_LABELS = ["name", "เคจเคพเคฎ", "naam"]
FATHER_LABELS = ["father", "father's name", "เคชเคฟเคคเคพ", "father name"]
DOB_LABELS = ["date of birth", "dob", "d.o.b", "birth", "เคœเคจเฅเคฎ เคคเคฟเคฅเคฟ"]


def _fix_pan_chars(value: str) -> str:
    """Fix common OCR misreads in PAN numbers."""
    if len(value) < 10:
        return value
    chars = list(value[:10])
    # Positions 0-4 should be letters
    for i in range(5):
        if chars[i].isdigit():
            chars[i] = {"0": "O", "1": "I", "5": "S", "8": "B"}.get(chars[i], chars[i])
    # Positions 5-8 should be digits
    for i in range(5, 9):
        if not chars[i].isdigit():
            chars[i] = {"O": "0", "I": "1", "S": "5", "B": "8", "l": "1", "o": "0"}.get(chars[i], chars[i])
    # Position 9 should be a letter
    if len(chars) == 10 and not chars[9].isalpha():
        chars[9] = {"0": "O", "1": "I"}.get(chars[9], chars[9])
    return "".join(chars)


def _cleanup_pan_text(text: str) -> str:
    """Clean and extract PAN number from OCR text."""
    compact = text.upper().replace(" ", "").replace("-", "").replace(".", "")
    match = PAN_RE.search(compact)
    if match:
        return _fix_pan_chars(match.group(0).upper())
    # Try with OCR error correction
    for candidate in re.findall(r"[A-Z0-9]{10}", compact):
        fixed = _fix_pan_chars(candidate)
        if PAN_RE.fullmatch(fixed):
            return fixed
    if len(compact) >= 10:
        return _fix_pan_chars(compact[-10:])
    return compact


# โ”€โ”€ Parser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def parse_pan_fields(ocr_results: list[OcrResult]) -> dict:
    """Extract PAN card fields from OCR results."""
    full_text = get_full_text(ocr_results)
    fields: dict = {}

    # PAN Number โ€” regex on all text
    pan_matches = find_by_regex(ocr_results, r"[A-Z0-9]{5}\s?[A-Z0-9]{4}\s?[A-Z0-9]")
    pan_found = False
    for raw_match, _ in pan_matches:
        cleaned = _cleanup_pan_text(raw_match)
        if PAN_RE.fullmatch(cleaned):
            fields["pan_number"] = cleaned
            pan_found = True
            break

    if not pan_found:
        # Try on full text
        compact = re.sub(r"\s", "", full_text.upper())
        match = PAN_RE.search(compact)
        if match:
            fields["pan_number"] = _fix_pan_chars(match.group(0))
        else:
            for candidate in re.findall(r"[A-Z0-9]{10}", compact):
                fixed = _fix_pan_chars(candidate)
                if PAN_RE.fullmatch(fixed):
                    fields["pan_number"] = fixed
                    break

    # Name
    name = find_value_near_label(ocr_results, NAME_LABELS)
    if name:
        cleaned = re.sub(r"[^A-Za-z\s.]", "", name).strip()
        if len(cleaned) >= 2 and not PAN_RE.search(cleaned):
            fields["name"] = cleaned

    if "name" not in fields:
        # PAN cards: name is usually the 2nd or 3rd line from top
        top_text = get_text_in_region(ocr_results, y_start_pct=0.15, y_end_pct=0.50)
        for line in top_text.split("\n"):
            cleaned = re.sub(r"[^A-Za-z\s.]", "", line).strip()
            if (len(cleaned) >= 4 and " " in cleaned
                    and not any(k in cleaned.lower() for k in ["income", "tax", "govt", "india", "permanent"])):
                fields["name"] = cleaned
                break

    # Father's Name
    father = find_value_near_label(ocr_results, FATHER_LABELS)
    if father:
        cleaned = re.sub(r"[^A-Za-z\s.]", "", father).strip()
        if len(cleaned) >= 2:
            fields["father_name"] = cleaned

    # DOB
    dob = find_value_near_label(ocr_results, DOB_LABELS)
    if dob:
        dob_match = DOB_RE.search(dob)
        if dob_match:
            fields["dob"] = dob_match.group(1)
    if "dob" not in fields:
        all_dobs = DOB_RE.findall(full_text)
        if all_dobs:
            fields["dob"] = all_dobs[0]

    # Signature presence (bottom region text)
    bottom_text = get_text_in_region(ocr_results, y_start_pct=0.75, y_end_pct=1.0)
    fields["signature_present"] = bool(
        re.search(r"signature|sign|เคนเคธเฅเคคเคพเค•เฅเคทเคฐ", bottom_text, re.IGNORECASE)
    )

    return fields


# โ”€โ”€ Validator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
REQUIRED_FIELDS = {"pan_number", "name"}


def validate_pan(fields: dict, ocr_results: list[OcrResult]) -> tuple[float, list[str]]:
    """Validate PAN extraction. Returns (validation_score_0_to_20, flags)."""
    flags: list[str] = []
    checks_total = 0
    checks_passed = 0

    # Required fields
    for req in REQUIRED_FIELDS:
        checks_total += 1
        if fields.get(req):
            checks_passed += 1
        else:
            flags.append(f"MISSING_{req.upper()}")

    # PAN format validation
    pan = fields.get("pan_number", "")
    if pan:
        checks_total += 1
        if PAN_RE.fullmatch(pan):
            checks_passed += 1
            fields["pan_validated"] = True
        else:
            flags.append("INVALID_PAN_FORMAT")
            fields["pan_validated"] = False
    else:
        flags.append("NO_PAN_OCR")
        fields["pan_validated"] = False

    # DOB format
    dob = fields.get("dob", "")
    if dob:
        checks_total += 1
        if DOB_RE.search(dob):
            checks_passed += 1
        else:
            flags.append("INVALID_DOB_FORMAT")

    # OCR confidence
    avg_conf = get_average_confidence(ocr_results)
    if avg_conf < 0.4:
        flags.append("LOW_OCR_CONFIDENCE")

    validation_ratio = checks_passed / max(checks_total, 1)
    return validation_ratio * 20.0, flags