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,187 @@
1
+ """Bounded pixel rereads for unchecked TD3/MRV issuer and nationality fields.
2
+
3
+ Checksums do not protect those fields. Two differently cropped recognizer
4
+ reads must agree before replacing an implausible code; an already plausible,
5
+ complete MRZ has no additional inference cost. This is recognition recovery,
6
+ not document authentication or a way to infer nationality from the issuer.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ import cv2
13
+ import numpy as np
14
+
15
+ from .document_registry import is_known_mrz_country
16
+ from .mrz_parser import _clean_mrz_text, parse_mrz, verify_check_digit
17
+ from .ocr_engine import TextRegion
18
+ from .recovery_budget import recovery_allowed
19
+ from .travel_mrz import parse_travel_mrz
20
+
21
+
22
+ def _geometry(region):
23
+ points = np.asarray(region.bbox, np.float32)
24
+ if points.shape != (4, 2) or not np.isfinite(points).all():
25
+ return None
26
+ u, v = points[1] - points[0], points[3] - points[0]
27
+ width, height = np.linalg.norm(u), np.linalg.norm(v)
28
+ if width < 150 or height < 8 or width < height * 5:
29
+ return None
30
+ if abs(float(np.linalg.det(np.stack([u, v])))) < width * height * .5:
31
+ return None
32
+ return points, u, v, width, height
33
+
34
+
35
+ def _pairs(regions):
36
+ pairs = []
37
+ for header in regions:
38
+ first = _clean_mrz_text(header.text)
39
+ shape = _geometry(header)
40
+ if (shape is None or not 28 <= len(first) <= 44
41
+ or not re.match(r"[PV][A-Z<]", first)
42
+ or first.count("<") < 3 or "<<" not in first):
43
+ continue
44
+ points, u, v, width, height = shape
45
+ for body in regions:
46
+ second = _clean_mrz_text(body.text)
47
+ body_shape = _geometry(body)
48
+ if body is header or body_shape is None or not 28 <= len(second) <= 44:
49
+ continue
50
+ if sum(character.isdigit() for character in second) < 10:
51
+ continue
52
+ delta = body_shape[0].mean(axis=0) - points.mean(axis=0)
53
+ across = abs(float(np.dot(delta, u) / width))
54
+ below = float(np.dot(delta, v) / height)
55
+ if across < width * .2 and height * .6 < below < height * 3:
56
+ pairs.append((header, body))
57
+ return pairs
58
+
59
+
60
+ def _checked(first, second, header, body):
61
+ """Return parsed checked fields only for complete, calendar-valid pairs."""
62
+ width = 44 if first.startswith("P") else len(first)
63
+ if width not in (36, 44) or len(first) != width or len(second) != width:
64
+ return None
65
+ candidate = [TextRegion(first, header.bbox, header.confidence),
66
+ TextRegion(second, body.bbox, body.confidence)]
67
+ if first.startswith("P"):
68
+ parsed = parse_mrz(candidate)
69
+ if not parsed or not parsed.overall_checksum_valid or parsed.errors:
70
+ return None
71
+ fields = tuple(getattr(parsed, name).value for name in
72
+ ("passport_number", "date_of_birth", "expiry_date", "sex"))
73
+ fields += (parsed.personal_number.raw,)
74
+ else:
75
+ parsed = parse_travel_mrz(candidate)
76
+ if not parsed or not parsed.checks.get("mrz_checksums_valid"):
77
+ return None
78
+ if any(error not in ("UNKNOWN_ISSUING_COUNTRY", "UNKNOWN_NATIONALITY") for error in parsed.errors):
79
+ return None
80
+ fields = tuple(parsed.fields.get(name) for name in
81
+ ("document_number", "date_of_birth", "expiry_date", "sex"))
82
+ return fields if all(fields) else None
83
+
84
+
85
+ def _preserves_checked_body(original, candidate):
86
+ # Protect each previously verified field even when the original pair was
87
+ # incomplete and no complete MRZ result could be parsed.
88
+ for start, stop, check in ((0, 9, 9), (13, 19, 19), (21, 27, 27), (28, 42, 42)):
89
+ if len(original) > check and verify_check_digit(original[start:stop], original[check]):
90
+ if original[start:stop] != candidate[start:stop]:
91
+ return False
92
+ if len(original) > 20 and original[20] in "MFX<" and original[20] != candidate[20]:
93
+ return False
94
+ return True
95
+
96
+
97
+ def _read_line(image, region, reader, horizontal_margin, vertical_margin):
98
+ shape = _geometry(region)
99
+ if shape is None:
100
+ return None
101
+ points, u, v, width, height = shape
102
+ tl, tr, br, bl = points
103
+ source = np.float32([tl - u * horizontal_margin - v * vertical_margin,
104
+ tr + u * horizontal_margin - v * vertical_margin,
105
+ br + u * horizontal_margin + v * vertical_margin,
106
+ bl - u * horizontal_margin + v * vertical_margin])
107
+ width = round(width * (1 + 2 * horizontal_margin))
108
+ height = round(height * (1 + 2 * vertical_margin))
109
+ if width > image.shape[1] * 2 or height > image.shape[0] * .25:
110
+ return None
111
+ target = np.float32([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]])
112
+ transform = cv2.getPerspectiveTransform(source, target)
113
+ if not np.isfinite(transform).all() or abs(float(np.linalg.det(transform))) < 1e-12:
114
+ return None
115
+ crop = cv2.warpPerspective(image, transform, (width, height), borderValue=(255, 255, 255))
116
+ reads = reader(crop)
117
+ if len(reads) != 1 or reads[0].confidence < .80:
118
+ return None
119
+ read = reads[0]
120
+ text = _clean_mrz_text(read.text)
121
+ if len(text) not in (36, 44) or not re.fullmatch(r"[A-Z0-9<]+", text):
122
+ return None
123
+ box = np.asarray(read.bbox, np.float32)
124
+ if box.shape != (4, 2) or not np.isfinite(box).all():
125
+ return None
126
+ mapped = cv2.perspectiveTransform(box[None], np.linalg.inv(transform))[0]
127
+ if not np.isfinite(mapped).all():
128
+ return None
129
+ return TextRegion(text, mapped.round().astype(int).tolist(), read.confidence)
130
+
131
+
132
+ def recover_travel_mrz(image, regions: list[TextRegion], recognize_line) -> list[TextRegion]:
133
+ """Reread at most one ambiguous pair, with two recognitions per bad row.
134
+
135
+ No document hint or expected value is used. Disagreeing rereads, changed
136
+ checked fields, and changes between plausible country codes are rejected.
137
+ Multiple candidate documents are left to the normal rejection path.
138
+ """
139
+ pairs = _pairs(regions)
140
+ if len(pairs) != 1:
141
+ return regions
142
+ header, body = pairs[0]
143
+ first, second = _clean_mrz_text(header.text), _clean_mrz_text(body.text)
144
+ existing = _checked(first, second, header, body)
145
+ plausible = is_known_mrz_country(first[2:5]) and is_known_mrz_country(second[10:13])
146
+ if existing and plausible:
147
+ return regions
148
+ first_ok = bool(len(first) in ((44,) if first.startswith("P") else (36, 44))
149
+ and re.fullmatch(r"[PV][A-Z<][A-Z<]{3}[A-Z<]+", first)
150
+ and is_known_mrz_country(first[2:5]))
151
+ second_ok = bool(existing and is_known_mrz_country(second[10:13]))
152
+ # A malformed header alone does not invalidate an otherwise complete,
153
+ # checked body. Preserve it so an unchecked name is not reread needlessly.
154
+ if not first_ok and len(second) in (36, 44) and is_known_mrz_country(second[10:13]):
155
+ second_ok = all(verify_check_digit(second[start:stop], second[check])
156
+ for start, stop, check in ((0, 9, 9), (13, 19, 19), (21, 27, 27)))
157
+ replacements = {}
158
+ for region, good in ((header, first_ok), (body, second_ok)):
159
+ if good:
160
+ continue
161
+ if not recovery_allowed():
162
+ return regions
163
+ first_read = _read_line(image, region, recognize_line, 0, .12)
164
+ if not recovery_allowed():
165
+ return regions # An unconfirmed reread is never admitted.
166
+ reads = [first_read, _read_line(image, region, recognize_line, .01, .2)]
167
+ if (any(read is None for read in reads) or reads[0].text != reads[1].text
168
+ or max(read.confidence for read in reads) < .90):
169
+ return regions
170
+ replacements[id(region)] = min(reads, key=lambda read: read.confidence)
171
+ if not replacements:
172
+ return regions
173
+ new_header, new_body = replacements.get(id(header), header), replacements.get(id(body), body)
174
+ new_first, new_second = _clean_mrz_text(new_header.text), _clean_mrz_text(new_body.text)
175
+ checked = _checked(new_first, new_second, new_header, new_body)
176
+ if not checked or not is_known_mrz_country(new_first[2:5]) or not is_known_mrz_country(new_second[10:13]):
177
+ return regions
178
+ if (existing and checked != existing) or not _preserves_checked_body(second, new_second):
179
+ return regions
180
+ for old, new in ((first[2:5], new_first[2:5]), (second[10:13], new_second[10:13])):
181
+ if is_known_mrz_country(old) and old != new:
182
+ return regions
183
+ # A valid name has no checksum. Repairing an issuer must not rewrite it.
184
+ if (re.fullmatch(r"[A-Z<]+", first[5:])
185
+ and first[5:].rstrip("<") != new_first[5:].rstrip("<")):
186
+ return regions
187
+ return [replacements.get(id(region), region) for region in regions]
@@ -0,0 +1,396 @@
1
+ """
2
+ Cross-validates MRZ output against visual OCR fields and computes
3
+ a final confidence score.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from dataclasses import dataclass, field
10
+ from datetime import date, datetime
11
+ from typing import Optional
12
+
13
+ from rapidfuzz import fuzz
14
+
15
+ from .mrz_parser import MRZResult
16
+ from .ocr_engine import TextRegion
17
+
18
+ # Shared ISO/ICAO code validation also covers travel-card and visa recovery.
19
+ from .document_registry import is_known_mrz_country
20
+
21
+ _LABEL_HINTS = [
22
+ "SURNAME",
23
+ "GIVEN NAME",
24
+ "NAME",
25
+ "DATE",
26
+ "BIRTH",
27
+ "NATIONALITY",
28
+ "PASSPORT",
29
+ "SEX",
30
+ "PLACE",
31
+ "ISSUE",
32
+ "EXPIRY",
33
+ "EXPIRATION",
34
+ "VALID",
35
+ "COUNTRY",
36
+ "CODE",
37
+ "FATHER",
38
+ "MOTHER",
39
+ "SPOUSE",
40
+ "ADDRESS",
41
+ "FILE",
42
+ ]
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Result types
47
+ # ---------------------------------------------------------------------------
48
+
49
+ @dataclass
50
+ class ValidationResult:
51
+ confidence: float # 0.0 – 1.0
52
+ errors: list[str] = field(default_factory=list)
53
+ warnings: list[str] = field(default_factory=list)
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Helpers
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def find_visual_field(
61
+ regions: list[TextRegion],
62
+ keywords: list[str],
63
+ ) -> Optional[TextRegion]:
64
+ """Find the best label match for the given keywords.
65
+
66
+ OCR label text is noisy, so prefer exact phrase matches and longer, more
67
+ specific keywords over generic substring matches.
68
+ """
69
+ normalised_keywords = [_normalise_label_text(keyword) for keyword in keywords]
70
+ best_match: tuple[int, float, TextRegion] | None = None
71
+
72
+ for region in regions:
73
+ label_text = _normalise_label_text(region.text)
74
+ if not label_text:
75
+ continue
76
+
77
+ for priority, keyword in enumerate(normalised_keywords):
78
+ if not keyword:
79
+ continue
80
+
81
+ padded_label = f" {label_text} "
82
+ padded_keyword = f" {keyword} "
83
+
84
+ if label_text == keyword:
85
+ score = 10_000 - priority
86
+ elif label_text.startswith(keyword + " "):
87
+ # An inline "Name: value" is stronger than NAME occurring
88
+ # inside "Father's Name: value", regardless of OCR confidence.
89
+ score = 2_000 + (len(keyword) * 100) - priority
90
+ elif padded_keyword in padded_label:
91
+ score = (len(keyword) * 100) - priority
92
+ else:
93
+ continue
94
+
95
+ if best_match is None or score > best_match[0] or (
96
+ score == best_match[0] and region.confidence > best_match[1]
97
+ ):
98
+ best_match = (score, region.confidence, region)
99
+
100
+ return best_match[2] if best_match else None
101
+
102
+
103
+ def find_label_row_left_edge(
104
+ regions: list[TextRegion],
105
+ label_region: TextRegion,
106
+ *,
107
+ max_prefix_chars: int = 5,
108
+ ) -> int:
109
+ """Leftmost x of the label *row*, absorbing short bilingual-prefix regions.
110
+
111
+ Bilingual passports (Hindi/English, Arabic/English, Cyrillic/English, …)
112
+ produce labels OCR'd as multiple regions on the same row, e.g.
113
+ `"पिता / "` (often mis-OCR'd as "fe /") followed by
114
+ `"Name of Father / Legal Guardian"`. Form values align to the leftmost
115
+ edge of the bilingual block, not to the English region alone.
116
+
117
+ Only short fragments (≤ `max_prefix_chars` alphanumeric characters) are
118
+ absorbed — long same-row regions are usually values from adjacent fields
119
+ in multi-column layouts (e.g. the Place-of-Issue value sitting on the same
120
+ row as the Date-of-Issue label on the biodata page) and must NOT shift
121
+ the label's effective left edge.
122
+ """
123
+ label_top = min(p[1] for p in label_region.bbox)
124
+ label_bottom = max(p[1] for p in label_region.bbox)
125
+ label_height = max(label_bottom - label_top, 1)
126
+ leftmost = min(p[0] for p in label_region.bbox)
127
+
128
+ for region in regions:
129
+ if region is label_region:
130
+ continue
131
+ top = min(p[1] for p in region.bbox)
132
+ bottom = max(p[1] for p in region.bbox)
133
+ overlap = min(bottom, label_bottom) - max(top, label_top)
134
+ if overlap < label_height * 0.5:
135
+ continue
136
+ left = min(p[0] for p in region.bbox)
137
+ if left >= leftmost:
138
+ continue
139
+ alnum_len = len(re.sub(r"[^A-Za-z0-9]", "", region.text))
140
+ if alnum_len > max_prefix_chars:
141
+ continue
142
+ leftmost = left
143
+
144
+ return leftmost
145
+
146
+
147
+ def find_visual_value_near(
148
+ regions: list[TextRegion],
149
+ label_region: TextRegion,
150
+ max_y_distance: int = 80,
151
+ ) -> Optional[TextRegion]:
152
+ """Find the OCR region immediately below a label region."""
153
+ label_bottom = max(p[1] for p in label_region.bbox)
154
+ label_left = find_label_row_left_edge(regions, label_region)
155
+ min_vertical_overlap = 10
156
+
157
+ candidates = []
158
+ for region in regions:
159
+ if region is label_region or _looks_like_field_label(region.text):
160
+ continue
161
+ top = min(p[1] for p in region.bbox)
162
+ left = min(p[0] for p in region.bbox)
163
+ vertical_gap = top - label_bottom
164
+ if -min_vertical_overlap <= vertical_gap < max_y_distance:
165
+ x_distance = abs(left - label_left)
166
+ if x_distance < 200:
167
+ candidates.append((max(vertical_gap, 0) + x_distance * 0.3, region))
168
+
169
+ if candidates:
170
+ candidates.sort(key=lambda x: x[0])
171
+ return candidates[0][1]
172
+ return None
173
+
174
+
175
+ def find_visual_value_right(
176
+ regions: list[TextRegion],
177
+ label_region: TextRegion,
178
+ max_x_distance: int = 450,
179
+ ) -> Optional[TextRegion]:
180
+ """Find the value region on the same row, immediately to the right of a label.
181
+
182
+ KYC cards (PAN / Aadhaar / DL / Voter ID) commonly lay fields out as
183
+ "Label : value" on one line, unlike the passport's label-above-value form.
184
+ """
185
+ label_right = max(p[0] for p in label_region.bbox)
186
+ label_top = min(p[1] for p in label_region.bbox)
187
+ label_bottom = max(p[1] for p in label_region.bbox)
188
+ label_height = max(label_bottom - label_top, 1)
189
+
190
+ candidates = []
191
+ for region in regions:
192
+ if region is label_region:
193
+ continue
194
+ top = min(p[1] for p in region.bbox)
195
+ bottom = max(p[1] for p in region.bbox)
196
+ overlap = min(bottom, label_bottom) - max(top, label_top)
197
+ if overlap < label_height * 0.4:
198
+ continue
199
+ left = min(p[0] for p in region.bbox)
200
+ gap = left - label_right
201
+ if 0 <= gap < max_x_distance:
202
+ if _looks_like_field_label(region.text):
203
+ continue
204
+ candidates.append((gap, region))
205
+
206
+ if candidates:
207
+ candidates.sort(key=lambda x: x[0])
208
+ return candidates[0][1]
209
+ return None
210
+
211
+
212
+ def find_label_value(
213
+ regions: list[TextRegion],
214
+ labels: list[str],
215
+ ) -> Optional[str]:
216
+ """Resolve an explicit inline value, then same-row-right and below."""
217
+ label_region = find_visual_field(regions, labels)
218
+ if label_region is None:
219
+ return None
220
+ # Detection can merge an entire printed "Label: value" row. Match the
221
+ # label at the beginning, not an embedded word such as Name in Father's
222
+ # Name. Preserve punctuation and casing in the actual printed value.
223
+ tokens = list(re.finditer(r"[A-Za-z0-9]+", label_region.text))
224
+ for label in sorted(labels, key=len, reverse=True):
225
+ words = _normalise_label_text(label).split()
226
+ if words and [t.group().upper() for t in tokens[:len(words)]] == words:
227
+ inline = label_region.text[tokens[len(words) - 1].end():].strip(" :\t#")
228
+ if inline and not _looks_like_field_label(inline):
229
+ return inline
230
+ break # A longer empty label must not become a shorter label's value.
231
+ value = find_visual_value_right(regions, label_region) or find_visual_value_near(
232
+ regions, label_region
233
+ )
234
+ if value is None:
235
+ return None
236
+ text = value.text.strip()
237
+ return text or None
238
+
239
+
240
+ def _parse_date_flexible(text: str) -> Optional[date]:
241
+ """Try multiple date formats to parse a visual date field."""
242
+ text = text.strip().replace("/", "-").replace(".", "-")
243
+ formats = ["%Y-%m-%d", "%d-%m-%Y", "%d %b %Y", "%d %B %Y", "%Y%m%d"]
244
+ for fmt in formats:
245
+ try:
246
+ return datetime.strptime(text, fmt).date()
247
+ except ValueError:
248
+ continue
249
+ return None
250
+
251
+
252
+ def _normalise_label_text(text: str) -> str:
253
+ """Normalise OCR label text so phrase matching is less brittle."""
254
+ text = text.upper()
255
+ text = text.replace("&", " AND ")
256
+ text = re.sub(r"[^A-Z0-9]+", " ", text)
257
+ return re.sub(r"\s+", " ", text).strip()
258
+
259
+
260
+ def _looks_like_field_label(text: str) -> bool:
261
+ """Heuristic to avoid treating the next label as a field value."""
262
+ normalised = _normalise_label_text(text)
263
+ padded = f" {normalised} "
264
+ return any(f" {hint} " in padded for hint in _LABEL_HINTS)
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Public API
269
+ # ---------------------------------------------------------------------------
270
+
271
+ def validate(
272
+ mrz: Optional[MRZResult],
273
+ regions: list[TextRegion],
274
+ ) -> ValidationResult:
275
+ """
276
+ Cross-validate MRZ fields against visual OCR regions and compute
277
+ a final confidence score.
278
+
279
+ Confidence = weighted average of:
280
+ - MRZ checksum validity: 40%
281
+ - Field cross-match: 30%
282
+ - Individual field OCR confidence: 30%
283
+ """
284
+ errors: list[str] = []
285
+ warnings: list[str] = []
286
+
287
+ # --- MRZ checksum component (40%) ---
288
+ if mrz is None:
289
+ mrz_score = 0.0
290
+ errors.append("MRZ_NOT_DETECTED")
291
+ elif mrz.overall_checksum_valid:
292
+ mrz_score = 1.0
293
+ else:
294
+ # Partial credit: count how many individual fields pass
295
+ checks = [
296
+ mrz.passport_number.checksum_valid,
297
+ mrz.date_of_birth.checksum_valid,
298
+ mrz.expiry_date.checksum_valid,
299
+ ]
300
+ passed = sum(1 for c in checks if c)
301
+ mrz_score = passed / len(checks) * 0.7 # cap at 0.7 if overall fails
302
+
303
+ # --- Cross-match component (30%) ---
304
+ cross_matches = 0
305
+ cross_total = 0
306
+
307
+ if mrz is not None:
308
+ # Name cross-match
309
+ name_label = find_visual_field(regions, ["SURNAME", "FAMILY NAME", "LAST NAME", "NOM"])
310
+ if name_label:
311
+ name_value = find_visual_value_near(regions, name_label)
312
+ if name_value and mrz.surname.value:
313
+ cross_total += 1
314
+ ratio = fuzz.token_sort_ratio(
315
+ mrz.surname.value.upper(),
316
+ name_value.text.upper(),
317
+ )
318
+ if ratio >= 85:
319
+ cross_matches += 1
320
+ elif ratio >= 60:
321
+ cross_matches += 0.5
322
+ warnings.append("NAME_PARTIAL_MATCH")
323
+ else:
324
+ warnings.append("NAME_MISMATCH")
325
+
326
+ # DOB cross-match
327
+ dob_label = find_visual_field(regions, ["DATE OF BIRTH", "BIRTH DATE", "DOB", "NAISSANCE"])
328
+ if dob_label and mrz.date_of_birth.value:
329
+ dob_value = find_visual_value_near(regions, dob_label)
330
+ if dob_value:
331
+ cross_total += 1
332
+ visual_date = _parse_date_flexible(dob_value.text)
333
+ if visual_date and str(visual_date) == mrz.date_of_birth.value:
334
+ cross_matches += 1
335
+ else:
336
+ warnings.append("DOB_MISMATCH")
337
+
338
+ # Expiry cross-match
339
+ exp_label = find_visual_field(
340
+ regions,
341
+ ["DATE OF EXPIRY", "EXPIRY DATE", "DATE OF EXPIRATION", "EXPIRY", "EXPIRATION", "VALID UNTIL"],
342
+ )
343
+ if exp_label and mrz.expiry_date.value:
344
+ exp_value = find_visual_value_near(regions, exp_label)
345
+ if exp_value:
346
+ cross_total += 1
347
+ visual_date = _parse_date_flexible(exp_value.text)
348
+ if visual_date and str(visual_date) == mrz.expiry_date.value:
349
+ cross_matches += 1
350
+ else:
351
+ warnings.append("EXPIRY_DATE_MISMATCH")
352
+
353
+ # Country code validation
354
+ code = (mrz.country_code.value or "").upper()
355
+ if not is_known_mrz_country(code):
356
+ reason = f"UNKNOWN_COUNTRY_CODE_{code}" if code else "MISSING_COUNTRY_CODE"
357
+ warnings.append(reason)
358
+ errors.append(reason)
359
+ if not is_known_mrz_country(mrz.nationality.value):
360
+ errors.append("UNKNOWN_NATIONALITY")
361
+
362
+ # A correct checksum does not establish that a date exists. Unknown
363
+ # date components are permitted as fillers, but impossible numeric
364
+ # dates must not disappear silently when the parser returns None.
365
+ parsed_dates: dict[str, date] = {}
366
+ for name, field in (("DATE_OF_BIRTH", mrz.date_of_birth), ("EXPIRY_DATE", mrz.expiry_date)):
367
+ if field.value is None:
368
+ if re.fullmatch(r"[0-9<]{6}", field.raw) and "<" in field.raw:
369
+ warnings.append(f"INCOMPLETE_{name}")
370
+ else:
371
+ errors.append(f"INVALID_{name}")
372
+ continue
373
+ try:
374
+ parsed_dates[name] = date.fromisoformat(field.value)
375
+ except ValueError:
376
+ errors.append(f"INVALID_{name}")
377
+ if len(parsed_dates) == 2 and parsed_dates["EXPIRY_DATE"] <= parsed_dates["DATE_OF_BIRTH"]:
378
+ errors.append("EXPIRY_BEFORE_DOB")
379
+
380
+ cross_score = (cross_matches / cross_total) if cross_total > 0 else 0.5
381
+
382
+ # --- OCR confidence component (30%) ---
383
+ if regions:
384
+ avg_conf = sum(r.confidence for r in regions) / len(regions)
385
+ else:
386
+ avg_conf = 0.0
387
+
388
+ # --- Weighted average ---
389
+ confidence = (mrz_score * 0.40) + (cross_score * 0.30) + (avg_conf * 0.30)
390
+ confidence = round(min(max(confidence, 0.0), 1.0), 3)
391
+
392
+ return ValidationResult(
393
+ confidence=confidence,
394
+ errors=errors,
395
+ warnings=warnings,
396
+ )