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,336 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MRZ (Machine Readable Zone) parser for TD3 passports (ICAO Doc 9303).
|
|
3
|
+
|
|
4
|
+
Parses the two 44-character MRZ lines, extracts fields by position,
|
|
5
|
+
and validates ICAO check digits.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import unicodedata
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import date
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from .ocr_engine import TextRegion
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# ICAO check digit
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
_WEIGHTS = [7, 3, 1]
|
|
24
|
+
|
|
25
|
+
_CHAR_VALUES = {str(i): i for i in range(10)}
|
|
26
|
+
_CHAR_VALUES.update({chr(c): c - 55 for c in range(65, 91)}) # A=10 .. Z=35
|
|
27
|
+
_CHAR_VALUES["<"] = 0
|
|
28
|
+
|
|
29
|
+
_DIGIT_CORRECTIONS = str.maketrans({
|
|
30
|
+
"O": "0", "o": "0",
|
|
31
|
+
"D": "0", "d": "0",
|
|
32
|
+
"Q": "0", "q": "0",
|
|
33
|
+
"I": "1", "i": "1",
|
|
34
|
+
"l": "1", "L": "1",
|
|
35
|
+
"Z": "2", "z": "2",
|
|
36
|
+
"A": "4", "a": "4",
|
|
37
|
+
"S": "5", "s": "5",
|
|
38
|
+
"G": "6", "g": "6",
|
|
39
|
+
"B": "8", "b": "8",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _apply_digit_corrections(line2: str) -> str:
|
|
44
|
+
"""Apply digit corrections to known digit-only positions in MRZ line 2.
|
|
45
|
+
Positions: [9] pn check, [13:19] DOB, [19] DOB check, [21:27] expiry,
|
|
46
|
+
[27] expiry check, [42] personal check, [43] overall check.
|
|
47
|
+
Does NOT touch [0:9] passport number, [10:13] nationality, [20] sex, [28:42] personal number."""
|
|
48
|
+
chars = list(line2)
|
|
49
|
+
digit_positions = [9, 13, 14, 15, 16, 17, 18, 19, 21, 22, 23, 24, 25, 26, 27, 42, 43]
|
|
50
|
+
for pos in digit_positions:
|
|
51
|
+
if pos < len(chars):
|
|
52
|
+
chars[pos] = chars[pos].translate(_DIGIT_CORRECTIONS)
|
|
53
|
+
return "".join(chars)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def icao_check_digit(data: str) -> int:
|
|
57
|
+
"""Compute ICAO weighted checksum for a string of MRZ characters."""
|
|
58
|
+
total = 0
|
|
59
|
+
for i, ch in enumerate(data):
|
|
60
|
+
try:
|
|
61
|
+
val = _CHAR_VALUES[ch.upper()]
|
|
62
|
+
except KeyError:
|
|
63
|
+
raise ValueError(f"Invalid MRZ character: {ch!r}") from None
|
|
64
|
+
total += val * _WEIGHTS[i % 3]
|
|
65
|
+
return total % 10
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def verify_check_digit(data: str, expected: str) -> bool:
|
|
69
|
+
"""Verify a single check digit field."""
|
|
70
|
+
if len(expected) != 1 or expected not in "0123456789":
|
|
71
|
+
return False
|
|
72
|
+
try:
|
|
73
|
+
return icao_check_digit(data) == int(expected)
|
|
74
|
+
except ValueError:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Date parsing
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _parse_mrz_date(
|
|
83
|
+
yymmdd: str,
|
|
84
|
+
*,
|
|
85
|
+
is_expiry: bool = False,
|
|
86
|
+
reference_date: Optional[date] = None,
|
|
87
|
+
) -> Optional[str]:
|
|
88
|
+
"""Resolve an MRZ's two-digit year and validate the calendar date.
|
|
89
|
+
|
|
90
|
+
Birth dates use the most recent occurrence not after the reference date.
|
|
91
|
+
Expiry dates use the nearest century, supporting both expired documents and
|
|
92
|
+
future expiries. The MRZ alone cannot disambiguate dates a century apart.
|
|
93
|
+
"""
|
|
94
|
+
if not re.fullmatch(r"[0-9]{6}", yymmdd):
|
|
95
|
+
return None
|
|
96
|
+
reference_date = reference_date or date.today()
|
|
97
|
+
yy, mm, dd = int(yymmdd[:2]), int(yymmdd[2:4]), int(yymmdd[4:6])
|
|
98
|
+
year = (reference_date.year // 100) * 100 + yy
|
|
99
|
+
if is_expiry:
|
|
100
|
+
if year - reference_date.year > 50:
|
|
101
|
+
year -= 100
|
|
102
|
+
elif reference_date.year - year > 50:
|
|
103
|
+
year += 100
|
|
104
|
+
elif (year, mm, dd) > (reference_date.year, reference_date.month, reference_date.day):
|
|
105
|
+
year -= 100
|
|
106
|
+
try:
|
|
107
|
+
return date(year, mm, dd).isoformat()
|
|
108
|
+
except ValueError:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Result types
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class MRZField:
|
|
118
|
+
value: Optional[str]
|
|
119
|
+
raw: str
|
|
120
|
+
checksum_valid: Optional[bool] = None # None if no checksum for this field
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class MRZResult:
|
|
125
|
+
"""Parsed MRZ result with all fields and validation info."""
|
|
126
|
+
document_type: MRZField
|
|
127
|
+
country_code: MRZField
|
|
128
|
+
surname: MRZField
|
|
129
|
+
given_names: MRZField
|
|
130
|
+
passport_number: MRZField
|
|
131
|
+
nationality: MRZField
|
|
132
|
+
date_of_birth: MRZField
|
|
133
|
+
sex: MRZField
|
|
134
|
+
expiry_date: MRZField
|
|
135
|
+
personal_number: MRZField
|
|
136
|
+
overall_checksum_valid: bool
|
|
137
|
+
raw_lines: tuple[str, str]
|
|
138
|
+
errors: list[str] = field(default_factory=list)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# MRZ line detection
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
_MRZ_PATTERN = re.compile(r"^[A-Z0-9<]{40,44}$")
|
|
146
|
+
_MRZ_LINE1_PATTERN = re.compile(r"P[A-Z<][A-Z<]{3}[A-Z<]{35,39}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _clean_mrz_text(raw: str) -> str:
|
|
150
|
+
"""Clean OCR text for MRZ matching — fix common substitution errors."""
|
|
151
|
+
# Recognizers can emit compatibility glyphs such as Roman numeral I or
|
|
152
|
+
# full-width Latin letters. Unicode defines their ASCII equivalents;
|
|
153
|
+
# unlike guessing a country or name, this does not substitute characters
|
|
154
|
+
# based on the expected document content.
|
|
155
|
+
text = re.sub(r"\s+", "", unicodedata.normalize("NFKC", raw).upper())
|
|
156
|
+
# Common OCR substitutions for the '<' filler character
|
|
157
|
+
text = text.replace("«", "<").replace("‹", "<").replace(">", "<")
|
|
158
|
+
return text
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _find_mrz_lines(regions: list[TextRegion]) -> Optional[tuple[str, str]]:
|
|
162
|
+
"""Identify the two MRZ lines from OCR output."""
|
|
163
|
+
mrz_regions: list[tuple[int, str]] = []
|
|
164
|
+
for region in regions:
|
|
165
|
+
text = _clean_mrz_text(region.text)
|
|
166
|
+
if _MRZ_PATTERN.fullmatch(text):
|
|
167
|
+
y_pos = max(p[1] for p in region.bbox) if region.bbox else 0
|
|
168
|
+
mrz_regions.append((y_pos, text))
|
|
169
|
+
|
|
170
|
+
mrz_regions.sort(key=lambda x: x[0])
|
|
171
|
+
# Anchor the pair to a TD3 passport header. A footer or OCR duplicate below
|
|
172
|
+
# the MRZ must not displace the actual first line.
|
|
173
|
+
for index in range(len(mrz_regions) - 2, -1, -1):
|
|
174
|
+
line1 = mrz_regions[index][1]
|
|
175
|
+
line2 = mrz_regions[index + 1][1]
|
|
176
|
+
if _MRZ_LINE1_PATTERN.fullmatch(line1) and not _MRZ_LINE1_PATTERN.fullmatch(line2):
|
|
177
|
+
return (_pad_to_44(line1), _pad_to_44(line2))
|
|
178
|
+
# Re-read text can be mapped back to a sideways document in the source
|
|
179
|
+
# photograph. Use the header's local axes instead of global page Y there.
|
|
180
|
+
for header in regions:
|
|
181
|
+
first = _clean_mrz_text(header.text)
|
|
182
|
+
if not _MRZ_LINE1_PATTERN.fullmatch(first) or len(header.bbox) != 4:
|
|
183
|
+
continue
|
|
184
|
+
tl, tr, br, bl = header.bbox
|
|
185
|
+
ux, uy = tr[0] - tl[0], tr[1] - tl[1]
|
|
186
|
+
vx, vy = bl[0] - tl[0], bl[1] - tl[1]
|
|
187
|
+
width, height = (ux * ux + uy * uy) ** .5, (vx * vx + vy * vy) ** .5
|
|
188
|
+
if width < 100 or height < 5 or abs(uy) <= abs(ux):
|
|
189
|
+
continue
|
|
190
|
+
cx, cy = sum(p[0] for p in header.bbox) / 4, sum(p[1] for p in header.bbox) / 4
|
|
191
|
+
for row in regions:
|
|
192
|
+
second = _clean_mrz_text(row.text)
|
|
193
|
+
if row is header or not _MRZ_PATTERN.fullmatch(second) or _MRZ_LINE1_PATTERN.fullmatch(second) or len(row.bbox) != 4:
|
|
194
|
+
continue
|
|
195
|
+
dx, dy = sum(p[0] for p in row.bbox) / 4 - cx, sum(p[1] for p in row.bbox) / 4 - cy
|
|
196
|
+
across, below = abs((dx * ux + dy * uy) / width), (dx * vx + dy * vy) / height
|
|
197
|
+
if across < width * .15 and height * .5 < below < height * 4:
|
|
198
|
+
return (_pad_to_44(first), _pad_to_44(second))
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _pad_to_44(line: str) -> str:
|
|
203
|
+
"""Pad MRZ line to 44 characters with '<' if shorter."""
|
|
204
|
+
return line.ljust(44, "<")[:44]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Parser
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def _clean_name(raw: str) -> str:
|
|
212
|
+
"""Replace MRZ filler '<' with spaces and clean up."""
|
|
213
|
+
return raw.replace("<", " ").strip()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def parse_mrz(regions: list[TextRegion]) -> Optional[MRZResult]:
|
|
217
|
+
"""
|
|
218
|
+
Parse MRZ from OCR text regions.
|
|
219
|
+
|
|
220
|
+
Returns None if MRZ lines cannot be identified.
|
|
221
|
+
"""
|
|
222
|
+
lines = _find_mrz_lines(regions)
|
|
223
|
+
if lines is None:
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
line1, line2 = lines
|
|
227
|
+
line2 = _apply_digit_corrections(line2)
|
|
228
|
+
errors: list[str] = []
|
|
229
|
+
|
|
230
|
+
# --- Line 1 ---
|
|
231
|
+
# Pos 0: document type (1 char, usually 'P')
|
|
232
|
+
# Pos 1: document type sub (1 char)
|
|
233
|
+
# Pos 2-4: issuing country (3 chars)
|
|
234
|
+
# Pos 5-43: name (surname << given names)
|
|
235
|
+
|
|
236
|
+
doc_type_raw = line1[0:2]
|
|
237
|
+
country_raw = line1[2:5]
|
|
238
|
+
name_raw = line1[5:44]
|
|
239
|
+
|
|
240
|
+
# Split name: surname and given names separated by "<<"
|
|
241
|
+
name_parts = name_raw.split("<<", 1)
|
|
242
|
+
surname_raw = name_parts[0] if name_parts else ""
|
|
243
|
+
given_raw = name_parts[1] if len(name_parts) > 1 else ""
|
|
244
|
+
# Within given names a single filler separates components. A double filler
|
|
245
|
+
# starts padding; OCR noise later in that padding is not an extra name.
|
|
246
|
+
given_value, _, padding = given_raw.partition("<<")
|
|
247
|
+
if padding.replace("<", ""):
|
|
248
|
+
errors.append("MRZ_NAME_PADDING_NOISE")
|
|
249
|
+
|
|
250
|
+
# --- Line 2 ---
|
|
251
|
+
# Pos 0-8: passport number (9 chars)
|
|
252
|
+
# Pos 9: check digit for passport number
|
|
253
|
+
# Pos 10-12: nationality (3 chars)
|
|
254
|
+
# Pos 13-18: date of birth YYMMDD (6 chars)
|
|
255
|
+
# Pos 19: check digit for DOB
|
|
256
|
+
# Pos 20: sex (M/F/<)
|
|
257
|
+
# Pos 21-26: expiry date YYMMDD (6 chars)
|
|
258
|
+
# Pos 27: check digit for expiry
|
|
259
|
+
# Pos 28-41: personal number (14 chars)
|
|
260
|
+
# Pos 42: check digit for personal number
|
|
261
|
+
# Pos 43: overall check digit
|
|
262
|
+
|
|
263
|
+
pn_raw = line2[0:9]
|
|
264
|
+
pn_check = line2[9]
|
|
265
|
+
nationality_raw = line2[10:13]
|
|
266
|
+
dob_raw = line2[13:19]
|
|
267
|
+
dob_check = line2[19]
|
|
268
|
+
sex_raw = line2[20]
|
|
269
|
+
expiry_raw = line2[21:27]
|
|
270
|
+
expiry_check = line2[27]
|
|
271
|
+
personal_raw = line2[28:42]
|
|
272
|
+
personal_check = line2[42]
|
|
273
|
+
overall_check = line2[43]
|
|
274
|
+
|
|
275
|
+
# Validate check digits
|
|
276
|
+
pn_valid = verify_check_digit(pn_raw, pn_check)
|
|
277
|
+
dob_valid = verify_check_digit(dob_raw, dob_check)
|
|
278
|
+
expiry_valid = verify_check_digit(expiry_raw, expiry_check)
|
|
279
|
+
# ICAO Doc 9303-4 permits '<' instead of zero for unused optional data.
|
|
280
|
+
personal_valid = (
|
|
281
|
+
personal_raw == "<" * 14 and personal_check == "<"
|
|
282
|
+
) or verify_check_digit(personal_raw, personal_check)
|
|
283
|
+
|
|
284
|
+
# Overall check digit: computed over passport_number + check + DOB + check + expiry + check + personal + check
|
|
285
|
+
composite = line2[0:10] + line2[13:20] + line2[21:43]
|
|
286
|
+
overall_valid = verify_check_digit(composite, overall_check)
|
|
287
|
+
|
|
288
|
+
if not pn_valid:
|
|
289
|
+
errors.append("PASSPORT_NUMBER_CHECKSUM_FAILED")
|
|
290
|
+
if not dob_valid:
|
|
291
|
+
errors.append("DOB_CHECKSUM_FAILED")
|
|
292
|
+
if not expiry_valid:
|
|
293
|
+
errors.append("EXPIRY_CHECKSUM_FAILED")
|
|
294
|
+
if not personal_valid:
|
|
295
|
+
errors.append("PERSONAL_NUMBER_CHECKSUM_FAILED")
|
|
296
|
+
if not overall_valid:
|
|
297
|
+
errors.append("OVERALL_CHECKSUM_FAILED")
|
|
298
|
+
|
|
299
|
+
# Parse sex
|
|
300
|
+
sex_value = None
|
|
301
|
+
if sex_raw in ("M", "F"):
|
|
302
|
+
sex_value = sex_raw
|
|
303
|
+
elif sex_raw == "<" or sex_raw == "X":
|
|
304
|
+
sex_value = "X"
|
|
305
|
+
|
|
306
|
+
return MRZResult(
|
|
307
|
+
document_type=MRZField(value=doc_type_raw.replace("<", "").strip() or "P", raw=doc_type_raw),
|
|
308
|
+
country_code=MRZField(value=country_raw.replace("<", ""), raw=country_raw),
|
|
309
|
+
surname=MRZField(value=_clean_name(surname_raw), raw=surname_raw),
|
|
310
|
+
given_names=MRZField(value=_clean_name(given_value), raw=given_raw),
|
|
311
|
+
passport_number=MRZField(
|
|
312
|
+
value=pn_raw.replace("<", "").strip(),
|
|
313
|
+
raw=pn_raw,
|
|
314
|
+
checksum_valid=pn_valid,
|
|
315
|
+
),
|
|
316
|
+
nationality=MRZField(value=nationality_raw.replace("<", ""), raw=nationality_raw),
|
|
317
|
+
date_of_birth=MRZField(
|
|
318
|
+
value=_parse_mrz_date(dob_raw),
|
|
319
|
+
raw=dob_raw,
|
|
320
|
+
checksum_valid=dob_valid,
|
|
321
|
+
),
|
|
322
|
+
sex=MRZField(value=sex_value, raw=sex_raw),
|
|
323
|
+
expiry_date=MRZField(
|
|
324
|
+
value=_parse_mrz_date(expiry_raw, is_expiry=True),
|
|
325
|
+
raw=expiry_raw,
|
|
326
|
+
checksum_valid=expiry_valid,
|
|
327
|
+
),
|
|
328
|
+
personal_number=MRZField(
|
|
329
|
+
value=personal_raw.replace("<", "").strip() or None,
|
|
330
|
+
raw=personal_raw,
|
|
331
|
+
checksum_valid=personal_valid,
|
|
332
|
+
),
|
|
333
|
+
overall_checksum_valid=overall_valid and pn_valid and dob_valid and expiry_valid and personal_valid,
|
|
334
|
+
raw_lines=(line1, line2),
|
|
335
|
+
errors=errors,
|
|
336
|
+
)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Bounded, geometry-based re-reading of a damaged passport MRZ.
|
|
2
|
+
|
|
3
|
+
Only a checksum-valid TD3 pair is admitted; identifiers are never invented.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
import cv2
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .mrz_parser import _clean_mrz_text, parse_mrz, verify_check_digit
|
|
13
|
+
from .ocr_engine import TextRegion
|
|
14
|
+
from .recovery_budget import recovery_allowed
|
|
15
|
+
|
|
16
|
+
# A complete surname/given-name header with an observed padding terminator.
|
|
17
|
+
_PADDED_HEADER = re.compile(r'P[A-Z<][A-Z<]{3}[A-Z]+(?:<[A-Z]+)*<<[A-Z]+(?:<[A-Z]+)*<{2,}')
|
|
18
|
+
_ANCHOR = re.compile(r'[A-Z0-9<]{28,44}')
|
|
19
|
+
_LINE_TWO = re.compile(r'[A-Z0-9<]{9}[0-9][A-Z<]{3}[0-9]{7}[MF<][0-9]{7}[A-Z0-9<]{16}')
|
|
20
|
+
_LINE_TWO_PREFIX = re.compile(r'[A-Z0-9<]{9}[0-9][A-Z]{3}[0-9]{7}[MF<][0-9]{7}[A-Z0-9<]{0,15}')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _checked_line_two_prefix(text: str) -> bool:
|
|
24
|
+
"""Locate a truncated row from observed fields, without filling its suffix."""
|
|
25
|
+
return bool(_LINE_TWO_PREFIX.fullmatch(text)
|
|
26
|
+
and verify_check_digit(text[:9], text[9])
|
|
27
|
+
and verify_check_digit(text[13:19], text[19])
|
|
28
|
+
and verify_check_digit(text[21:27], text[27]))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _anchors(regions: list[TextRegion]) -> list[TextRegion]:
|
|
32
|
+
result = []
|
|
33
|
+
for row in regions:
|
|
34
|
+
text = _clean_mrz_text(row.text)
|
|
35
|
+
if (len(row.bbox) == 4 and _ANCHOR.fullmatch(text)
|
|
36
|
+
and (text.count('<') >= 2 or _LINE_TWO.fullmatch(text) or _checked_line_two_prefix(text))
|
|
37
|
+
and (not text.startswith('P') or _LINE_TWO.fullmatch(text) or _checked_line_two_prefix(text))):
|
|
38
|
+
result.append(row)
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def has_mrz_anchor(regions: list[TextRegion]) -> bool:
|
|
43
|
+
"""True when the regions contain a row recovery could re-read as an MRZ."""
|
|
44
|
+
return bool(_anchors(regions))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_crop(image, src, width, height, reader) -> list[TextRegion]:
|
|
48
|
+
"""Read a deskewed crop and map evidence back to page coordinates."""
|
|
49
|
+
dst = np.array([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], np.float32)
|
|
50
|
+
transform = cv2.getPerspectiveTransform(np.asarray(src, np.float32), dst)
|
|
51
|
+
if abs(np.linalg.det(transform)) < 1e-12:
|
|
52
|
+
return []
|
|
53
|
+
crop = cv2.warpPerspective(image, transform, (width, height), borderValue=(255, 255, 255))
|
|
54
|
+
inverse = np.linalg.inv(transform)
|
|
55
|
+
recovered = []
|
|
56
|
+
for region in reader(crop):
|
|
57
|
+
text = _clean_mrz_text(region.text)
|
|
58
|
+
if 20 <= len(text) < 44 and _PADDED_HEADER.fullmatch(text):
|
|
59
|
+
# Only omitted trailing fillers can be recovered from this rule.
|
|
60
|
+
text = text.ljust(44, '<')
|
|
61
|
+
points = np.asarray(region.bbox, np.float32)
|
|
62
|
+
if points.shape != (4, 2) or not np.isfinite(points).all():
|
|
63
|
+
continue
|
|
64
|
+
points = cv2.perspectiveTransform(points[None], inverse)[0]
|
|
65
|
+
recovered.append(TextRegion(text, points.round().astype(int).tolist(), region.confidence))
|
|
66
|
+
return recovered
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def recover_passport_mrz(image, regions: list[TextRegion], ocr, recognize_line=None,
|
|
70
|
+
band_ocr=None) -> list[TextRegion]:
|
|
71
|
+
"""Re-read a damaged MRZ. ``band_ocr`` reads thin band crops (defaults to ``ocr``).
|
|
72
|
+
|
|
73
|
+
Every re-read is optional and starts only while the scan's recovery budget
|
|
74
|
+
remains (see recovery_budget); the input regions are returned otherwise.
|
|
75
|
+
"""
|
|
76
|
+
band_ocr = band_ocr or ocr
|
|
77
|
+
existing = parse_mrz(regions)
|
|
78
|
+
if existing and existing.overall_checksum_valid and 'MRZ_NAME_PADDING_NOISE' not in existing.errors:
|
|
79
|
+
return regions
|
|
80
|
+
if existing and existing.overall_checksum_valid:
|
|
81
|
+
# Only name-padding cleanup remains. It reads filler more reliably at
|
|
82
|
+
# the default enlargement and runs on already-valid scans, so keep it.
|
|
83
|
+
band_ocr = ocr
|
|
84
|
+
long_rows = [r for r in regions if len(r.text) >= 8 and len(r.bbox) == 4]
|
|
85
|
+
vertical_rows = [r for r in long_rows if
|
|
86
|
+
abs(r.bbox[1][1] - r.bbox[0][1]) > 2 * abs(r.bbox[1][0] - r.bbox[0][0])
|
|
87
|
+
or (max(p[1] for p in r.bbox) - min(p[1] for p in r.bbox)) >
|
|
88
|
+
2 * (max(p[0] for p in r.bbox) - min(p[0] for p in r.bbox))]
|
|
89
|
+
if len(vertical_rows) >= 4 and len(vertical_rows) / len(long_rows) >= .6:
|
|
90
|
+
# Sideways lines cannot reliably be recognized or paired in page-Y
|
|
91
|
+
# order. Try each quarter-turn, keeping original-page evidence boxes.
|
|
92
|
+
h, w = image.shape[:2]
|
|
93
|
+
corners = [(0, 0), (w - 1, 0), (w - 1, h - 1), (0, h - 1)]
|
|
94
|
+
for order in ((3, 0, 1, 2), (1, 2, 3, 0)):
|
|
95
|
+
if not recovery_allowed():
|
|
96
|
+
break
|
|
97
|
+
recovered = _read_crop(image, [corners[i] for i in order], h, w, ocr)
|
|
98
|
+
parsed = parse_mrz(recovered)
|
|
99
|
+
if parsed and parsed.overall_checksum_valid:
|
|
100
|
+
return recovered
|
|
101
|
+
return regions # The two-read budget is exhausted.
|
|
102
|
+
anchors = _anchors(regions)
|
|
103
|
+
band_budget = 2
|
|
104
|
+
if not anchors and any(re.search(r'\bPASSPORT\b', r.text, re.I) for r in regions):
|
|
105
|
+
# A small passport in a large photo can lose its entire MRZ during
|
|
106
|
+
# detection downscaling. Focus on confident visible document text once.
|
|
107
|
+
body = [r for r in regions if len(r.text) >= 4 and r.confidence >= .85 and len(r.bbox) == 4]
|
|
108
|
+
if len(body) >= 5:
|
|
109
|
+
points = np.asarray([p for r in body for p in r.bbox], np.float32)
|
|
110
|
+
left, top = points.min(axis=0)
|
|
111
|
+
right, bottom = points.max(axis=0)
|
|
112
|
+
bw, bh = right - left, bottom - top
|
|
113
|
+
h, w = image.shape[:2]
|
|
114
|
+
x1, y1 = max(0, int(left - bw * .2)), max(0, int(top - bh * .15))
|
|
115
|
+
x2, y2 = min(w, int(right + bw * .2)), min(h, int(bottom + bh * .4))
|
|
116
|
+
if (bw >= 200 and bh >= 100 and 0 < (x2 - x1) * (y2 - y1) < w * h * .75
|
|
117
|
+
and recovery_allowed()):
|
|
118
|
+
recovered = _read_crop(image, [(x1, y1), (x2, y1), (x2, y2), (x1, y2)],
|
|
119
|
+
x2 - x1, y2 - y1, ocr)
|
|
120
|
+
parsed = parse_mrz(recovered)
|
|
121
|
+
if parsed and parsed.overall_checksum_valid:
|
|
122
|
+
return recovered
|
|
123
|
+
# Detection may reveal only the beginning of the bottom row.
|
|
124
|
+
# Reuse its original-page geometry for one final band read;
|
|
125
|
+
# never insert unobserved suffix characters or check digits.
|
|
126
|
+
anchors = _anchors(recovered)
|
|
127
|
+
band_budget -= 1
|
|
128
|
+
# At most two band reads, plus one recognition-only fallback per full anchor.
|
|
129
|
+
for anchor in sorted(anchors, key=lambda r: len(_clean_mrz_text(r.text)), reverse=True)[:band_budget]:
|
|
130
|
+
box = np.asarray(anchor.bbox, dtype=np.float32)
|
|
131
|
+
if box.shape != (4, 2) or not np.isfinite(box).all():
|
|
132
|
+
continue
|
|
133
|
+
tl, tr, br, bl = box
|
|
134
|
+
vertical = ((bl - tl) + (br - tr)) / 2
|
|
135
|
+
width, line_height = float(np.linalg.norm(tr - tl)), float(np.linalg.norm(vertical))
|
|
136
|
+
if width < 100 or line_height < 5 or width / line_height < 8:
|
|
137
|
+
continue
|
|
138
|
+
if not recovery_allowed():
|
|
139
|
+
break
|
|
140
|
+
# Include the line above and a little border. Deskew using detected text,
|
|
141
|
+
# which still works when a document occupies a small part of a photograph.
|
|
142
|
+
# A TD3 line has 44 fixed-width characters. A partial detection must not
|
|
143
|
+
# clip the unseen right-hand characters from the recovery crop.
|
|
144
|
+
anchor_text = _clean_mrz_text(anchor.text)
|
|
145
|
+
extension = (44 / len(anchor_text) - 1) * (tr - tl)
|
|
146
|
+
tr, br = tr + extension, br + extension
|
|
147
|
+
if _checked_line_two_prefix(anchor_text):
|
|
148
|
+
# A truncated detector box can end inside a character. Leave one
|
|
149
|
+
# character of pixels at either edge of the predicted full row.
|
|
150
|
+
margin = (tr - tl) / 44
|
|
151
|
+
tl, bl, tr, br = tl - margin, bl - margin, tr + margin, br + margin
|
|
152
|
+
full_width = float(np.linalg.norm(tr - tl))
|
|
153
|
+
w = min(1600, int(full_width))
|
|
154
|
+
h = max(20, int(line_height * 4 * w / full_width))
|
|
155
|
+
src = [tl - 2.5 * vertical, tr - 2.5 * vertical,
|
|
156
|
+
br + .5 * vertical, bl + .5 * vertical]
|
|
157
|
+
recovered = _read_crop(image, src, w, h, band_ocr)
|
|
158
|
+
# The original full-width line two can be better than the cropped reading.
|
|
159
|
+
# Evaluate complete re-read first, then each recovered header with its anchor.
|
|
160
|
+
candidates = [recovered] + [[r, anchor] for r in recovered
|
|
161
|
+
if _PADDED_HEADER.fullmatch(_clean_mrz_text(r.text))]
|
|
162
|
+
def candidate_reads():
|
|
163
|
+
yield from candidates
|
|
164
|
+
if (recognize_line is None or not _LINE_TWO.fullmatch(_clean_mrz_text(anchor.text))
|
|
165
|
+
or not recovery_allowed()):
|
|
166
|
+
return
|
|
167
|
+
# A complete bottom line also localizes the name line immediately
|
|
168
|
+
# above it. Bypass detection when that line was missed in the band.
|
|
169
|
+
line_src = [tl - 1.1 * vertical, tr - 1.1 * vertical,
|
|
170
|
+
tr + .1 * vertical, tl + .1 * vertical]
|
|
171
|
+
line_height_px = max(10, int(line_height * 1.2 * w / full_width))
|
|
172
|
+
header = _read_crop(image, line_src, w, line_height_px, recognize_line)
|
|
173
|
+
for row in header:
|
|
174
|
+
# Names have no check digit: require clean syntax, observed name
|
|
175
|
+
# termination, and high recognition confidence before admission.
|
|
176
|
+
if row.confidence >= .9 and _PADDED_HEADER.fullmatch(row.text):
|
|
177
|
+
yield [row, anchor]
|
|
178
|
+
|
|
179
|
+
for candidate in candidate_reads():
|
|
180
|
+
parsed = parse_mrz(candidate)
|
|
181
|
+
if not parsed or not parsed.overall_checksum_valid:
|
|
182
|
+
continue
|
|
183
|
+
if existing and existing.overall_checksum_valid and 'MRZ_NAME_PADDING_NOISE' in parsed.errors:
|
|
184
|
+
continue
|
|
185
|
+
if existing and existing.passport_number.value != parsed.passport_number.value:
|
|
186
|
+
continue
|
|
187
|
+
# Preserve visual text and its coordinates; replace only MRZ-like rows.
|
|
188
|
+
visual = [r for r in regions if not (
|
|
189
|
+
_ANCHOR.fullmatch(_clean_mrz_text(r.text))
|
|
190
|
+
and ('<' in _clean_mrz_text(r.text) or _LINE_TWO.fullmatch(_clean_mrz_text(r.text))))]
|
|
191
|
+
mrz_rows = [r for r in candidate if _ANCHOR.fullmatch(_clean_mrz_text(r.text))]
|
|
192
|
+
return visual + mrz_rows
|
|
193
|
+
return regions
|