document-ocr 0.0.0-stage → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +260 -2
- package/dist/index.cjs +659 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +293 -0
- package/dist/index.d.ts +293 -0
- package/dist/index.js +616 -0
- package/dist/index.js.map +1 -0
- package/hosting/.dockerignore +17 -0
- package/hosting/.gcloudignore +14 -0
- package/hosting/FEATURES.md +172 -0
- package/hosting/HOSTING.md +268 -0
- package/hosting/LICENSE +21 -0
- package/hosting/README.md +369 -0
- package/hosting/core/__init__.py +0 -0
- package/hosting/core/aadhaar_extractor.py +298 -0
- package/hosting/core/back_page_extractor.py +492 -0
- package/hosting/core/barcodes.py +125 -0
- package/hosting/core/card_recovery.py +244 -0
- package/hosting/core/document_bundle.py +82 -0
- package/hosting/core/document_classifier.py +233 -0
- package/hosting/core/document_input.py +135 -0
- package/hosting/core/document_registry.py +143 -0
- package/hosting/core/driving_licence_extractor.py +510 -0
- package/hosting/core/ead_recovery.py +54 -0
- package/hosting/core/evidence.py +54 -0
- package/hosting/core/form_recovery.py +61 -0
- package/hosting/core/jobs.py +232 -0
- package/hosting/core/kyc_ocr.py +132 -0
- package/hosting/core/kyc_validation.py +411 -0
- package/hosting/core/model_setup.py +13 -0
- package/hosting/core/mrz_parser.py +336 -0
- package/hosting/core/mrz_recovery.py +193 -0
- package/hosting/core/npr_extractor.py +410 -0
- package/hosting/core/nrega_extractor.py +827 -0
- package/hosting/core/ocr_engine.py +207 -0
- package/hosting/core/page_classifier.py +137 -0
- package/hosting/core/pan_extractor.py +183 -0
- package/hosting/core/pipeline.py +712 -0
- package/hosting/core/preprocessor.py +297 -0
- package/hosting/core/recovery_budget.py +67 -0
- package/hosting/core/redaction.py +48 -0
- package/hosting/core/review.html +244 -0
- package/hosting/core/structured_documents.py +422 -0
- package/hosting/core/travel_mrz.py +164 -0
- package/hosting/core/travel_recovery.py +187 -0
- package/hosting/core/validator.py +396 -0
- package/hosting/core/validators.py +179 -0
- package/hosting/core/voter_id_extractor.py +363 -0
- package/hosting/deploy/cloudflare/deploy.sh +44 -0
- package/hosting/deploy/cloudflare/package-lock.json +3088 -0
- package/hosting/deploy/cloudflare/package.json +29 -0
- package/hosting/deploy/cloudflare/src/gateway.ts +42 -0
- package/hosting/deploy/cloudflare/src/index.ts +21 -0
- package/hosting/deploy/cloudflare/test/gateway.test.ts +59 -0
- package/hosting/deploy/cloudflare/tsconfig.json +12 -0
- package/hosting/deploy/cloudflare/worker-configuration.d.ts +16253 -0
- package/hosting/deploy/cloudflare/wrangler.jsonc +26 -0
- package/hosting/deploy/cloudrun/cloudbuild.yaml +18 -0
- package/hosting/deploy/cloudrun/deploy.sh +71 -0
- package/hosting/deploy/cloudrun/render_service.py +47 -0
- package/hosting/deploy/common.sh +14 -0
- package/hosting/deploy/docker/Dockerfile +16 -0
- package/hosting/deploy/docker/server.py +313 -0
- package/hosting/deploy/lambda/Dockerfile.lambda +11 -0
- package/hosting/deploy/lambda/deploy.sh +22 -0
- package/hosting/deploy/lambda/handler.py +132 -0
- package/hosting/deploy/lambda/template.yaml +59 -0
- package/hosting/deploy/local/dev.sh +18 -0
- package/hosting/deploy/server/Caddyfile +5 -0
- package/hosting/deploy/server/compose.jobs.yaml +36 -0
- package/hosting/deploy/server/compose.yaml +38 -0
- package/hosting/deploy/server/deploy.sh +23 -0
- package/hosting/infra/cloudrun/main.tf +104 -0
- package/hosting/infra/cloudrun/terraform.tfvars.example +7 -0
- package/hosting/infra/cloudrun/variables.tf +55 -0
- package/hosting/infra/lambda/main.tf +63 -0
- package/hosting/infra/lambda/terraform.tfvars.example +6 -0
- package/hosting/infra/lambda/variables.tf +37 -0
- package/hosting/pyproject.toml +65 -0
- package/hosting/requirements.lock +134 -0
- package/package.json +76 -4
- package/python/core/__init__.py +0 -0
- package/python/core/aadhaar_extractor.py +298 -0
- package/python/core/back_page_extractor.py +492 -0
- package/python/core/barcodes.py +125 -0
- package/python/core/card_recovery.py +244 -0
- package/python/core/document_bundle.py +82 -0
- package/python/core/document_classifier.py +233 -0
- package/python/core/document_input.py +135 -0
- package/python/core/document_registry.py +143 -0
- package/python/core/driving_licence_extractor.py +510 -0
- package/python/core/ead_recovery.py +54 -0
- package/python/core/evidence.py +54 -0
- package/python/core/form_recovery.py +61 -0
- package/python/core/jobs.py +232 -0
- package/python/core/kyc_ocr.py +132 -0
- package/python/core/kyc_validation.py +411 -0
- package/python/core/model_setup.py +13 -0
- package/python/core/mrz_parser.py +336 -0
- package/python/core/mrz_recovery.py +193 -0
- package/python/core/npr_extractor.py +410 -0
- package/python/core/nrega_extractor.py +827 -0
- package/python/core/ocr_engine.py +207 -0
- package/python/core/page_classifier.py +137 -0
- package/python/core/pan_extractor.py +183 -0
- package/python/core/pipeline.py +712 -0
- package/python/core/preprocessor.py +297 -0
- package/python/core/recovery_budget.py +67 -0
- package/python/core/redaction.py +48 -0
- package/python/core/review.html +244 -0
- package/python/core/structured_documents.py +422 -0
- package/python/core/travel_mrz.py +164 -0
- package/python/core/travel_recovery.py +187 -0
- package/python/core/validator.py +396 -0
- package/python/core/validators.py +179 -0
- package/python/core/voter_id_extractor.py +363 -0
- package/python/pyproject.toml +34 -0
- package/python/requirements.lock +134 -0
- package/python/server.py +313 -0
- package/scripts/dococr.mjs +163 -0
- package/scripts/postinstall.mjs +173 -0
- package/scripts/sync-hosting.mjs +35 -0
- package/scripts/sync-python.sh +20 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RapidOCR wrapper. Accepts a preprocessed image, returns detected text regions.
|
|
3
|
+
|
|
4
|
+
Uses PP-OCRv5 models via ONNX Runtime — no PaddlePaddle dependency.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("document-ocr.ocr_engine")
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Types
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TextRegion:
|
|
24
|
+
text: str
|
|
25
|
+
bbox: list[list[int]] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
|
|
26
|
+
confidence: float
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OCRModelInitError(RuntimeError):
|
|
30
|
+
"""Raised when the RapidOCR models cannot be initialised.
|
|
31
|
+
|
|
32
|
+
Surfaces a clear, actionable error instead of letting the first request
|
|
33
|
+
hang until timeout when the model source (ModelScope) is unreachable or
|
|
34
|
+
the local model cache cannot be written (e.g. disk full / read-only FS).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Singleton OCR instance
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
_lock = threading.Lock()
|
|
43
|
+
_ocr_instances: dict[str, object] = {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# RapidOCR's default detector resize enlarges an input until its short side is
|
|
47
|
+
# 736 px, which turns a thin MRZ band into a detector tensor larger than the
|
|
48
|
+
# whole page. Bands still need some enlargement: on the public benchmark a
|
|
49
|
+
# photographed MRZ band failed without it and at 400 px, and read correctly at
|
|
50
|
+
# 480 px and above. 576 keeps a margin and is ~40% less detector work than 736.
|
|
51
|
+
_BAND_DETECTOR_PARAMS = {"Det.limit_type": "min", "Det.limit_side_len": 576}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_ocr(lang: str = "en", *, band: bool = False):
|
|
55
|
+
"""Get or create a cached RapidOCR instance.
|
|
56
|
+
|
|
57
|
+
``band=True`` returns a separate instance tuned for thin, already-cropped
|
|
58
|
+
text bands (see ``_BAND_DETECTOR_PARAMS``); it shares model files, not state.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
OCRModelInitError: if RapidOCR model initialisation fails. A failed
|
|
62
|
+
instance is never cached, so a subsequent call can retry once the
|
|
63
|
+
underlying problem (network / disk) is resolved.
|
|
64
|
+
"""
|
|
65
|
+
key = f"{lang}:band" if band else lang
|
|
66
|
+
if key in _ocr_instances:
|
|
67
|
+
return _ocr_instances[key]
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
from rapidocr import LangRec, ModelType, OCRVersion, RapidOCR
|
|
71
|
+
except Exception as exc: # pragma: no cover - import failure is environmental
|
|
72
|
+
logger.exception("Failed to import rapidocr")
|
|
73
|
+
raise OCRModelInitError(f"MODEL_INIT_FAILED: rapidocr import failed: {exc}") from exc
|
|
74
|
+
|
|
75
|
+
# Map string language codes to RapidOCR Enum values
|
|
76
|
+
_lang_map = {
|
|
77
|
+
"en": LangRec.EN,
|
|
78
|
+
"latin": LangRec.LATIN,
|
|
79
|
+
"ch": LangRec.CH,
|
|
80
|
+
"chinese_cht": LangRec.CHINESE_CHT,
|
|
81
|
+
"japan": LangRec.JAPAN,
|
|
82
|
+
"korean": LangRec.KOREAN,
|
|
83
|
+
"arabic": LangRec.ARABIC,
|
|
84
|
+
"cyrillic": LangRec.CYRILLIC,
|
|
85
|
+
"devanagari": LangRec.DEVANAGARI,
|
|
86
|
+
"ka": LangRec.KA,
|
|
87
|
+
"ta": LangRec.TA,
|
|
88
|
+
"te": LangRec.TE,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
with _lock:
|
|
92
|
+
# Re-check under the lock — another thread may have built it.
|
|
93
|
+
if key in _ocr_instances:
|
|
94
|
+
return _ocr_instances[key]
|
|
95
|
+
|
|
96
|
+
rec_lang = _lang_map.get(lang, LangRec.EN)
|
|
97
|
+
try:
|
|
98
|
+
instance = RapidOCR(params={
|
|
99
|
+
"Global.use_cls": False,
|
|
100
|
+
"Det.model_type": ModelType.MOBILE,
|
|
101
|
+
"Det.ocr_version": OCRVersion.PPOCRV5,
|
|
102
|
+
"Rec.lang_type": rec_lang,
|
|
103
|
+
"Rec.model_type": ModelType.MOBILE,
|
|
104
|
+
"Rec.ocr_version": OCRVersion.PPOCRV5,
|
|
105
|
+
**(_BAND_DETECTOR_PARAMS if band else {}),
|
|
106
|
+
})
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
# Do NOT cache — leave the slot empty so a later call can retry.
|
|
109
|
+
logger.exception("RapidOCR model initialisation failed (lang=%s)", key)
|
|
110
|
+
raise OCRModelInitError(
|
|
111
|
+
f"MODEL_INIT_FAILED: could not initialise OCR models for lang={key}: {exc}"
|
|
112
|
+
) from exc
|
|
113
|
+
|
|
114
|
+
_ocr_instances[key] = instance
|
|
115
|
+
|
|
116
|
+
return _ocr_instances[key]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Result parsing
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def _parse_rapidocr_results(result) -> list[TextRegion]:
|
|
124
|
+
"""Parse RapidOCR output into TextRegion list."""
|
|
125
|
+
regions: list[TextRegion] = []
|
|
126
|
+
|
|
127
|
+
if result.boxes is None or result.txts is None:
|
|
128
|
+
return regions
|
|
129
|
+
|
|
130
|
+
for box, txt, score in zip(result.boxes, result.txts, result.scores):
|
|
131
|
+
text = str(txt).strip()
|
|
132
|
+
if text:
|
|
133
|
+
bbox = [[int(pt[0]), int(pt[1])] for pt in box]
|
|
134
|
+
regions.append(TextRegion(text=text, bbox=bbox, confidence=float(score)))
|
|
135
|
+
|
|
136
|
+
return regions
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Script detection
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def _is_likely_non_latin(regions: list[TextRegion]) -> bool:
|
|
144
|
+
"""Heuristic: if > 40% of characters in detected text are non-ASCII, re-run
|
|
145
|
+
with multilingual model."""
|
|
146
|
+
all_text = "".join(r.text for r in regions)
|
|
147
|
+
if not all_text:
|
|
148
|
+
return False
|
|
149
|
+
non_ascii = sum(1 for c in all_text if ord(c) > 127)
|
|
150
|
+
return (non_ascii / len(all_text)) > 0.4
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# Public API
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def run_line_ocr(image: np.ndarray) -> list[TextRegion]:
|
|
158
|
+
"""Recognize one already-localized Latin text line without running detection.
|
|
159
|
+
|
|
160
|
+
Calling the recognizer directly avoids changing the cached RapidOCR object's
|
|
161
|
+
persistent ``use_det`` flag, which would break subsequent full-page scans.
|
|
162
|
+
"""
|
|
163
|
+
result = _get_ocr("en").recognize_txt([image])
|
|
164
|
+
if not result.txts or not result.scores or not result.txts[0].strip():
|
|
165
|
+
return []
|
|
166
|
+
height, width = image.shape[:2]
|
|
167
|
+
return [TextRegion(result.txts[0].strip(),
|
|
168
|
+
[[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]],
|
|
169
|
+
float(result.scores[0]))]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def run_band_ocr(image: np.ndarray) -> list[TextRegion]:
|
|
173
|
+
"""Detect and recognize Latin text in a thin, already-cropped band.
|
|
174
|
+
|
|
175
|
+
Used for MRZ band re-reads, which the default reader enlarged to a detector
|
|
176
|
+
input several times the band's own size.
|
|
177
|
+
"""
|
|
178
|
+
return _parse_rapidocr_results(_get_ocr("en", band=True)(image))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_ocr(
|
|
182
|
+
image: np.ndarray,
|
|
183
|
+
*,
|
|
184
|
+
lang: Optional[str] = None,
|
|
185
|
+
) -> list[TextRegion]:
|
|
186
|
+
"""
|
|
187
|
+
Run OCR on a preprocessed image.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
image: BGR numpy array (preprocessed).
|
|
191
|
+
lang: Force a language. If None, starts with 'en' and falls back to
|
|
192
|
+
'latin' if non-Latin script is detected.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
List of TextRegion with text, bounding box, and confidence.
|
|
196
|
+
"""
|
|
197
|
+
use_lang = lang or "en"
|
|
198
|
+
ocr = _get_ocr(use_lang)
|
|
199
|
+
|
|
200
|
+
result = ocr(image)
|
|
201
|
+
regions = _parse_rapidocr_results(result)
|
|
202
|
+
|
|
203
|
+
# Auto-detect non-Latin and retry with multilingual
|
|
204
|
+
if lang is None and _is_likely_non_latin(regions):
|
|
205
|
+
return run_ocr(image, lang="latin")
|
|
206
|
+
|
|
207
|
+
return regions
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fast passport page classification.
|
|
3
|
+
|
|
4
|
+
Uses a lightweight OCR probe to distinguish passport biodata pages from
|
|
5
|
+
supplementary/non-biodata pages before the heavier extraction pipeline runs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from .mrz_parser import _find_mrz_lines, parse_mrz
|
|
14
|
+
from .ocr_engine import TextRegion
|
|
15
|
+
|
|
16
|
+
_BIODATA_HINTS = [
|
|
17
|
+
"SURNAME",
|
|
18
|
+
"GIVEN NAME",
|
|
19
|
+
"PASSPORT NO",
|
|
20
|
+
"PASSPORT NUMBER",
|
|
21
|
+
"DATE OF BIRTH",
|
|
22
|
+
"NATIONALITY",
|
|
23
|
+
"PLACE OF BIRTH",
|
|
24
|
+
"DATE OF ISSUE",
|
|
25
|
+
"DATE OF EXPIRY",
|
|
26
|
+
"SEX",
|
|
27
|
+
"COUNTRY CODE",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
_NON_BIODATA_HINTS = [
|
|
31
|
+
"NAME OF FATHER",
|
|
32
|
+
"LEGAL GUARDIAN",
|
|
33
|
+
"NAME OF MOTHER",
|
|
34
|
+
"NAME OF SPOUSE",
|
|
35
|
+
"ADDRESS",
|
|
36
|
+
"FILE NO",
|
|
37
|
+
"OLD PASSPORT",
|
|
38
|
+
"PLACE OF ISSUE",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class PageClassification:
|
|
44
|
+
document_type: str
|
|
45
|
+
page_type: str
|
|
46
|
+
confidence: float
|
|
47
|
+
reasons: list[str] = field(default_factory=list)
|
|
48
|
+
probe_text: list[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def classify_passport_page(regions: list[TextRegion]) -> PageClassification:
|
|
52
|
+
"""Classify a page using a low-cost OCR probe."""
|
|
53
|
+
probe_text = [region.text.strip() for region in regions if region.text.strip()][:12]
|
|
54
|
+
normalised_regions = [_normalise_text(region.text) for region in regions if region.text.strip()]
|
|
55
|
+
|
|
56
|
+
has_mrz = _has_mrz_like_lines(regions)
|
|
57
|
+
biodata_matches = _count_matches(normalised_regions, _BIODATA_HINTS)
|
|
58
|
+
non_biodata_matches = _count_matches(normalised_regions, _NON_BIODATA_HINTS)
|
|
59
|
+
|
|
60
|
+
if has_mrz or biodata_matches >= 2:
|
|
61
|
+
confidence = 0.55 + (0.12 * min(biodata_matches, 3)) + (0.18 if has_mrz else 0.0)
|
|
62
|
+
reasons = ["MRZ_DETECTED"] if has_mrz else []
|
|
63
|
+
if biodata_matches:
|
|
64
|
+
reasons.append(f"BIODATA_HINTS_{biodata_matches}")
|
|
65
|
+
return PageClassification(
|
|
66
|
+
document_type="passport",
|
|
67
|
+
page_type="passport_biodata",
|
|
68
|
+
confidence=round(min(confidence, 0.99), 3),
|
|
69
|
+
reasons=reasons,
|
|
70
|
+
probe_text=probe_text,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if non_biodata_matches >= 2:
|
|
74
|
+
confidence = 0.62 + (0.1 * min(non_biodata_matches, 3))
|
|
75
|
+
return PageClassification(
|
|
76
|
+
document_type="passport",
|
|
77
|
+
page_type="passport_non_biodata",
|
|
78
|
+
confidence=round(min(confidence, 0.98), 3),
|
|
79
|
+
reasons=[f"NON_BIODATA_HINTS_{non_biodata_matches}"],
|
|
80
|
+
probe_text=probe_text,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if biodata_matches or non_biodata_matches:
|
|
84
|
+
return PageClassification(
|
|
85
|
+
document_type="passport",
|
|
86
|
+
page_type="unknown",
|
|
87
|
+
confidence=0.45,
|
|
88
|
+
reasons=["WEAK_PASSPORT_HINTS"],
|
|
89
|
+
probe_text=probe_text,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return PageClassification(
|
|
93
|
+
document_type="unknown",
|
|
94
|
+
page_type="unknown",
|
|
95
|
+
confidence=0.2,
|
|
96
|
+
reasons=["NO_PASSPORT_HINTS"],
|
|
97
|
+
probe_text=probe_text,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _count_matches(normalised_regions: list[str], hints: list[str]) -> int:
|
|
102
|
+
matched_hints: set[str] = set()
|
|
103
|
+
for hint in hints:
|
|
104
|
+
hint_text = _normalise_text(hint)
|
|
105
|
+
padded_hint = f" {hint_text} "
|
|
106
|
+
for region_text in normalised_regions:
|
|
107
|
+
if padded_hint in f" {region_text} ":
|
|
108
|
+
matched_hints.add(hint_text)
|
|
109
|
+
break
|
|
110
|
+
return len(matched_hints)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _normalise_text(text: str) -> str:
|
|
114
|
+
text = text.upper()
|
|
115
|
+
text = re.sub(r"[^A-Z0-9]+", " ", text)
|
|
116
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _has_mrz_like_lines(regions: list[TextRegion]) -> bool:
|
|
120
|
+
if not regions:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
page_bottom = max((max(point[1] for point in region.bbox) for region in regions if region.bbox), default=0)
|
|
124
|
+
bottom_threshold = page_bottom * 0.6
|
|
125
|
+
bottom_regions = [
|
|
126
|
+
region for region in regions
|
|
127
|
+
if region.bbox and max(point[1] for point in region.bbox) >= bottom_threshold
|
|
128
|
+
]
|
|
129
|
+
# Optional data may occupy all of line two: it need not contain fillers.
|
|
130
|
+
# Require a paired TD3 header instead of counting arbitrary long strings.
|
|
131
|
+
if _find_mrz_lines(bottom_regions) is not None:
|
|
132
|
+
return True
|
|
133
|
+
# A passport may sit high in a larger photograph, with background text
|
|
134
|
+
# below it. Strong MRZ evidence should not depend on its position in that
|
|
135
|
+
# photograph. Keep checksum validation for this location-independent path.
|
|
136
|
+
mrz = parse_mrz(regions)
|
|
137
|
+
return bool(mrz and mrz.overall_checksum_valid)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Extractor for Indian PAN (Permanent Account Number) cards.
|
|
3
|
+
|
|
4
|
+
PAN card fields: PAN number, holder name, father's name, date of birth.
|
|
5
|
+
Layout has shifted across generations (old NSDL card, e-PAN, QR PAN), but the
|
|
6
|
+
labels ("Name", "Father's Name", "Date of Birth") and the PAN format are stable,
|
|
7
|
+
so extraction is label- and format-driven rather than position-driven.
|
|
8
|
+
|
|
9
|
+
Robustness goals (see tests):
|
|
10
|
+
* The PAN number is recognised by *shape* (and preferably holder-type
|
|
11
|
+
validity), never confused with a date or another KYC identifier.
|
|
12
|
+
* ``name`` must never collapse into ``father_name`` — the "Name" label is a
|
|
13
|
+
substring of "Father's Name". We rely on ``find_visual_field``'s
|
|
14
|
+
exact-match priority and additionally de-duplicate the two values.
|
|
15
|
+
* Both the old NSDL "value below the label" layout and the newer e-PAN
|
|
16
|
+
"Label : value on the same row" layout are handled.
|
|
17
|
+
* Bilingual Hindi/English noise rows and missing labels are tolerated.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from .ocr_engine import TextRegion
|
|
27
|
+
from .validator import find_label_value, find_visual_field
|
|
28
|
+
from .validators import is_valid_pan, normalize_pan
|
|
29
|
+
|
|
30
|
+
# A printed PAN-card date: DD/MM/YYYY or DD-MM-YYYY (also tolerates "." and a
|
|
31
|
+
# 2-digit year). The PAN itself never contains these separators, so a date can
|
|
32
|
+
# never be mistaken for a PAN and vice-versa.
|
|
33
|
+
_DATE_RE = re.compile(r"\b(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})\b")
|
|
34
|
+
|
|
35
|
+
# Label vocabularies. ``find_visual_field`` normalises OCR noise (case,
|
|
36
|
+
# punctuation, "&" -> "AND") and favours exact phrase matches, so we list the
|
|
37
|
+
# most specific spellings first.
|
|
38
|
+
_NAME_LABELS = [
|
|
39
|
+
"NAME",
|
|
40
|
+
"NAME OF THE CARDHOLDER",
|
|
41
|
+
"CARDHOLDER NAME",
|
|
42
|
+
"CARD HOLDER NAME",
|
|
43
|
+
]
|
|
44
|
+
_FATHER_LABELS = [
|
|
45
|
+
"FATHER S NAME",
|
|
46
|
+
"FATHERS NAME",
|
|
47
|
+
"FATHER NAME",
|
|
48
|
+
"NAME OF FATHER",
|
|
49
|
+
"PARENT S NAME",
|
|
50
|
+
"PARENTS NAME",
|
|
51
|
+
"NAME OF PARENT",
|
|
52
|
+
]
|
|
53
|
+
_DOB_LABELS = [
|
|
54
|
+
"DATE OF BIRTH",
|
|
55
|
+
"DATE OF BIRTH INCORPORATION",
|
|
56
|
+
"DATE OF BIRTH OR INCORPORATION",
|
|
57
|
+
"DOB",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class PanFields:
|
|
63
|
+
pan_number: Optional[str] = None
|
|
64
|
+
name: Optional[str] = None
|
|
65
|
+
father_name: Optional[str] = None
|
|
66
|
+
date_of_birth: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# PAN number
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _find_pan_number(regions: list[TextRegion]) -> Optional[str]:
|
|
74
|
+
"""Locate the PAN by shape, preferring a holder-type-valid token.
|
|
75
|
+
|
|
76
|
+
Strategy, best-first:
|
|
77
|
+
1. A region adjacent to a "Permanent Account Number" / "PAN" label whose
|
|
78
|
+
text yields a *valid* PAN (correct 4th holder-type character).
|
|
79
|
+
2. Any region yielding a *valid* PAN.
|
|
80
|
+
3. Any region yielding a merely PAN-*shaped* token (tolerates OCR noise
|
|
81
|
+
in the holder-type position).
|
|
82
|
+
|
|
83
|
+
``normalize_pan`` uses ``re.search`` over the alnum-compacted text, so dates
|
|
84
|
+
(which carry separators) and other KYC ids (e.g. a 15-char DL number) can
|
|
85
|
+
never be sliced into a false PAN.
|
|
86
|
+
"""
|
|
87
|
+
labelled = find_label_value(
|
|
88
|
+
regions, ["PERMANENT ACCOUNT NUMBER", "PERMANENT ACCOUNT NUMBER CARD", "PAN"]
|
|
89
|
+
)
|
|
90
|
+
if labelled and is_valid_pan(labelled):
|
|
91
|
+
pan = normalize_pan(labelled)
|
|
92
|
+
if pan:
|
|
93
|
+
return pan
|
|
94
|
+
|
|
95
|
+
shaped_fallback: Optional[str] = None
|
|
96
|
+
for region in regions:
|
|
97
|
+
pan = normalize_pan(region.text)
|
|
98
|
+
if pan is None:
|
|
99
|
+
continue
|
|
100
|
+
if is_valid_pan(region.text):
|
|
101
|
+
return pan
|
|
102
|
+
if shaped_fallback is None:
|
|
103
|
+
shaped_fallback = pan
|
|
104
|
+
return shaped_fallback
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Date of birth
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _find_date(regions: list[TextRegion]) -> Optional[str]:
|
|
112
|
+
"""Prefer a date adjacent to a DOB label; fall back to the first date.
|
|
113
|
+
|
|
114
|
+
DOB is left as the printed string (DD/MM/YYYY or DD-MM-YYYY) — see the
|
|
115
|
+
module/report notes: no ISO normalisation, to keep downstream values and
|
|
116
|
+
the TypeScript types unchanged.
|
|
117
|
+
"""
|
|
118
|
+
labelled = find_label_value(regions, _DOB_LABELS)
|
|
119
|
+
if labelled:
|
|
120
|
+
m = _DATE_RE.search(labelled)
|
|
121
|
+
if m:
|
|
122
|
+
return m.group(1)
|
|
123
|
+
|
|
124
|
+
for region in regions:
|
|
125
|
+
m = _DATE_RE.search(region.text)
|
|
126
|
+
if m:
|
|
127
|
+
return m.group(1)
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Name / father's name
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def _clean_value(text: Optional[str]) -> Optional[str]:
|
|
136
|
+
"""Strip leftover label punctuation/whitespace from a resolved value."""
|
|
137
|
+
if text is None:
|
|
138
|
+
return None
|
|
139
|
+
# Drop a leading "value-side" colon that occasionally rides along when the
|
|
140
|
+
# label and value were merged or mis-split by OCR (e.g. ": ROHIT SHARMA").
|
|
141
|
+
cleaned = text.strip().lstrip(":").strip()
|
|
142
|
+
return cleaned or None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _resolve_names(regions: list[TextRegion]) -> tuple[Optional[str], Optional[str]]:
|
|
146
|
+
"""Resolve (name, father_name) ensuring ``name`` never collapses into it.
|
|
147
|
+
|
|
148
|
+
The "Name" label is a substring of "Father's Name", so a naive lookup can
|
|
149
|
+
bind ``name`` to the father's value when the dedicated "Name" label is
|
|
150
|
+
absent or mis-OCR'd. We therefore:
|
|
151
|
+
* locate the father's-name label region first and exclude it (and its
|
|
152
|
+
resolved value) when resolving the plain "Name" label, and
|
|
153
|
+
* if both labels resolve to the *same* value, treat the plain-name lookup
|
|
154
|
+
as a false positive and drop it.
|
|
155
|
+
"""
|
|
156
|
+
father_value = _clean_value(find_label_value(regions, _FATHER_LABELS))
|
|
157
|
+
|
|
158
|
+
name_value = _clean_value(find_label_value(regions, _NAME_LABELS))
|
|
159
|
+
|
|
160
|
+
# Guard against the substring collapse: if the "Name" lookup latched onto
|
|
161
|
+
# the father's-name label/value, discard it rather than duplicate.
|
|
162
|
+
if name_value is not None and name_value == father_value:
|
|
163
|
+
# Make sure we did not simply read the same label region. Re-resolve the
|
|
164
|
+
# plain-name label and confirm it is a distinct region from the father
|
|
165
|
+
# label before trusting the duplicate; otherwise drop the name.
|
|
166
|
+
name_label = find_visual_field(regions, _NAME_LABELS)
|
|
167
|
+
father_label = find_visual_field(regions, _FATHER_LABELS)
|
|
168
|
+
if name_label is None or name_label is father_label:
|
|
169
|
+
name_value = None
|
|
170
|
+
|
|
171
|
+
return name_value, father_value
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Public API
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def extract_pan(regions: list[TextRegion]) -> PanFields:
|
|
179
|
+
fields = PanFields()
|
|
180
|
+
fields.pan_number = _find_pan_number(regions)
|
|
181
|
+
fields.name, fields.father_name = _resolve_names(regions)
|
|
182
|
+
fields.date_of_birth = _find_date(regions)
|
|
183
|
+
return fields
|