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,492 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Spatial extraction of Indian passport back page fields.
|
|
3
|
+
|
|
4
|
+
Uses label→value-below-label relationships via bounding boxes,
|
|
5
|
+
making it format-agnostic across old and new passport generations.
|
|
6
|
+
|
|
7
|
+
Indian passport back page fields (consistent across all generations):
|
|
8
|
+
- Name of Father / Legal Guardian
|
|
9
|
+
- Name of Mother
|
|
10
|
+
- Name of Spouse
|
|
11
|
+
- Address
|
|
12
|
+
- File Number
|
|
13
|
+
- Old Passport Number / Date of Issue / Place of Issue (renewals only)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from .ocr_engine import TextRegion
|
|
23
|
+
from .validator import find_label_row_left_edge, find_visual_field, find_visual_value_near
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Result type
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class BackPageFields:
|
|
32
|
+
father_name: Optional[str] = None
|
|
33
|
+
mother_name: Optional[str] = None
|
|
34
|
+
spouse_name: Optional[str] = None
|
|
35
|
+
address: Optional[str] = None
|
|
36
|
+
pincode: Optional[str] = None
|
|
37
|
+
city: Optional[str] = None
|
|
38
|
+
state: Optional[str] = None
|
|
39
|
+
file_number: Optional[str] = None
|
|
40
|
+
old_passport_number: Optional[str] = None
|
|
41
|
+
old_passport_date_of_issue: Optional[str] = None
|
|
42
|
+
old_passport_place_of_issue: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Indian state codes for address parsing
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
_STATE_CODES: dict[str, str] = {
|
|
50
|
+
"AP": "Andhra Pradesh", "AR": "Arunachal Pradesh", "AS": "Assam",
|
|
51
|
+
"BR": "Bihar", "CG": "Chhattisgarh", "GA": "Goa", "GJ": "Gujarat",
|
|
52
|
+
"HR": "Haryana", "HP": "Himachal Pradesh", "JH": "Jharkhand",
|
|
53
|
+
"KA": "Karnataka", "KL": "Kerala", "MP": "Madhya Pradesh",
|
|
54
|
+
"MH": "Maharashtra", "MN": "Manipur", "ML": "Meghalaya",
|
|
55
|
+
"MZ": "Mizoram", "NL": "Nagaland", "OD": "Odisha", "OR": "Odisha",
|
|
56
|
+
"PB": "Punjab", "RJ": "Rajasthan", "SK": "Sikkim", "TN": "Tamil Nadu",
|
|
57
|
+
"TS": "Telangana", "TR": "Tripura", "UP": "Uttar Pradesh",
|
|
58
|
+
"UK": "Uttarakhand", "WB": "West Bengal",
|
|
59
|
+
"AN": "Andaman and Nicobar Islands", "CH": "Chandigarh",
|
|
60
|
+
"DN": "Dadra and Nagar Haveli and Daman and Diu",
|
|
61
|
+
"DD": "Dadra and Nagar Haveli and Daman and Diu",
|
|
62
|
+
"DL": "Delhi", "JK": "Jammu and Kashmir", "LA": "Ladakh",
|
|
63
|
+
"LD": "Lakshadweep", "PY": "Puducherry",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_STATE_NAMES_UPPER = {name.upper() for name in _STATE_CODES.values()}
|
|
67
|
+
_UPPER_TO_CANONICAL_STATE = {name.upper(): name for name in _STATE_CODES.values()}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Label keywords (English + common OCR misreads)
|
|
72
|
+
# Bilingual Hindi/English labels appear on all Indian passport formats.
|
|
73
|
+
# OCR mostly reads the English portion; Hindi text is noise-filtered by
|
|
74
|
+
# the label matcher.
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
_FATHER_LABELS = [
|
|
78
|
+
"NAME OF FATHER", "FATHER LEGAL GUARDIAN", "NAME OF FATHER LEGAL GUARDIAN",
|
|
79
|
+
"FATHER", "LEGAL GUARDIAN",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
_MOTHER_LABELS = [
|
|
83
|
+
"NAME OF MOTHER", "MOTHER",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
_SPOUSE_LABELS = [
|
|
87
|
+
"NAME OF SPOUSE", "SPOUSE",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
_ADDRESS_LABELS = [
|
|
91
|
+
"ADDRESS",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
_FILE_NO_LABELS = [
|
|
95
|
+
"FILE NO", "FILE NUMBER", "FILE NO.",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
_OLD_PASSPORT_LABELS = [
|
|
99
|
+
"OLD PASSPORT NO", "OLD PASSPORT NUMBER", "PREVIOUS PASSPORT",
|
|
100
|
+
"OLD PASSPORT NO.", "OLD PASSPORT",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
_OLD_PASSPORT_DOI_LABELS = [
|
|
104
|
+
"DATE OF ISSUE",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
_OLD_PASSPORT_POI_LABELS = [
|
|
108
|
+
"PLACE OF ISSUE",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Multi-line collection
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _collect_multiline_value(
|
|
117
|
+
regions: list[TextRegion],
|
|
118
|
+
label_region: TextRegion,
|
|
119
|
+
*,
|
|
120
|
+
max_lines: int = 5,
|
|
121
|
+
max_y_gap: int = 50,
|
|
122
|
+
stop_labels: Optional[list[list[str]]] = None,
|
|
123
|
+
) -> list[TextRegion]:
|
|
124
|
+
"""Collect multiple value regions below a label (for address blocks).
|
|
125
|
+
|
|
126
|
+
Keeps collecting lines below the label until we hit a stop label
|
|
127
|
+
(anywhere on a row, regardless of horizontal alignment), exceed
|
|
128
|
+
max_lines, or the vertical gap is too large.
|
|
129
|
+
"""
|
|
130
|
+
label_bottom = max(p[1] for p in label_region.bbox)
|
|
131
|
+
label_left = find_label_row_left_edge(regions, label_region)
|
|
132
|
+
|
|
133
|
+
# All stop-label keywords
|
|
134
|
+
stop_keywords: set[str] = set()
|
|
135
|
+
if stop_labels:
|
|
136
|
+
for label_group in stop_labels:
|
|
137
|
+
for kw in label_group:
|
|
138
|
+
stop_keywords.add(kw.upper())
|
|
139
|
+
|
|
140
|
+
def _is_stop_label(text: str) -> bool:
|
|
141
|
+
norm = re.sub(r"[^A-Z0-9 ]", "", text.upper()).strip()
|
|
142
|
+
return any(kw in norm for kw in stop_keywords)
|
|
143
|
+
|
|
144
|
+
# Group regions by row so we can stop on a row that contains a stop label
|
|
145
|
+
# *anywhere* (including columns far from the address column).
|
|
146
|
+
row_for_top: dict[int, list[TextRegion]] = {}
|
|
147
|
+
for region in regions:
|
|
148
|
+
if region is label_region:
|
|
149
|
+
continue
|
|
150
|
+
top = min(p[1] for p in region.bbox)
|
|
151
|
+
if top < label_bottom - 10:
|
|
152
|
+
continue
|
|
153
|
+
if top - label_bottom > 300:
|
|
154
|
+
continue
|
|
155
|
+
# bucket by ~half-line height (assume ≥ 12 px lines)
|
|
156
|
+
bucket = top // 12
|
|
157
|
+
row_for_top.setdefault(bucket, []).append(region)
|
|
158
|
+
|
|
159
|
+
sorted_rows = sorted(row_for_top.items())
|
|
160
|
+
|
|
161
|
+
collected: list[TextRegion] = []
|
|
162
|
+
prev_bottom = label_bottom
|
|
163
|
+
for _, row_regions in sorted_rows:
|
|
164
|
+
if len(collected) >= max_lines:
|
|
165
|
+
break
|
|
166
|
+
# Stop if any region in this row matches a stop label.
|
|
167
|
+
if any(_is_stop_label(r.text) for r in row_regions):
|
|
168
|
+
break
|
|
169
|
+
# Pick the value-aligned region(s) — same column as the label.
|
|
170
|
+
for region in row_regions:
|
|
171
|
+
left = min(p[0] for p in region.bbox)
|
|
172
|
+
if abs(left - label_left) >= 250:
|
|
173
|
+
continue
|
|
174
|
+
top = min(p[1] for p in region.bbox)
|
|
175
|
+
if top - prev_bottom > max_y_gap and collected:
|
|
176
|
+
break
|
|
177
|
+
text = region.text.strip()
|
|
178
|
+
if len(text) < 2:
|
|
179
|
+
continue
|
|
180
|
+
# Drop rows that are mostly non-Latin script (Hindi labels) — the
|
|
181
|
+
# address proper is in Latin script even when adjacent labels are
|
|
182
|
+
# bilingual.
|
|
183
|
+
if _is_mostly_non_latin(text):
|
|
184
|
+
continue
|
|
185
|
+
collected.append(region)
|
|
186
|
+
prev_bottom = max(p[1] for p in region.bbox)
|
|
187
|
+
if len(collected) >= max_lines:
|
|
188
|
+
break
|
|
189
|
+
|
|
190
|
+
return collected
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _is_mostly_non_latin(text: str) -> bool:
|
|
194
|
+
"""True if more than half the alphabetic characters are non-ASCII."""
|
|
195
|
+
alpha = [c for c in text if c.isalpha()]
|
|
196
|
+
if not alpha:
|
|
197
|
+
return False
|
|
198
|
+
non_latin = sum(1 for c in alpha if ord(c) > 127)
|
|
199
|
+
return non_latin / len(alpha) > 0.5
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Address parsing
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def _parse_address(address_regions: list[TextRegion]) -> tuple[str, Optional[str], Optional[str], Optional[str]]:
|
|
207
|
+
"""Parse address regions into (full_address, pincode, city, state)."""
|
|
208
|
+
lines = [r.text.strip() for r in address_regions if r.text.strip()]
|
|
209
|
+
full_address = ", ".join(lines)
|
|
210
|
+
|
|
211
|
+
# Extract 6-digit pincode from anywhere in the address
|
|
212
|
+
pincode_match = re.search(r"\b(\d{6})\b", full_address)
|
|
213
|
+
pincode = pincode_match.group(1) if pincode_match else None
|
|
214
|
+
|
|
215
|
+
# First pass: comma-tokenized scan. Handles addresses where the state
|
|
216
|
+
# appears as its own token among other comma-separated tokens — e.g.
|
|
217
|
+
# "NEW MAHAVIR NAGAR,DELHI" or "PIN:110018,DELHI,INDIA".
|
|
218
|
+
state, city = _scan_state_and_city_from_tokens(full_address)
|
|
219
|
+
|
|
220
|
+
# Second pass (fallback): per-line regex patterns. These handle line-
|
|
221
|
+
# oriented formats where the state info is at the END of a single line.
|
|
222
|
+
if state is None or city is None:
|
|
223
|
+
for i in range(len(lines) - 1, -1, -1):
|
|
224
|
+
line = lines[i].strip()
|
|
225
|
+
|
|
226
|
+
# Pattern: "CITY - PINCODE STATE_ABBREV"
|
|
227
|
+
m = re.match(r"^([A-Za-z\s.]+?)[\s-]+\d{6}\s+([A-Z]{2,3})$", line)
|
|
228
|
+
if m and m.group(2).upper() in _STATE_CODES:
|
|
229
|
+
city = city or m.group(1).strip()
|
|
230
|
+
state = state or _STATE_CODES[m.group(2).upper()]
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
# Pattern: "CITY PINCODE STATE_ABBREV"
|
|
234
|
+
m = re.match(r"^([A-Za-z\s.]+?)\s+(\d{6})\s+([A-Z]{2,3})$", line)
|
|
235
|
+
if m and m.group(3).upper() in _STATE_CODES:
|
|
236
|
+
city = city or m.group(1).strip()
|
|
237
|
+
state = state or _STATE_CODES[m.group(3).upper()]
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
# Pattern: standalone full state name
|
|
241
|
+
if line.upper() in _STATE_NAMES_UPPER:
|
|
242
|
+
state = state or line.strip()
|
|
243
|
+
if i > 0 and not city:
|
|
244
|
+
city = lines[i - 1].strip()
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
# Pattern: "STATE_FULLNAME - PINCODE" or "STATE_FULLNAME PINCODE"
|
|
248
|
+
m = re.match(r"^([A-Za-z\s]+?)[\s-]*\d{6}$", line)
|
|
249
|
+
if m and m.group(1).strip().upper() in _STATE_NAMES_UPPER:
|
|
250
|
+
state = state or m.group(1).strip()
|
|
251
|
+
if i > 0 and not city:
|
|
252
|
+
city = lines[i - 1].strip()
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
# Pattern: "CITY - PINCODE" (state on a different line)
|
|
256
|
+
m = re.match(r"^([A-Za-z\s.]+?)[\s-]+\d{6}$", line)
|
|
257
|
+
if m and not city:
|
|
258
|
+
city = m.group(1).strip()
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
# Normalise the resolved state to canonical title case regardless of which
|
|
262
|
+
# path set it (the per-line fallback patterns above capture the raw, often
|
|
263
|
+
# all-caps, OCR text). Keeps output consistent — e.g. "KARNATAKA" → "Karnataka".
|
|
264
|
+
if state and state.upper() in _UPPER_TO_CANONICAL_STATE:
|
|
265
|
+
state = _UPPER_TO_CANONICAL_STATE[state.upper()]
|
|
266
|
+
|
|
267
|
+
return full_address, pincode, city, state
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _scan_state_and_city_from_tokens(
|
|
271
|
+
full_address: str,
|
|
272
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
273
|
+
"""Tokenize the full address by punctuation and infer state + city.
|
|
274
|
+
|
|
275
|
+
The state is the first token that matches a known Indian state name or
|
|
276
|
+
2-3 letter code. The city is the most recent preceding token that isn't
|
|
277
|
+
a state, country, or pincode — so for "NEW MAHAVIR NAGAR,DELHI,INDIA"
|
|
278
|
+
we get state=Delhi, city=NEW MAHAVIR NAGAR.
|
|
279
|
+
"""
|
|
280
|
+
tokens: list[str] = []
|
|
281
|
+
for raw in re.split(r"[,;|/]", full_address):
|
|
282
|
+
tok = raw.strip()
|
|
283
|
+
# Strip "PIN:" / "PIN " prefixes commonly stuck on the pincode token
|
|
284
|
+
tok = re.sub(r"^PIN[\s:]*", "", tok, flags=re.IGNORECASE).strip()
|
|
285
|
+
if not tok or re.fullmatch(r"\d{4,6}", tok):
|
|
286
|
+
continue
|
|
287
|
+
tokens.append(tok)
|
|
288
|
+
|
|
289
|
+
# Map UPPER -> canonical title-case name so output is consistent
|
|
290
|
+
# whether the OCR caught the full name or just the 2-letter code.
|
|
291
|
+
upper_to_canonical = {name.upper(): name for name in _STATE_CODES.values()}
|
|
292
|
+
|
|
293
|
+
state: Optional[str] = None
|
|
294
|
+
state_idx = -1
|
|
295
|
+
for i, tok in enumerate(tokens):
|
|
296
|
+
upper = tok.upper()
|
|
297
|
+
if upper in upper_to_canonical:
|
|
298
|
+
state = upper_to_canonical[upper]
|
|
299
|
+
state_idx = i
|
|
300
|
+
break
|
|
301
|
+
if upper in _STATE_CODES:
|
|
302
|
+
state = _STATE_CODES[upper]
|
|
303
|
+
state_idx = i
|
|
304
|
+
break
|
|
305
|
+
|
|
306
|
+
city: Optional[str] = None
|
|
307
|
+
if state_idx > 0:
|
|
308
|
+
for j in range(state_idx - 1, -1, -1):
|
|
309
|
+
prev = tokens[j].strip()
|
|
310
|
+
prev_upper = prev.upper()
|
|
311
|
+
if prev_upper == "INDIA":
|
|
312
|
+
continue
|
|
313
|
+
if prev_upper in _STATE_NAMES_UPPER or prev_upper in _STATE_CODES:
|
|
314
|
+
continue
|
|
315
|
+
city = prev
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
return state, city
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
# Public API
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
def extract_back_page(regions: list[TextRegion]) -> BackPageFields:
|
|
326
|
+
"""Extract structured fields from a passport back page using spatial layout.
|
|
327
|
+
|
|
328
|
+
Uses label→value-below relationships via bounding boxes. This approach
|
|
329
|
+
is format-agnostic and works across old and new Indian passport generations
|
|
330
|
+
because the field labels are consistent even though the visual layout varies.
|
|
331
|
+
"""
|
|
332
|
+
fields = BackPageFields()
|
|
333
|
+
|
|
334
|
+
# --- Single-value fields (find label, get value below) ---
|
|
335
|
+
father_label = find_visual_field(regions, _FATHER_LABELS)
|
|
336
|
+
if father_label:
|
|
337
|
+
val = find_visual_value_near(regions, father_label)
|
|
338
|
+
if val:
|
|
339
|
+
fields.father_name = val.text.strip()
|
|
340
|
+
|
|
341
|
+
mother_label = find_visual_field(regions, _MOTHER_LABELS)
|
|
342
|
+
if mother_label:
|
|
343
|
+
val = find_visual_value_near(regions, mother_label)
|
|
344
|
+
if val:
|
|
345
|
+
fields.mother_name = val.text.strip()
|
|
346
|
+
|
|
347
|
+
spouse_label = find_visual_field(regions, _SPOUSE_LABELS)
|
|
348
|
+
if spouse_label:
|
|
349
|
+
val = find_visual_value_near(regions, spouse_label)
|
|
350
|
+
if val:
|
|
351
|
+
fields.spouse_name = val.text.strip()
|
|
352
|
+
|
|
353
|
+
file_label = find_visual_field(regions, _FILE_NO_LABELS)
|
|
354
|
+
if file_label:
|
|
355
|
+
val = find_visual_value_near(regions, file_label)
|
|
356
|
+
if val:
|
|
357
|
+
fields.file_number = val.text.strip()
|
|
358
|
+
|
|
359
|
+
# --- Address (multi-line) ---
|
|
360
|
+
address_label = find_visual_field(regions, _ADDRESS_LABELS)
|
|
361
|
+
if address_label:
|
|
362
|
+
# Collect lines below "Address" until we hit the next section
|
|
363
|
+
address_regions = _collect_multiline_value(
|
|
364
|
+
regions, address_label,
|
|
365
|
+
max_lines=6,
|
|
366
|
+
stop_labels=[_FILE_NO_LABELS, _OLD_PASSPORT_LABELS],
|
|
367
|
+
)
|
|
368
|
+
if address_regions:
|
|
369
|
+
full_address, pincode, city, state = _parse_address(address_regions)
|
|
370
|
+
fields.address = full_address
|
|
371
|
+
fields.pincode = pincode
|
|
372
|
+
fields.city = city
|
|
373
|
+
fields.state = state
|
|
374
|
+
|
|
375
|
+
# --- Old passport (renewals) ---
|
|
376
|
+
# The Indian passport has a single compound label "Old Passport No. with
|
|
377
|
+
# Date and Place of Issue" sitting above THREE columnar values:
|
|
378
|
+
# passport_number | date_of_issue | place_of_issue. So instead of running
|
|
379
|
+
# `find_visual_value_near` separately for each, we collect the whole row
|
|
380
|
+
# below the label and assign by content shape.
|
|
381
|
+
old_pp_label = find_visual_field(regions, _OLD_PASSPORT_LABELS)
|
|
382
|
+
if old_pp_label:
|
|
383
|
+
pp_no, doi, poi = _extract_old_passport_row(regions, old_pp_label)
|
|
384
|
+
fields.old_passport_number = pp_no
|
|
385
|
+
fields.old_passport_date_of_issue = doi
|
|
386
|
+
fields.old_passport_place_of_issue = poi
|
|
387
|
+
|
|
388
|
+
# Fall back to the separate-label path if we did not find the
|
|
389
|
+
# passport number in the columnar row (e.g. layouts where the labels
|
|
390
|
+
# are split into separate regions).
|
|
391
|
+
if fields.old_passport_number is None:
|
|
392
|
+
val = find_visual_value_near(regions, old_pp_label)
|
|
393
|
+
if val:
|
|
394
|
+
pp_match = re.search(r"[A-Z]\d{7}", val.text.upper())
|
|
395
|
+
fields.old_passport_number = pp_match.group(0) if pp_match else val.text.strip()
|
|
396
|
+
if fields.old_passport_date_of_issue is None:
|
|
397
|
+
old_doi_label = find_visual_field(regions, _OLD_PASSPORT_DOI_LABELS)
|
|
398
|
+
if old_doi_label and old_doi_label is not old_pp_label:
|
|
399
|
+
old_pp_bottom = max(p[1] for p in old_pp_label.bbox)
|
|
400
|
+
doi_top = min(p[1] for p in old_doi_label.bbox)
|
|
401
|
+
if doi_top > old_pp_bottom - 30:
|
|
402
|
+
val = find_visual_value_near(regions, old_doi_label)
|
|
403
|
+
if val:
|
|
404
|
+
fields.old_passport_date_of_issue = val.text.strip()
|
|
405
|
+
if fields.old_passport_place_of_issue is None:
|
|
406
|
+
old_poi_label = find_visual_field(regions, _OLD_PASSPORT_POI_LABELS)
|
|
407
|
+
if old_poi_label and old_poi_label is not old_pp_label:
|
|
408
|
+
old_pp_bottom = max(p[1] for p in old_pp_label.bbox)
|
|
409
|
+
poi_top = min(p[1] for p in old_poi_label.bbox)
|
|
410
|
+
if poi_top > old_pp_bottom - 30:
|
|
411
|
+
val = find_visual_value_near(regions, old_poi_label)
|
|
412
|
+
if val:
|
|
413
|
+
fields.old_passport_place_of_issue = val.text.strip()
|
|
414
|
+
|
|
415
|
+
return fields
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
# Old-passport row extraction (3 columns: passport_no | date | place_of_issue)
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
_PASSPORT_NUMBER_RE = re.compile(r"[A-Z]\d{7}")
|
|
423
|
+
_DATE_RE = re.compile(r"^\s*\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4}\s*$")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
_OLD_PASSPORT_ROW_LABEL_KEYWORDS = {"FILE", "ADDRESS", "FATHER", "MOTHER", "SPOUSE", "GUARDIAN"}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _extract_old_passport_row(
|
|
430
|
+
regions: list[TextRegion],
|
|
431
|
+
old_pp_label: TextRegion,
|
|
432
|
+
) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
|
433
|
+
"""Pull (passport_no, date_of_issue, place_of_issue) from the row directly
|
|
434
|
+
below the compound Old-Passport label.
|
|
435
|
+
|
|
436
|
+
The Indian passport back page (and several other formats) places these
|
|
437
|
+
three fields in a single row under one wide label. Treating them as
|
|
438
|
+
independent label/value pairs fails because there are no separate sub-
|
|
439
|
+
labels — the three values must be classified by their content shape:
|
|
440
|
+
a passport number, a date, and a place name.
|
|
441
|
+
"""
|
|
442
|
+
label_top = min(p[1] for p in old_pp_label.bbox)
|
|
443
|
+
label_bottom = max(p[1] for p in old_pp_label.bbox)
|
|
444
|
+
label_center_y = (label_top + label_bottom) // 2
|
|
445
|
+
|
|
446
|
+
# Use the label's vertical center as the lower bound, not the bottom —
|
|
447
|
+
# OCR bboxes for labels are routinely taller than the visible glyphs, so
|
|
448
|
+
# the value row often starts a few pixels above the label_bottom while
|
|
449
|
+
# still being clearly the next form row.
|
|
450
|
+
min_y = label_center_y
|
|
451
|
+
max_y = label_bottom + 60
|
|
452
|
+
|
|
453
|
+
row_regions: list[TextRegion] = []
|
|
454
|
+
for region in regions:
|
|
455
|
+
if region is old_pp_label:
|
|
456
|
+
continue
|
|
457
|
+
top = min(p[1] for p in region.bbox)
|
|
458
|
+
if not (min_y <= top <= max_y):
|
|
459
|
+
continue
|
|
460
|
+
text = region.text.strip()
|
|
461
|
+
if len(text) < 2 or _is_mostly_non_latin(text):
|
|
462
|
+
continue
|
|
463
|
+
# Skip neighbouring labels (File No., the next row's bilingual prefix, etc.)
|
|
464
|
+
normalised_words = set(re.sub(r"[^A-Z]+", " ", text.upper()).split())
|
|
465
|
+
if normalised_words & _OLD_PASSPORT_ROW_LABEL_KEYWORDS:
|
|
466
|
+
continue
|
|
467
|
+
row_regions.append(region)
|
|
468
|
+
|
|
469
|
+
if not row_regions:
|
|
470
|
+
return None, None, None
|
|
471
|
+
|
|
472
|
+
row_regions.sort(key=lambda r: min(p[0] for p in r.bbox))
|
|
473
|
+
|
|
474
|
+
pp_no: Optional[str] = None
|
|
475
|
+
doi: Optional[str] = None
|
|
476
|
+
poi: Optional[str] = None
|
|
477
|
+
|
|
478
|
+
for region in row_regions:
|
|
479
|
+
text = region.text.strip()
|
|
480
|
+
upper = text.upper()
|
|
481
|
+
# Passport number must MATCH the whole region exactly — otherwise we
|
|
482
|
+
# would slice "L2072369" out of file-number text like "DL2072369058018".
|
|
483
|
+
if pp_no is None and _PASSPORT_NUMBER_RE.fullmatch(upper):
|
|
484
|
+
pp_no = upper
|
|
485
|
+
continue
|
|
486
|
+
if doi is None and _DATE_RE.match(text):
|
|
487
|
+
doi = text
|
|
488
|
+
continue
|
|
489
|
+
if poi is None and not any(ch.isdigit() for ch in text):
|
|
490
|
+
poi = text
|
|
491
|
+
|
|
492
|
+
return pp_no, doi, poi
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Local barcode decoding and bounded AAMVA PDF417 payload parsing.
|
|
2
|
+
|
|
3
|
+
Annex D of the AAMVA 2025 DL/ID standard defines the header/subfile directory.
|
|
4
|
+
Decoding a barcode establishes neither issuer authenticity nor a signature.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BarcodeResult:
|
|
18
|
+
format: str
|
|
19
|
+
text: str
|
|
20
|
+
bbox: list[list[int]]
|
|
21
|
+
raw_bytes: bytes = b""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def barcode_decoder_available() -> bool:
|
|
25
|
+
return importlib.util.find_spec("zxingcpp") is not None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def decode_barcodes(image: np.ndarray) -> list[BarcodeResult]:
|
|
29
|
+
"""Decode PDF417/other barcodes locally; an optional missing backend yields []."""
|
|
30
|
+
if not barcode_decoder_available():
|
|
31
|
+
return []
|
|
32
|
+
import zxingcpp
|
|
33
|
+
|
|
34
|
+
# OCR images are BGR. Grayscale conversion also makes channel ordering
|
|
35
|
+
# irrelevant to the native decoder and avoids an additional cv2 dependency.
|
|
36
|
+
if image.ndim == 3:
|
|
37
|
+
image = np.mean(image[:, :, :3], axis=2).astype(np.uint8)
|
|
38
|
+
results = []
|
|
39
|
+
for barcode in zxingcpp.read_barcodes(np.ascontiguousarray(image)):
|
|
40
|
+
if not barcode.valid:
|
|
41
|
+
continue
|
|
42
|
+
position = barcode.position
|
|
43
|
+
bbox = [[int(point.x), int(point.y)] for point in (position.top_left, position.top_right, position.bottom_right, position.bottom_left)]
|
|
44
|
+
results.append(BarcodeResult(str(barcode.format), barcode.text, bbox, bytes(barcode.bytes)))
|
|
45
|
+
return results
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
decode = decode_barcodes
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AAMVAResult:
|
|
53
|
+
document_type: str
|
|
54
|
+
issuer_id: str
|
|
55
|
+
version: int
|
|
56
|
+
jurisdiction_version: int
|
|
57
|
+
elements: dict[str, str]
|
|
58
|
+
errors: list[str] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_HEADER = re.compile(rb"@\n\x1e\rANSI ([0-9]{6})([0-9]{2})([0-9]{2})([0-9]{2})")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_aamva(barcode: BarcodeResult | str) -> AAMVAResult | None:
|
|
65
|
+
"""Parse directory offsets against original bytes, not stripped OCR text.
|
|
66
|
+
|
|
67
|
+
None means this is not an AAMVA payload. Recognizable malformed payloads
|
|
68
|
+
raise ValueError so callers can surface corrupt barcode evidence.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
if isinstance(barcode, str):
|
|
72
|
+
if not barcode.startswith("@") or "ANSI " not in barcode[:16]:
|
|
73
|
+
return None
|
|
74
|
+
data = barcode.encode("latin-1")
|
|
75
|
+
else:
|
|
76
|
+
if not barcode.raw_bytes and (not barcode.text.startswith("@") or "ANSI " not in barcode.text[:16]):
|
|
77
|
+
return None
|
|
78
|
+
data = barcode.raw_bytes or barcode.text.encode("latin-1")
|
|
79
|
+
except UnicodeEncodeError:
|
|
80
|
+
raise ValueError("INVALID_AAMVA_ENCODING") from None
|
|
81
|
+
if not data.startswith(b"@") or b"ANSI " not in data[:16]:
|
|
82
|
+
return None
|
|
83
|
+
header = _HEADER.match(data)
|
|
84
|
+
if header is None:
|
|
85
|
+
raise ValueError("INVALID_AAMVA_HEADER")
|
|
86
|
+
issuer, version, jurisdiction, count = header.groups()
|
|
87
|
+
version_number = int(version)
|
|
88
|
+
if not 1 <= version_number <= 11:
|
|
89
|
+
raise ValueError("UNSUPPORTED_AAMVA_VERSION")
|
|
90
|
+
count_number = int(count)
|
|
91
|
+
directory_end = header.end() + 10 * count_number
|
|
92
|
+
if not count_number or directory_end > len(data):
|
|
93
|
+
raise ValueError("INVALID_AAMVA_DIRECTORY")
|
|
94
|
+
subfiles: list[tuple[str, int, int]] = []
|
|
95
|
+
for index in range(count_number):
|
|
96
|
+
entry = data[header.end() + 10 * index:header.end() + 10 * (index + 1)]
|
|
97
|
+
if not re.fullmatch(rb"[A-Z]{2}[0-9]{8}", entry):
|
|
98
|
+
raise ValueError("INVALID_AAMVA_DIRECTORY")
|
|
99
|
+
kind, offset, length = entry[:2].decode("ascii"), int(entry[2:6]), int(entry[6:10])
|
|
100
|
+
end = offset + length
|
|
101
|
+
if offset < directory_end or length < 2 or end > len(data) or data[offset:offset + 2] != entry[:2]:
|
|
102
|
+
raise ValueError("INVALID_AAMVA_SUBFILE_BOUNDS")
|
|
103
|
+
if any(offset < old_end and start < end for _, start, old_end in subfiles):
|
|
104
|
+
raise ValueError("OVERLAPPING_AAMVA_SUBFILES")
|
|
105
|
+
subfiles.append((kind, offset, end))
|
|
106
|
+
identities = [(kind, start, end) for kind, start, end in subfiles if kind in ("DL", "ID")]
|
|
107
|
+
if len(identities) != 1:
|
|
108
|
+
raise ValueError("AMBIGUOUS_AAMVA_DOCUMENT_TYPE")
|
|
109
|
+
kind, start, end = identities[0]
|
|
110
|
+
# Annex D.6 specifies ISO 8859-1. Offsets count bytes, including accented
|
|
111
|
+
# Latin characters; UTF-8 encoding would shift the subfile boundaries.
|
|
112
|
+
content = data[start + 2:end].decode("latin-1")
|
|
113
|
+
elements: dict[str, str] = {}
|
|
114
|
+
errors: list[str] = []
|
|
115
|
+
for record in re.split(r"[\r\n]", content):
|
|
116
|
+
if not record:
|
|
117
|
+
continue
|
|
118
|
+
if not re.fullmatch(r"[A-Z]{3}[ -~\xa0-\xff]*", record):
|
|
119
|
+
raise ValueError("INVALID_AAMVA_ELEMENT")
|
|
120
|
+
key, value = record[:3], record[3:].strip()
|
|
121
|
+
if key in elements and elements[key] != value:
|
|
122
|
+
errors.append(f"CONFLICTING_AAMVA_ELEMENT_{key}")
|
|
123
|
+
else:
|
|
124
|
+
elements[key] = value
|
|
125
|
+
return AAMVAResult("us_driver_license" if kind == "DL" else "us_state_id", issuer.decode("ascii"), version_number, int(jurisdiction), elements, errors)
|