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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -2
  3. package/dist/index.cjs +659 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +293 -0
  6. package/dist/index.d.ts +293 -0
  7. package/dist/index.js +616 -0
  8. package/dist/index.js.map +1 -0
  9. package/hosting/.dockerignore +17 -0
  10. package/hosting/.gcloudignore +14 -0
  11. package/hosting/FEATURES.md +172 -0
  12. package/hosting/HOSTING.md +268 -0
  13. package/hosting/LICENSE +21 -0
  14. package/hosting/README.md +369 -0
  15. package/hosting/core/__init__.py +0 -0
  16. package/hosting/core/aadhaar_extractor.py +298 -0
  17. package/hosting/core/back_page_extractor.py +492 -0
  18. package/hosting/core/barcodes.py +125 -0
  19. package/hosting/core/card_recovery.py +244 -0
  20. package/hosting/core/document_bundle.py +82 -0
  21. package/hosting/core/document_classifier.py +233 -0
  22. package/hosting/core/document_input.py +135 -0
  23. package/hosting/core/document_registry.py +143 -0
  24. package/hosting/core/driving_licence_extractor.py +510 -0
  25. package/hosting/core/ead_recovery.py +54 -0
  26. package/hosting/core/evidence.py +54 -0
  27. package/hosting/core/form_recovery.py +61 -0
  28. package/hosting/core/jobs.py +232 -0
  29. package/hosting/core/kyc_ocr.py +132 -0
  30. package/hosting/core/kyc_validation.py +411 -0
  31. package/hosting/core/model_setup.py +13 -0
  32. package/hosting/core/mrz_parser.py +336 -0
  33. package/hosting/core/mrz_recovery.py +193 -0
  34. package/hosting/core/npr_extractor.py +410 -0
  35. package/hosting/core/nrega_extractor.py +827 -0
  36. package/hosting/core/ocr_engine.py +207 -0
  37. package/hosting/core/page_classifier.py +137 -0
  38. package/hosting/core/pan_extractor.py +183 -0
  39. package/hosting/core/pipeline.py +712 -0
  40. package/hosting/core/preprocessor.py +297 -0
  41. package/hosting/core/recovery_budget.py +67 -0
  42. package/hosting/core/redaction.py +48 -0
  43. package/hosting/core/review.html +244 -0
  44. package/hosting/core/structured_documents.py +422 -0
  45. package/hosting/core/travel_mrz.py +164 -0
  46. package/hosting/core/travel_recovery.py +187 -0
  47. package/hosting/core/validator.py +396 -0
  48. package/hosting/core/validators.py +179 -0
  49. package/hosting/core/voter_id_extractor.py +363 -0
  50. package/hosting/deploy/cloudflare/deploy.sh +44 -0
  51. package/hosting/deploy/cloudflare/package-lock.json +3088 -0
  52. package/hosting/deploy/cloudflare/package.json +29 -0
  53. package/hosting/deploy/cloudflare/src/gateway.ts +42 -0
  54. package/hosting/deploy/cloudflare/src/index.ts +21 -0
  55. package/hosting/deploy/cloudflare/test/gateway.test.ts +59 -0
  56. package/hosting/deploy/cloudflare/tsconfig.json +12 -0
  57. package/hosting/deploy/cloudflare/worker-configuration.d.ts +16253 -0
  58. package/hosting/deploy/cloudflare/wrangler.jsonc +26 -0
  59. package/hosting/deploy/cloudrun/cloudbuild.yaml +18 -0
  60. package/hosting/deploy/cloudrun/deploy.sh +71 -0
  61. package/hosting/deploy/cloudrun/render_service.py +47 -0
  62. package/hosting/deploy/common.sh +14 -0
  63. package/hosting/deploy/docker/Dockerfile +16 -0
  64. package/hosting/deploy/docker/server.py +313 -0
  65. package/hosting/deploy/lambda/Dockerfile.lambda +11 -0
  66. package/hosting/deploy/lambda/deploy.sh +22 -0
  67. package/hosting/deploy/lambda/handler.py +132 -0
  68. package/hosting/deploy/lambda/template.yaml +59 -0
  69. package/hosting/deploy/local/dev.sh +18 -0
  70. package/hosting/deploy/server/Caddyfile +5 -0
  71. package/hosting/deploy/server/compose.jobs.yaml +36 -0
  72. package/hosting/deploy/server/compose.yaml +38 -0
  73. package/hosting/deploy/server/deploy.sh +23 -0
  74. package/hosting/infra/cloudrun/main.tf +104 -0
  75. package/hosting/infra/cloudrun/terraform.tfvars.example +7 -0
  76. package/hosting/infra/cloudrun/variables.tf +55 -0
  77. package/hosting/infra/lambda/main.tf +63 -0
  78. package/hosting/infra/lambda/terraform.tfvars.example +6 -0
  79. package/hosting/infra/lambda/variables.tf +37 -0
  80. package/hosting/pyproject.toml +65 -0
  81. package/hosting/requirements.lock +134 -0
  82. package/package.json +76 -4
  83. package/python/core/__init__.py +0 -0
  84. package/python/core/aadhaar_extractor.py +298 -0
  85. package/python/core/back_page_extractor.py +492 -0
  86. package/python/core/barcodes.py +125 -0
  87. package/python/core/card_recovery.py +244 -0
  88. package/python/core/document_bundle.py +82 -0
  89. package/python/core/document_classifier.py +233 -0
  90. package/python/core/document_input.py +135 -0
  91. package/python/core/document_registry.py +143 -0
  92. package/python/core/driving_licence_extractor.py +510 -0
  93. package/python/core/ead_recovery.py +54 -0
  94. package/python/core/evidence.py +54 -0
  95. package/python/core/form_recovery.py +61 -0
  96. package/python/core/jobs.py +232 -0
  97. package/python/core/kyc_ocr.py +132 -0
  98. package/python/core/kyc_validation.py +411 -0
  99. package/python/core/model_setup.py +13 -0
  100. package/python/core/mrz_parser.py +336 -0
  101. package/python/core/mrz_recovery.py +193 -0
  102. package/python/core/npr_extractor.py +410 -0
  103. package/python/core/nrega_extractor.py +827 -0
  104. package/python/core/ocr_engine.py +207 -0
  105. package/python/core/page_classifier.py +137 -0
  106. package/python/core/pan_extractor.py +183 -0
  107. package/python/core/pipeline.py +712 -0
  108. package/python/core/preprocessor.py +297 -0
  109. package/python/core/recovery_budget.py +67 -0
  110. package/python/core/redaction.py +48 -0
  111. package/python/core/review.html +244 -0
  112. package/python/core/structured_documents.py +422 -0
  113. package/python/core/travel_mrz.py +164 -0
  114. package/python/core/travel_recovery.py +187 -0
  115. package/python/core/validator.py +396 -0
  116. package/python/core/validators.py +179 -0
  117. package/python/core/voter_id_extractor.py +363 -0
  118. package/python/pyproject.toml +34 -0
  119. package/python/requirements.lock +134 -0
  120. package/python/server.py +313 -0
  121. package/scripts/dococr.mjs +163 -0
  122. package/scripts/postinstall.mjs +173 -0
  123. package/scripts/sync-hosting.mjs +35 -0
  124. 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