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,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
|
+
)
|