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,510 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Extractor for Indian Driving Licences (DL).
|
|
3
|
+
|
|
4
|
+
Fields: DL number, name, relation (S/D/W of), date of birth, date of issue,
|
|
5
|
+
validity date, address, blood group, class of vehicle (COV).
|
|
6
|
+
|
|
7
|
+
Indian DL layouts vary a great deal by issuing state/RTO and are frequently
|
|
8
|
+
bilingual (Hindi/English and regional scripts). Extraction is therefore
|
|
9
|
+
label- and format-driven rather than positional:
|
|
10
|
+
|
|
11
|
+
* The DL number is recovered with ``core.validators.normalize_dl`` (state
|
|
12
|
+
code + RTO + year + serial), tolerating spaces/hyphens and the common
|
|
13
|
+
case where OCR splits the number across two adjacent regions.
|
|
14
|
+
* A DL card carries several ``DD/MM/YYYY`` dates (DOB, issue, one or two
|
|
15
|
+
validity dates). Each date is assigned strictly by the label adjacent to
|
|
16
|
+
it (right-then-below), never by raw reading order, so re-ordered or
|
|
17
|
+
multi-column layouts do not scramble the fields.
|
|
18
|
+
* When both a non-transport (NT) and a transport (TR) validity date are
|
|
19
|
+
present, the non-transport / primary validity is preferred (see
|
|
20
|
+
``_find_validity_date``).
|
|
21
|
+
* Addresses may be multi-line and may appear twice (Present + Permanent);
|
|
22
|
+
the present/current address is preferred (see ``_find_address``).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from .ocr_engine import TextRegion
|
|
32
|
+
from .validator import (
|
|
33
|
+
find_label_value,
|
|
34
|
+
find_visual_field,
|
|
35
|
+
find_visual_value_near,
|
|
36
|
+
find_visual_value_right,
|
|
37
|
+
)
|
|
38
|
+
from .validators import normalize_dl
|
|
39
|
+
|
|
40
|
+
# A DD/MM/YYYY-style date anywhere in a string (also tolerates 2-digit years
|
|
41
|
+
# and '.'/'-'/'/' separators that OCR emits interchangeably).
|
|
42
|
+
_DATE_RE = re.compile(r"\b(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})\b")
|
|
43
|
+
|
|
44
|
+
# Blood group as a standalone token, e.g. "B+", "O-", "AB+VE", "A POSITIVE".
|
|
45
|
+
# A trailing sign/word is REQUIRED so a bare "B" inside a label like "BG" or a
|
|
46
|
+
# word like "BIHAR" is not mistaken for a blood group.
|
|
47
|
+
_BLOOD_GROUP_RE = re.compile(
|
|
48
|
+
r"\b(AB|A|B|O)\s*"
|
|
49
|
+
r"(\+VE|-VE|\+|-|POS(?:ITIVE)?|NEG(?:ATIVE)?)"
|
|
50
|
+
r"(?![A-Z])",
|
|
51
|
+
re.IGNORECASE,
|
|
52
|
+
)
|
|
53
|
+
# Canonical class-of-vehicle tokens that appear on Indian DLs.
|
|
54
|
+
_COV_TOKENS = [
|
|
55
|
+
"MCWOG", "MCWG", "MCEX50CC", "MGV", "HGMV", "HMV", "HPMV", "LMV-NT",
|
|
56
|
+
"LMV-TR", "LMV", "TRANS", "TRACTOR", "ERIG", "ROAD ROLLER", "MC", "FVG",
|
|
57
|
+
"PSV", "3WT", "3WNT", "INVCRG",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
_NAME_LABELS = ["NAME", "HOLDER S NAME", "HOLDERS NAME", "HOLDER NAME"]
|
|
61
|
+
# S/D/W of => relation/guardian. Many OCRs collapse the slashes.
|
|
62
|
+
_RELATION_LABELS = [
|
|
63
|
+
"SON DAUGHTER WIFE OF",
|
|
64
|
+
"S D W OF",
|
|
65
|
+
"S O",
|
|
66
|
+
"D O",
|
|
67
|
+
"W O",
|
|
68
|
+
"C O",
|
|
69
|
+
"SON OF",
|
|
70
|
+
"DAUGHTER OF",
|
|
71
|
+
"WIFE OF",
|
|
72
|
+
"FATHER",
|
|
73
|
+
"GUARDIAN",
|
|
74
|
+
]
|
|
75
|
+
_DOB_LABELS = ["DATE OF BIRTH", "DOB", "BIRTH"]
|
|
76
|
+
_DOI_LABELS = ["DATE OF ISSUE", "ISSUE DATE", "DOI", "ISSUE"]
|
|
77
|
+
# Generic validity labels (used when there is a single validity date).
|
|
78
|
+
_VALIDITY_LABELS = [
|
|
79
|
+
"VALID TILL",
|
|
80
|
+
"VALID UPTO",
|
|
81
|
+
"VALID UNTIL",
|
|
82
|
+
"VALIDITY",
|
|
83
|
+
"DATE OF EXPIRY",
|
|
84
|
+
"EXPIRY",
|
|
85
|
+
]
|
|
86
|
+
# Non-transport (primary) validity labels — preferred when both NT and TR exist.
|
|
87
|
+
_VALIDITY_NT_LABELS = [
|
|
88
|
+
"VALIDITY NT",
|
|
89
|
+
"VALID TILL NT",
|
|
90
|
+
"NON TRANSPORT",
|
|
91
|
+
"NT",
|
|
92
|
+
]
|
|
93
|
+
# Transport validity labels — only used if no NT/primary date is found.
|
|
94
|
+
_VALIDITY_TR_LABELS = [
|
|
95
|
+
"VALIDITY TR",
|
|
96
|
+
"VALID TILL TR",
|
|
97
|
+
"TRANSPORT",
|
|
98
|
+
"TR",
|
|
99
|
+
]
|
|
100
|
+
_BLOOD_GROUP_LABELS = ["BLOOD GROUP", "BG", "BLOOD"]
|
|
101
|
+
_COV_LABELS = [
|
|
102
|
+
"CLASS OF VEHICLE",
|
|
103
|
+
"CLASS OF VEHICLES",
|
|
104
|
+
"COV",
|
|
105
|
+
"CL OF VEH",
|
|
106
|
+
"AUTHORISATION TO DRIVE",
|
|
107
|
+
"AUTHORIZATION TO DRIVE",
|
|
108
|
+
]
|
|
109
|
+
# Present/current address preferred over permanent.
|
|
110
|
+
_PRESENT_ADDRESS_LABELS = [
|
|
111
|
+
"PRESENT ADDRESS",
|
|
112
|
+
"CURRENT ADDRESS",
|
|
113
|
+
"TEMPORARY ADDRESS",
|
|
114
|
+
"TEMP ADDRESS",
|
|
115
|
+
]
|
|
116
|
+
_PERMANENT_ADDRESS_LABELS = ["PERMANENT ADDRESS", "PERM ADDRESS"]
|
|
117
|
+
_GENERIC_ADDRESS_LABELS = ["ADDRESS", "ADD", "ADDR"]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class DrivingLicenceFields:
|
|
122
|
+
dl_number: Optional[str] = None
|
|
123
|
+
name: Optional[str] = None
|
|
124
|
+
date_of_birth: Optional[str] = None
|
|
125
|
+
issue_date: Optional[str] = None
|
|
126
|
+
validity_date: Optional[str] = None
|
|
127
|
+
address: Optional[str] = None
|
|
128
|
+
# --- Added fields (must be synced to TS DrivingLicenceFields) ---
|
|
129
|
+
relation_name: Optional[str] = None
|
|
130
|
+
blood_group: Optional[str] = None
|
|
131
|
+
class_of_vehicle: Optional[str] = None
|
|
132
|
+
# Secondary (transport) validity date, when a card lists both NT and TR.
|
|
133
|
+
validity_date_transport: Optional[str] = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# DL number
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def _find_dl_number(regions: list[TextRegion]) -> Optional[str]:
|
|
141
|
+
"""Recover the DL number, including the case where OCR splits it in two.
|
|
142
|
+
|
|
143
|
+
First try a direct ``normalize_dl`` scan over each region (handles
|
|
144
|
+
``MH12 20110012345``, ``DL-0420110149646``, ``TN0120200001234`` …). If
|
|
145
|
+
that fails, try concatenating spatially adjacent regions on the same row,
|
|
146
|
+
since some cards render the state/RTO prefix and the serial as separate
|
|
147
|
+
OCR tokens (e.g. ``"MH12"`` then ``"20110012345"``).
|
|
148
|
+
"""
|
|
149
|
+
for region in regions:
|
|
150
|
+
dl = normalize_dl(region.text)
|
|
151
|
+
if dl:
|
|
152
|
+
return dl
|
|
153
|
+
|
|
154
|
+
# Adjacency fallback: join same-row neighbours left-to-right and re-scan.
|
|
155
|
+
ordered = sorted(
|
|
156
|
+
regions,
|
|
157
|
+
key=lambda r: (
|
|
158
|
+
round(min(p[1] for p in r.bbox) / 20), # row band
|
|
159
|
+
min(p[0] for p in r.bbox),
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
for i, region in enumerate(ordered):
|
|
163
|
+
top = min(p[1] for p in region.bbox)
|
|
164
|
+
bottom = max(p[1] for p in region.bbox)
|
|
165
|
+
height = max(bottom - top, 1)
|
|
166
|
+
combined = region.text
|
|
167
|
+
for nxt in ordered[i + 1 : i + 4]:
|
|
168
|
+
n_top = min(p[1] for p in nxt.bbox)
|
|
169
|
+
n_bottom = max(p[1] for p in nxt.bbox)
|
|
170
|
+
overlap = min(bottom, n_bottom) - max(top, n_top)
|
|
171
|
+
if overlap < height * 0.4:
|
|
172
|
+
continue
|
|
173
|
+
combined += " " + nxt.text
|
|
174
|
+
dl = normalize_dl(combined)
|
|
175
|
+
if dl:
|
|
176
|
+
return dl
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Dates
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def _first_date(text: Optional[str]) -> Optional[str]:
|
|
185
|
+
if not text:
|
|
186
|
+
return None
|
|
187
|
+
m = _DATE_RE.search(text)
|
|
188
|
+
return m.group(1) if m else None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _labelled_date(
|
|
192
|
+
regions: list[TextRegion],
|
|
193
|
+
labels: list[str],
|
|
194
|
+
*,
|
|
195
|
+
label_region: Optional[TextRegion] = None,
|
|
196
|
+
) -> Optional[str]:
|
|
197
|
+
"""Resolve a date by the label adjacent to it (right then below).
|
|
198
|
+
|
|
199
|
+
The label's *own* text is also inspected: some compact layouts print the
|
|
200
|
+
value on the same region as the label (``"DOB: 09/03/1986"``).
|
|
201
|
+
"""
|
|
202
|
+
if label_region is None:
|
|
203
|
+
label_region = find_visual_field(regions, labels)
|
|
204
|
+
if label_region is None:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
# Date embedded in the label region itself.
|
|
208
|
+
self_date = _first_date(label_region.text)
|
|
209
|
+
if self_date:
|
|
210
|
+
return self_date
|
|
211
|
+
|
|
212
|
+
value = find_visual_value_right(regions, label_region) or find_visual_value_near(
|
|
213
|
+
regions, label_region
|
|
214
|
+
)
|
|
215
|
+
if value is not None:
|
|
216
|
+
return _first_date(value.text)
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _find_validity_date(regions: list[TextRegion]) -> tuple[Optional[str], Optional[str]]:
|
|
221
|
+
"""Return ``(primary_validity, transport_validity)``.
|
|
222
|
+
|
|
223
|
+
Indian DLs may carry two validity dates: NT (non-transport) and TR
|
|
224
|
+
(transport). We prefer the non-transport / primary date for
|
|
225
|
+
``validity_date`` and expose the transport date separately.
|
|
226
|
+
|
|
227
|
+
Resolution order for the primary date:
|
|
228
|
+
1. An explicit non-transport (NT) label.
|
|
229
|
+
2. A generic "Valid Till / Valid Upto / Validity" label.
|
|
230
|
+
The transport date is taken from an explicit TR label when present.
|
|
231
|
+
"""
|
|
232
|
+
nt_label = find_visual_field(regions, _VALIDITY_NT_LABELS)
|
|
233
|
+
tr_label = find_visual_field(regions, _VALIDITY_TR_LABELS)
|
|
234
|
+
generic_label = find_visual_field(regions, _VALIDITY_LABELS)
|
|
235
|
+
|
|
236
|
+
transport = _labelled_date(regions, _VALIDITY_TR_LABELS, label_region=tr_label)
|
|
237
|
+
|
|
238
|
+
# Avoid letting a generic "VALIDITY" match collide with the TR row: if the
|
|
239
|
+
# generic match resolved to the same region as the TR label, ignore it.
|
|
240
|
+
primary = None
|
|
241
|
+
if nt_label is not None:
|
|
242
|
+
primary = _labelled_date(regions, _VALIDITY_NT_LABELS, label_region=nt_label)
|
|
243
|
+
if primary is None and generic_label is not None and generic_label is not tr_label:
|
|
244
|
+
primary = _labelled_date(regions, _VALIDITY_LABELS, label_region=generic_label)
|
|
245
|
+
|
|
246
|
+
# If we only found a transport date, surface it as the primary too so the
|
|
247
|
+
# field is never silently empty when a validity date plainly exists.
|
|
248
|
+
if primary is None and transport is not None:
|
|
249
|
+
primary = transport
|
|
250
|
+
|
|
251
|
+
return primary, transport
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# Name / relation
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
# Any region whose normalised text matches one of these is a field label, not a
|
|
259
|
+
# value — used to reject a label that was mistakenly picked up as a name/relation.
|
|
260
|
+
_ALL_LABEL_WORDS = frozenset(
|
|
261
|
+
word
|
|
262
|
+
for label_set in (
|
|
263
|
+
_NAME_LABELS,
|
|
264
|
+
_RELATION_LABELS,
|
|
265
|
+
_DOB_LABELS,
|
|
266
|
+
_DOI_LABELS,
|
|
267
|
+
_VALIDITY_LABELS,
|
|
268
|
+
_VALIDITY_NT_LABELS,
|
|
269
|
+
_VALIDITY_TR_LABELS,
|
|
270
|
+
_BLOOD_GROUP_LABELS,
|
|
271
|
+
_COV_LABELS,
|
|
272
|
+
_PRESENT_ADDRESS_LABELS,
|
|
273
|
+
_PERMANENT_ADDRESS_LABELS,
|
|
274
|
+
_GENERIC_ADDRESS_LABELS,
|
|
275
|
+
)
|
|
276
|
+
for word in label_set
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _is_known_label(text: str) -> bool:
|
|
281
|
+
norm = re.sub(r"[^A-Z0-9]+", " ", text.upper()).strip()
|
|
282
|
+
return norm in _ALL_LABEL_WORDS
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _looks_like_value(text: str) -> bool:
|
|
286
|
+
"""True if a string looks like a usable name/value rather than empty noise."""
|
|
287
|
+
cleaned = text.strip()
|
|
288
|
+
return len(re.sub(r"[^A-Za-z]", "", cleaned)) >= 2
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _find_name(regions: list[TextRegion]) -> Optional[str]:
|
|
292
|
+
"""Resolve the holder name, avoiding the relation (S/D/W of) value.
|
|
293
|
+
|
|
294
|
+
``find_visual_field`` for "NAME" can latch onto a relation label whose
|
|
295
|
+
text also contains "NAME" in bilingual cards; we explicitly prefer a label
|
|
296
|
+
that is exactly/primarily a name label and is not a relation label.
|
|
297
|
+
"""
|
|
298
|
+
value = find_label_value(regions, _NAME_LABELS)
|
|
299
|
+
if value and _looks_like_value(value) and not _is_known_label(value):
|
|
300
|
+
return value.strip()
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _find_relation(regions: list[TextRegion]) -> Optional[str]:
|
|
305
|
+
value = find_label_value(regions, _RELATION_LABELS)
|
|
306
|
+
if value and _looks_like_value(value) and not _is_known_label(value):
|
|
307
|
+
return value.strip()
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
# Blood group / class of vehicle
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
def _normalise_blood_group(text: str) -> Optional[str]:
|
|
316
|
+
upper = text.upper()
|
|
317
|
+
# Never read a blood group out of the label words themselves.
|
|
318
|
+
if re.search(r"BLOOD|GROUP|\bBG\b", upper):
|
|
319
|
+
return None
|
|
320
|
+
m = _BLOOD_GROUP_RE.search(upper)
|
|
321
|
+
if not m:
|
|
322
|
+
return None
|
|
323
|
+
group = m.group(1)
|
|
324
|
+
suffix = m.group(2)
|
|
325
|
+
sign = "+" if suffix[0] in "+P" or suffix.upper().startswith("POS") else "-"
|
|
326
|
+
return f"{group}{sign}"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _find_blood_group(regions: list[TextRegion]) -> Optional[str]:
|
|
330
|
+
labelled = find_label_value(regions, _BLOOD_GROUP_LABELS)
|
|
331
|
+
if labelled and (group := _normalise_blood_group(labelled)):
|
|
332
|
+
return group
|
|
333
|
+
label = find_visual_field(regions, _BLOOD_GROUP_LABELS)
|
|
334
|
+
if label is not None:
|
|
335
|
+
# Value may sit to the right, below, or (rarely) on the label region.
|
|
336
|
+
for candidate_text in (
|
|
337
|
+
getattr(find_visual_value_right(regions, label), "text", "") or "",
|
|
338
|
+
getattr(find_visual_value_near(regions, label), "text", "") or "",
|
|
339
|
+
label.text,
|
|
340
|
+
):
|
|
341
|
+
bg = _normalise_blood_group(candidate_text)
|
|
342
|
+
if bg:
|
|
343
|
+
return bg
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _find_class_of_vehicle(regions: list[TextRegion]) -> Optional[str]:
|
|
348
|
+
"""Collect class-of-vehicle (COV) tokens (e.g. ``MCWG``, ``LMV``).
|
|
349
|
+
|
|
350
|
+
Returns the COV value adjacent to a COV label; falls back to scanning for
|
|
351
|
+
canonical COV tokens anywhere on the card (multiple classes joined).
|
|
352
|
+
"""
|
|
353
|
+
label = find_visual_field(regions, _COV_LABELS)
|
|
354
|
+
if label is not None:
|
|
355
|
+
value = find_visual_value_right(regions, label) or find_visual_value_near(
|
|
356
|
+
regions, label
|
|
357
|
+
)
|
|
358
|
+
if value is not None:
|
|
359
|
+
tokens = _extract_cov_tokens(value.text)
|
|
360
|
+
if tokens:
|
|
361
|
+
return ", ".join(tokens)
|
|
362
|
+
|
|
363
|
+
# Fallback: scan the whole card for canonical COV tokens.
|
|
364
|
+
found: list[str] = []
|
|
365
|
+
for region in regions:
|
|
366
|
+
for tok in _extract_cov_tokens(region.text):
|
|
367
|
+
if tok not in found:
|
|
368
|
+
found.append(tok)
|
|
369
|
+
return ", ".join(found) if found else None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _extract_cov_tokens(text: str) -> list[str]:
|
|
373
|
+
upper = re.sub(r"\s+", " ", text.upper())
|
|
374
|
+
found: list[str] = []
|
|
375
|
+
for tok in _COV_TOKENS: # longest-first ordering in the list avoids LMV ⊂ LMV-NT
|
|
376
|
+
if re.search(rf"(?<![A-Z0-9-]){re.escape(tok)}(?![A-Z0-9])", upper):
|
|
377
|
+
if not any(tok in existing for existing in found):
|
|
378
|
+
found.append(tok)
|
|
379
|
+
return found
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ---------------------------------------------------------------------------
|
|
383
|
+
# Address
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
def _collect_address_block(
|
|
387
|
+
regions: list[TextRegion], label: TextRegion
|
|
388
|
+
) -> Optional[str]:
|
|
389
|
+
"""Collect the (possibly multi-line) address block beneath/right of a label."""
|
|
390
|
+
label_bottom = max(p[1] for p in label.bbox)
|
|
391
|
+
label_top = min(p[1] for p in label.bbox)
|
|
392
|
+
label_left = min(p[0] for p in label.bbox)
|
|
393
|
+
|
|
394
|
+
lines: list[tuple[int, int, str]] = []
|
|
395
|
+
|
|
396
|
+
# Inline value on the same row as the label (e.g. "Address : 14 Nehru Nagar").
|
|
397
|
+
inline = find_visual_value_right(regions, label)
|
|
398
|
+
if inline is not None and not _is_other_label(inline.text):
|
|
399
|
+
lines.append((label_top, min(p[0] for p in inline.bbox), inline.text.strip()))
|
|
400
|
+
|
|
401
|
+
# Candidate rows below the label, left-aligned with the label column.
|
|
402
|
+
candidates: list[tuple[int, int, str]] = []
|
|
403
|
+
for region in regions:
|
|
404
|
+
if region is label or region is inline:
|
|
405
|
+
continue
|
|
406
|
+
top = min(p[1] for p in region.bbox)
|
|
407
|
+
left = min(p[0] for p in region.bbox)
|
|
408
|
+
# Must start below the label and within a reasonable block height.
|
|
409
|
+
if top <= label_bottom - 5 or top - label_bottom > 220:
|
|
410
|
+
continue
|
|
411
|
+
# Roughly left-aligned with the label column.
|
|
412
|
+
if abs(left - label_left) > 320:
|
|
413
|
+
continue
|
|
414
|
+
text = region.text.strip()
|
|
415
|
+
if len(text) < 2:
|
|
416
|
+
continue
|
|
417
|
+
candidates.append((top, left, text))
|
|
418
|
+
|
|
419
|
+
# Walk the rows top-to-bottom and stop at the first row that begins a
|
|
420
|
+
# *different* field (its label), so the next field's value cannot leak in.
|
|
421
|
+
candidates.sort(key=lambda x: (x[0], x[1]))
|
|
422
|
+
stop_y: Optional[int] = None
|
|
423
|
+
for top, _left, text in candidates:
|
|
424
|
+
if _is_other_label(text):
|
|
425
|
+
stop_y = top
|
|
426
|
+
break
|
|
427
|
+
for top, left, text in candidates:
|
|
428
|
+
if stop_y is not None and top >= stop_y:
|
|
429
|
+
continue
|
|
430
|
+
lines.append((top, left, text))
|
|
431
|
+
|
|
432
|
+
if not lines:
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
lines.sort(key=lambda x: (x[0], x[1]))
|
|
436
|
+
seen: set[str] = set()
|
|
437
|
+
ordered: list[str] = []
|
|
438
|
+
for _, _, text in lines:
|
|
439
|
+
if text not in seen:
|
|
440
|
+
seen.add(text)
|
|
441
|
+
ordered.append(text)
|
|
442
|
+
return ", ".join(ordered[:6])
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
_OTHER_LABEL_HINTS = [
|
|
446
|
+
"DL NO",
|
|
447
|
+
"LICENCE NO",
|
|
448
|
+
"LICENSE NO",
|
|
449
|
+
"NAME",
|
|
450
|
+
"DATE OF BIRTH",
|
|
451
|
+
"DOB",
|
|
452
|
+
"DATE OF ISSUE",
|
|
453
|
+
"VALID",
|
|
454
|
+
"VALIDITY",
|
|
455
|
+
"BLOOD GROUP",
|
|
456
|
+
"CLASS OF VEHICLE",
|
|
457
|
+
"COV",
|
|
458
|
+
"SIGNATURE",
|
|
459
|
+
"ISSUING AUTHORITY",
|
|
460
|
+
"AUTHORITY",
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _is_other_label(text: str) -> bool:
|
|
465
|
+
"""True if a region reads like a *different* field label (stop the block)."""
|
|
466
|
+
upper = re.sub(r"[^A-Z0-9]+", " ", text.upper()).strip()
|
|
467
|
+
padded = f" {upper} "
|
|
468
|
+
return any(f" {hint} " in padded for hint in _OTHER_LABEL_HINTS)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _find_address(regions: list[TextRegion]) -> Optional[str]:
|
|
472
|
+
"""Collect the address block, preferring the present/current address.
|
|
473
|
+
|
|
474
|
+
Order of preference:
|
|
475
|
+
1. Present / Current / Temporary address.
|
|
476
|
+
2. Permanent address.
|
|
477
|
+
3. Generic "Address" label.
|
|
478
|
+
"""
|
|
479
|
+
for label_set in (
|
|
480
|
+
_PRESENT_ADDRESS_LABELS,
|
|
481
|
+
_PERMANENT_ADDRESS_LABELS,
|
|
482
|
+
_GENERIC_ADDRESS_LABELS,
|
|
483
|
+
):
|
|
484
|
+
label = find_visual_field(regions, label_set)
|
|
485
|
+
if label is None:
|
|
486
|
+
continue
|
|
487
|
+
block = _collect_address_block(regions, label)
|
|
488
|
+
if block:
|
|
489
|
+
return block
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# ---------------------------------------------------------------------------
|
|
494
|
+
# Public API
|
|
495
|
+
# ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
def extract_driving_licence(regions: list[TextRegion]) -> DrivingLicenceFields:
|
|
498
|
+
fields = DrivingLicenceFields()
|
|
499
|
+
fields.dl_number = _find_dl_number(regions)
|
|
500
|
+
fields.name = _find_name(regions)
|
|
501
|
+
fields.relation_name = _find_relation(regions)
|
|
502
|
+
fields.date_of_birth = _labelled_date(regions, _DOB_LABELS)
|
|
503
|
+
fields.issue_date = _labelled_date(regions, _DOI_LABELS)
|
|
504
|
+
primary_validity, transport_validity = _find_validity_date(regions)
|
|
505
|
+
fields.validity_date = primary_validity
|
|
506
|
+
fields.validity_date_transport = transport_validity
|
|
507
|
+
fields.blood_group = _find_blood_group(regions)
|
|
508
|
+
fields.class_of_vehicle = _find_class_of_vehicle(regions)
|
|
509
|
+
fields.address = _find_address(regions)
|
|
510
|
+
return fields
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Read an incomplete EAD once without contrast enhancement; expose conflicts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .card_recovery import _read_box
|
|
9
|
+
from .structured_documents import StructuredExtraction, _finish, extract_structured_document
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def recover_ead_fields(unenhanced_image, initial: StructuredExtraction, ocr, recognize_line) -> StructuredExtraction | None:
|
|
13
|
+
if (unenhanced_image is None or initial.document_type != 'us_ead'
|
|
14
|
+
or initial.issuing_country != 'US' or initial.complete or len(initial.fields) < 3
|
|
15
|
+
or 'DOCUMENT_TYPE_NOT_CONFIRMED' in initial.errors):
|
|
16
|
+
return None
|
|
17
|
+
regions = ocr(unenhanced_image)
|
|
18
|
+
fresh = extract_structured_document(regions)
|
|
19
|
+
if fresh is None or fresh.document_type != 'us_ead' or fresh.issuing_country != 'US':
|
|
20
|
+
return None
|
|
21
|
+
if 'expiry_date' in fresh.missing_required_fields:
|
|
22
|
+
# An observed truncated label locates the pixels; only a fresh read of
|
|
23
|
+
# the complete label can associate a date with expiry.
|
|
24
|
+
anchor = next((row for row in regions if row.confidence >= .9 and len(row.bbox) == 4
|
|
25
|
+
and re.fullmatch(r'card\s+expir(?:e|es)?[.:]?', row.text.strip(), re.I)), None)
|
|
26
|
+
if anchor:
|
|
27
|
+
points = np.asarray(anchor.bbox, np.float32)
|
|
28
|
+
if np.isfinite(points).all():
|
|
29
|
+
tl, tr, br, bl = points
|
|
30
|
+
along = tr - tl
|
|
31
|
+
width, height = int(np.linalg.norm(along) * 1.2), int(np.linalg.norm(bl - tl))
|
|
32
|
+
read = _read_box(unenhanced_image, [tl - .1 * along, tr + .1 * along,
|
|
33
|
+
br + .1 * along, bl - .1 * along], width, height, recognize_line)
|
|
34
|
+
if (len(read) == 1 and read[0].confidence >= .9
|
|
35
|
+
and re.fullmatch(r'card\s+expires[.:]?', read[0].text.strip(), re.I)):
|
|
36
|
+
regions = regions + read
|
|
37
|
+
fresh = extract_structured_document(regions)
|
|
38
|
+
if (fresh is None or fresh.errors or set(initial.fields) - set(fresh.fields)
|
|
39
|
+
or (len(fresh.fields) <= len(initial.fields)
|
|
40
|
+
and len(fresh.missing_required_fields) >= len(initial.missing_required_fields))):
|
|
41
|
+
return None
|
|
42
|
+
added = fresh.fields.keys() - initial.fields.keys()
|
|
43
|
+
if any(not fresh.field_evidence.get(key)
|
|
44
|
+
or min(item['confidence'] for item in fresh.field_evidence[key]) < .9 for key in added):
|
|
45
|
+
return None
|
|
46
|
+
conflicts = [key for key, value in initial.fields.items()
|
|
47
|
+
if value not in (None, '') and fresh.fields[key] != value]
|
|
48
|
+
for key in conflicts:
|
|
49
|
+
fresh.field_evidence.setdefault(key, []).extend(initial.field_evidence.get(key, []))
|
|
50
|
+
fresh.errors.append('OCR_VIEW_CONFLICT_' + key.upper())
|
|
51
|
+
fresh.errors.extend(initial.errors)
|
|
52
|
+
fresh.checks['ocr_view_agreement'] = not conflicts
|
|
53
|
+
fresh.warnings.append('UNENHANCED_OCR_RECOVERY')
|
|
54
|
+
return _finish(fresh)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Conservative links from returned values to source text; never invent boxes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import fields
|
|
6
|
+
|
|
7
|
+
from .ocr_engine import TextRegion
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def camel(name: str) -> str:
|
|
11
|
+
parts = name.split('_')
|
|
12
|
+
return parts[0] + ''.join(p.title() for p in parts[1:])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def document_fields(result) -> dict:
|
|
16
|
+
if result.document_fields is not None:
|
|
17
|
+
return result.document_fields
|
|
18
|
+
output = {}
|
|
19
|
+
for attr in ('fields', 'back_page_fields', 'pan_fields', 'aadhaar_fields',
|
|
20
|
+
'driving_licence_fields', 'voter_id_fields', 'nrega_job_card_fields', 'npr_letter_fields'):
|
|
21
|
+
block = getattr(result, attr, None)
|
|
22
|
+
if block is not None:
|
|
23
|
+
output.update({f.name: getattr(block, f.name) for f in fields(block)})
|
|
24
|
+
return output
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _norm(value: str) -> str:
|
|
28
|
+
return ''.join(c for c in value.casefold() if c.isalnum())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def field_evidence(values: dict, regions: list[TextRegion], *, source: str = 'ocr', mrz_raw=None) -> dict:
|
|
32
|
+
evidence = {}
|
|
33
|
+
mrz_fields = {'surname', 'given_names', 'full_name', 'passport_number', 'nationality',
|
|
34
|
+
'date_of_birth', 'sex', 'expiry_date', 'country_code'}
|
|
35
|
+
for key, value in values.items():
|
|
36
|
+
if not isinstance(value, (str, int)) or not str(value).strip():
|
|
37
|
+
continue
|
|
38
|
+
needle = _norm(str(value))
|
|
39
|
+
hits = []
|
|
40
|
+
for region in regions:
|
|
41
|
+
is_mrz = bool(mrz_raw and region.text.replace(' ', '') in mrz_raw)
|
|
42
|
+
date = re.fullmatch(r'(\d{4})-(\d{2})-(\d{2})', str(value))
|
|
43
|
+
match = needle in _norm(region.text)
|
|
44
|
+
if date:
|
|
45
|
+
y, m, d = date.groups()
|
|
46
|
+
match |= any(x in _norm(region.text) for x in (d+m+y, m+d+y))
|
|
47
|
+
if is_mrz and key in mrz_fields:
|
|
48
|
+
match = True
|
|
49
|
+
if match and (len(needle) > 1 or is_mrz):
|
|
50
|
+
hits.append({'text': region.text, 'bbox': region.bbox,
|
|
51
|
+
'source': 'mrz' if is_mrz else source, 'confidence': region.confidence})
|
|
52
|
+
if hits:
|
|
53
|
+
evidence[key] = hits
|
|
54
|
+
return evidence
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Read a boxed W-9 TIN when text detection splits or omits individual digits."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .ocr_engine import TextRegion
|
|
9
|
+
from .structured_documents import _detect_text_type, extract_structured_document
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def recover_form_fields(image, regions: list[TextRegion], recognize_line) -> list[TextRegion]:
|
|
13
|
+
if _detect_text_type(regions)[0] != 'us_w9':
|
|
14
|
+
return regions
|
|
15
|
+
parsed = extract_structured_document(regions)
|
|
16
|
+
if parsed and parsed.fields.get('taxpayer_id'):
|
|
17
|
+
return regions
|
|
18
|
+
output = list(regions)
|
|
19
|
+
# At most two reads for each of the two labelled TIN boxes. The original
|
|
20
|
+
# full image determines evidence coordinates; no expected digits are used.
|
|
21
|
+
for label in ('social security number', 'employer identification number'):
|
|
22
|
+
anchor = next((r for r in regions if r.text.strip().lower() == label), None)
|
|
23
|
+
if not anchor or len(anchor.bbox) != 4:
|
|
24
|
+
continue
|
|
25
|
+
points = np.asarray(anchor.bbox)
|
|
26
|
+
left, top = points.min(axis=0)
|
|
27
|
+
right, bottom = points.max(axis=0)
|
|
28
|
+
height = bottom - top
|
|
29
|
+
if height < 8 or right - left < height * 4:
|
|
30
|
+
continue
|
|
31
|
+
x1, y1 = max(0, int(left - 4)), max(0, int(bottom))
|
|
32
|
+
x2, y2 = min(image.shape[1], int(left + height * 18)), min(image.shape[0], int(bottom + height * 2.8))
|
|
33
|
+
if x2 <= x1 or y2 <= y1:
|
|
34
|
+
continue
|
|
35
|
+
crop = image[y1:y2, x1:x2]
|
|
36
|
+
for row in recognize_line(crop):
|
|
37
|
+
# Box borders and printed hyphens can be recognized as separators.
|
|
38
|
+
# Letters/guessed substitutions and non-nine-digit reads fail closed.
|
|
39
|
+
if row.confidence < .8 or not re.fullmatch(r'[0-9\s|\[\]{}()_.:;\-]+', row.text):
|
|
40
|
+
continue
|
|
41
|
+
digits = re.sub(r'\D', '', row.text)
|
|
42
|
+
if len(digits) != 9:
|
|
43
|
+
continue
|
|
44
|
+
if row.confidence < .9:
|
|
45
|
+
# Printed box borders lower the whole-string confidence. For
|
|
46
|
+
# an uncertain read, require all nine digits to agree after
|
|
47
|
+
# removing long straight ruling lines and reading once more.
|
|
48
|
+
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
|
|
49
|
+
ink = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
|
|
50
|
+
vertical = cv2.morphologyEx(ink, cv2.MORPH_OPEN, np.ones((int(height * 1.6), 1), np.uint8))
|
|
51
|
+
horizontal = cv2.morphologyEx(ink, cv2.MORPH_OPEN, np.ones((1, int(height * 3)), np.uint8))
|
|
52
|
+
cleaned = crop.copy()
|
|
53
|
+
cleaned[cv2.bitwise_or(vertical, horizontal) > 0] = 255
|
|
54
|
+
confirmation = recognize_line(cleaned)
|
|
55
|
+
if (len(confirmation) != 1 or confirmation[0].confidence < .8
|
|
56
|
+
or not re.fullmatch(r'[0-9\s|\[\]{}()_.:;\-]+', confirmation[0].text)
|
|
57
|
+
or re.sub(r'\D', '', confirmation[0].text) != digits):
|
|
58
|
+
continue
|
|
59
|
+
output.append(TextRegion(label + ': ' + digits,
|
|
60
|
+
[[x1, y1], [x2, y1], [x2, y2], [x1, y2]], row.confidence))
|
|
61
|
+
return output
|