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,712 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Passport OCR pipeline — single entry point.
|
|
3
|
+
|
|
4
|
+
Orchestrates: preprocessing → page classification → targeted OCR →
|
|
5
|
+
MRZ parsing → validation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field, fields as dataclass_fields
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, Union
|
|
14
|
+
|
|
15
|
+
from .aadhaar_extractor import AadhaarFields, extract_aadhaar
|
|
16
|
+
from .back_page_extractor import BackPageFields, extract_back_page
|
|
17
|
+
from .document_classifier import classify_document
|
|
18
|
+
from .driving_licence_extractor import DrivingLicenceFields, extract_driving_licence
|
|
19
|
+
from .kyc_ocr import run_kyc_ocr
|
|
20
|
+
from .kyc_validation import (
|
|
21
|
+
MIN_OCR_GEOMETRY_CONFIDENCE_FOR_SUCCESS,
|
|
22
|
+
assess_kyc_extraction,
|
|
23
|
+
)
|
|
24
|
+
from .mrz_parser import MRZResult, parse_mrz
|
|
25
|
+
from .mrz_recovery import has_mrz_anchor, recover_passport_mrz
|
|
26
|
+
from .form_recovery import recover_form_fields
|
|
27
|
+
from .card_recovery import recover_card_fields
|
|
28
|
+
from .travel_recovery import recover_travel_mrz
|
|
29
|
+
from .recovery_budget import recovery_deadline
|
|
30
|
+
from .ead_recovery import recover_ead_fields
|
|
31
|
+
from .npr_extractor import NprLetterFields, extract_npr_letter
|
|
32
|
+
from .nrega_extractor import NregaFields, extract_nrega
|
|
33
|
+
from .ocr_engine import TextRegion, run_band_ocr, run_line_ocr, run_ocr
|
|
34
|
+
from .page_classifier import classify_passport_page
|
|
35
|
+
from .pan_extractor import PanFields, extract_pan
|
|
36
|
+
from .preprocessor import ImageQualityError, PreprocessResult, enhance_contrast, preprocess
|
|
37
|
+
from .document_registry import validate_document_hint
|
|
38
|
+
from .barcodes import decode_barcodes
|
|
39
|
+
from .document_input import input_bytes, pdf_pages
|
|
40
|
+
from .evidence import document_fields as collect_fields, field_evidence as collect_evidence
|
|
41
|
+
from .validator import validate, find_visual_field, find_visual_value_near
|
|
42
|
+
from .voter_id_extractor import VoterIdFields, extract_voter_id
|
|
43
|
+
|
|
44
|
+
TARGETED_CROP_TOP_RATIO = 0.45
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class PassportFields:
|
|
49
|
+
surname: Optional[str] = None
|
|
50
|
+
given_names: Optional[str] = None
|
|
51
|
+
full_name: Optional[str] = None
|
|
52
|
+
passport_number: Optional[str] = None
|
|
53
|
+
nationality: Optional[str] = None
|
|
54
|
+
date_of_birth: Optional[str] = None
|
|
55
|
+
sex: Optional[str] = None
|
|
56
|
+
expiry_date: Optional[str] = None
|
|
57
|
+
issue_date: Optional[str] = None
|
|
58
|
+
place_of_birth: Optional[str] = None
|
|
59
|
+
country_code: Optional[str] = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class DocumentScanResult:
|
|
64
|
+
status: str
|
|
65
|
+
document_type: str
|
|
66
|
+
page_type: str
|
|
67
|
+
confidence: float
|
|
68
|
+
fields: Optional[PassportFields] = None
|
|
69
|
+
back_page_fields: Optional[BackPageFields] = None
|
|
70
|
+
pan_fields: Optional[PanFields] = None
|
|
71
|
+
aadhaar_fields: Optional[AadhaarFields] = None
|
|
72
|
+
driving_licence_fields: Optional[DrivingLicenceFields] = None
|
|
73
|
+
voter_id_fields: Optional[VoterIdFields] = None
|
|
74
|
+
nrega_job_card_fields: Optional[NregaFields] = None
|
|
75
|
+
npr_letter_fields: Optional[NprLetterFields] = None
|
|
76
|
+
mrz_raw: Optional[tuple[str, str]] = None
|
|
77
|
+
mrz_valid: bool = False
|
|
78
|
+
low_confidence: bool = False
|
|
79
|
+
unsupported_reason: Optional[str] = None
|
|
80
|
+
identifier_valid: Optional[bool] = None
|
|
81
|
+
missing_required_fields: list[str] = field(default_factory=list)
|
|
82
|
+
probe_text: list[str] = field(default_factory=list)
|
|
83
|
+
errors: list[str] = field(default_factory=list)
|
|
84
|
+
warnings: list[str] = field(default_factory=list)
|
|
85
|
+
processing_ms: int = 0
|
|
86
|
+
|
|
87
|
+
document_fields: Optional[dict] = None
|
|
88
|
+
issuing_country: Optional[str] = None
|
|
89
|
+
issuing_region: Optional[str] = None
|
|
90
|
+
field_evidence: Optional[dict] = None
|
|
91
|
+
checks: Optional[dict] = None
|
|
92
|
+
image_size: Optional[list[int]] = None
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> dict:
|
|
95
|
+
result = {
|
|
96
|
+
"status": self.status,
|
|
97
|
+
"documentType": self.document_type,
|
|
98
|
+
"pageType": self.page_type,
|
|
99
|
+
"confidence": self.confidence,
|
|
100
|
+
"fields": _dataclass_to_camel_dict(self.fields),
|
|
101
|
+
"backPageFields": _dataclass_to_camel_dict(self.back_page_fields),
|
|
102
|
+
"panFields": _dataclass_to_camel_dict(self.pan_fields),
|
|
103
|
+
"aadhaarFields": _dataclass_to_camel_dict(self.aadhaar_fields),
|
|
104
|
+
"drivingLicenceFields": _dataclass_to_camel_dict(self.driving_licence_fields),
|
|
105
|
+
"voterIdFields": _dataclass_to_camel_dict(self.voter_id_fields),
|
|
106
|
+
"nregaJobCardFields": _dataclass_to_camel_dict(self.nrega_job_card_fields),
|
|
107
|
+
"nprLetterFields": _dataclass_to_camel_dict(self.npr_letter_fields),
|
|
108
|
+
"mrzRaw": list(self.mrz_raw) if self.mrz_raw else None,
|
|
109
|
+
"mrzValid": self.mrz_valid,
|
|
110
|
+
"lowConfidence": self.low_confidence,
|
|
111
|
+
"unsupportedReason": self.unsupported_reason,
|
|
112
|
+
"identifierValid": self.identifier_valid,
|
|
113
|
+
"missingRequiredFields": [_snake_to_camel(name) for name in self.missing_required_fields],
|
|
114
|
+
"probeText": self.probe_text,
|
|
115
|
+
"errors": self.errors,
|
|
116
|
+
"warnings": self.warnings,
|
|
117
|
+
"processingMs": self.processing_ms,
|
|
118
|
+
}
|
|
119
|
+
if self.document_fields is not None:
|
|
120
|
+
result.update({
|
|
121
|
+
"schemaVersion": 1,
|
|
122
|
+
"documentFields": {_snake_to_camel(k): _serialise_dataclass_value(v) for k, v in self.document_fields.items()},
|
|
123
|
+
"issuingCountry": self.issuing_country,
|
|
124
|
+
"issuingRegion": self.issuing_region,
|
|
125
|
+
"checks": {_snake_to_camel(k): v for k, v in (self.checks or {}).items()},
|
|
126
|
+
})
|
|
127
|
+
if self.field_evidence is not None:
|
|
128
|
+
result["fieldEvidence"] = {_snake_to_camel(k): v for k, v in self.field_evidence.items()}
|
|
129
|
+
result["imageSize"] = self.image_size
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def scan(
|
|
134
|
+
image_input: Union[str, bytes, Path], *, document_type: str | None = None,
|
|
135
|
+
country: str | None = None, include_evidence: bool = False,
|
|
136
|
+
) -> DocumentScanResult:
|
|
137
|
+
"""Scan one image/PDF page. Use scan_document for grouped images/all PDF pages.
|
|
138
|
+
|
|
139
|
+
Hints restrict routing; they never make an unidentified document valid.
|
|
140
|
+
Evidence coordinates refer to the normalized image from preview_image().
|
|
141
|
+
"""
|
|
142
|
+
document_type, country = validate_document_hint(document_type, country)
|
|
143
|
+
start = time.monotonic()
|
|
144
|
+
try:
|
|
145
|
+
data = input_bytes(image_input)
|
|
146
|
+
if data.startswith(b"%PDF-"):
|
|
147
|
+
page = pdf_pages(data, first_only=True)[0]
|
|
148
|
+
warnings = ["PDF_ADDITIONAL_PAGES_IGNORED"] if page.total > 1 else []
|
|
149
|
+
prep = PreprocessResult(page.image, warnings)
|
|
150
|
+
return _scan_page(prep, start, document_type, country, include_evidence, native=page.regions)
|
|
151
|
+
prep = _prepare_image(image_input)
|
|
152
|
+
except ImageQualityError as exc:
|
|
153
|
+
return DocumentScanResult("failure", "unknown", "unknown", 0.0,
|
|
154
|
+
errors=[str(exc)], processing_ms=_elapsed_ms(start))
|
|
155
|
+
return _scan_page(prep, start, document_type, country, include_evidence)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _prepare_image(image_input) -> PreprocessResult:
|
|
159
|
+
"""Preprocess an image exactly as scans do, so previews and redaction match them."""
|
|
160
|
+
try:
|
|
161
|
+
return preprocess(image_input)
|
|
162
|
+
except ImageQualityError as exc:
|
|
163
|
+
prep = _readable_despite_blur(image_input, exc)
|
|
164
|
+
if prep is None:
|
|
165
|
+
raise
|
|
166
|
+
return prep
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _readable_despite_blur(image_input, exc: ImageQualityError) -> Optional[PreprocessResult]:
|
|
170
|
+
"""Overrule the blur gate only for a passport whose MRZ reads with valid check digits.
|
|
171
|
+
|
|
172
|
+
The gate scores sharpness over the whole frame, so a sharp passport that
|
|
173
|
+
fills part of a large photo can fall below it. Check digits cannot validate
|
|
174
|
+
on a genuinely unreadable image, so they decide; everything else keeps the
|
|
175
|
+
rejection, including back pages, which carry no MRZ.
|
|
176
|
+
"""
|
|
177
|
+
if str(exc) != "IMAGE_TOO_BLURRY":
|
|
178
|
+
return None
|
|
179
|
+
try:
|
|
180
|
+
prep = preprocess(image_input, blur_threshold=0)
|
|
181
|
+
except ImageQualityError:
|
|
182
|
+
return None
|
|
183
|
+
mrz = parse_mrz(_extract_targeted_regions(prep.image))
|
|
184
|
+
if not (mrz and mrz.overall_checksum_valid):
|
|
185
|
+
return None
|
|
186
|
+
prep.warnings.append("BLUR_CHECK_OVERRIDDEN_BY_VALID_MRZ")
|
|
187
|
+
return prep
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def preview_image(image_input: Union[str, bytes, Path]):
|
|
191
|
+
"""Return the exact coordinate plane used for first-page extraction evidence."""
|
|
192
|
+
data = input_bytes(image_input)
|
|
193
|
+
if data.startswith(b"%PDF-"):
|
|
194
|
+
return pdf_pages(data, first_only=True)[0].image
|
|
195
|
+
return _prepare_image(image_input).image
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _structured(regions, prep, start, *, document_type=None, country=None, barcodes=None):
|
|
199
|
+
from .structured_documents import extract_structured_document
|
|
200
|
+
extraction = extract_structured_document(regions, document_type=document_type,
|
|
201
|
+
country=country, barcodes=barcodes)
|
|
202
|
+
if extraction is None:
|
|
203
|
+
return None
|
|
204
|
+
if prep.unenhanced_image is not None:
|
|
205
|
+
recovered = recover_ead_fields(prep.unenhanced_image, extraction, run_ocr, run_line_ocr)
|
|
206
|
+
if recovered is not None:
|
|
207
|
+
extraction = recovered
|
|
208
|
+
return DocumentScanResult(
|
|
209
|
+
"success" if extraction.complete else "failure", extraction.document_type,
|
|
210
|
+
extraction.document_type, extraction.confidence,
|
|
211
|
+
document_fields=extraction.fields, issuing_country=extraction.issuing_country,
|
|
212
|
+
issuing_region=extraction.issuing_region, field_evidence=extraction.field_evidence,
|
|
213
|
+
checks=extraction.checks, missing_required_fields=extraction.missing_required_fields,
|
|
214
|
+
errors=extraction.errors, warnings=prep.warnings + extraction.warnings,
|
|
215
|
+
processing_ms=_elapsed_ms(start),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _prepare_pdf_ocr(prep):
|
|
220
|
+
# PDF text and previews use the rendered coordinate plane. Enhance colors
|
|
221
|
+
# only; perspective correction or resizing here would invalidate boxes.
|
|
222
|
+
return PreprocessResult(enhance_contrast(prep.image), prep.warnings, prep.image)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _scan_page(prep, start, document_type, country, include_evidence, *, native=None):
|
|
226
|
+
# Every page entry point (scan, scan_document's PDF pages) bounds optional
|
|
227
|
+
# recovery re-reads from the page's start.
|
|
228
|
+
with recovery_deadline(start):
|
|
229
|
+
return _scan_page_bounded(prep, start, document_type, country, include_evidence, native=native)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _scan_page_bounded(prep, start, document_type, country, include_evidence, *, native=None):
|
|
233
|
+
barcodes = decode_barcodes(prep.image)
|
|
234
|
+
full = None
|
|
235
|
+
source = "ocr"
|
|
236
|
+
# Native text avoids re-OCR of digital forms and preserves exact characters.
|
|
237
|
+
# A document that cannot be identified from it falls back to visible OCR.
|
|
238
|
+
if native and sum(len(r.text) for r in native) >= 30:
|
|
239
|
+
full = native
|
|
240
|
+
source = "pdf_text"
|
|
241
|
+
prep.warnings.append("PDF_NATIVE_TEXT_NOT_VISUALLY_VERIFIED")
|
|
242
|
+
if native is not None and source == "ocr":
|
|
243
|
+
prep = _prepare_pdf_ocr(prep)
|
|
244
|
+
needs_full = bool(full or barcodes or document_type or country or include_evidence)
|
|
245
|
+
if needs_full:
|
|
246
|
+
full = full if full is not None else recover_passport_mrz(prep.image,
|
|
247
|
+
recover_travel_mrz(prep.image, run_kyc_ocr(prep.image), run_line_ocr), run_ocr, run_line_ocr,
|
|
248
|
+
band_ocr=run_band_ocr)
|
|
249
|
+
full = recover_form_fields(prep.image, full, run_line_ocr) if source == "ocr" else full
|
|
250
|
+
full = recover_card_fields(prep.image, full, run_ocr, run_line_ocr,
|
|
251
|
+
unenhanced_image=prep.unenhanced_image) if source == "ocr" else full
|
|
252
|
+
result = _structured(full, prep, start, document_type=document_type, country=country, barcodes=barcodes)
|
|
253
|
+
if result is None:
|
|
254
|
+
result = _scan_non_passport(prep, start, full_regions=full, try_structured=False)
|
|
255
|
+
if source == "pdf_text" and (result.document_type == "unknown" or "DOCUMENT_TYPE_NOT_CONFIRMED" in result.errors):
|
|
256
|
+
prep = _prepare_pdf_ocr(prep)
|
|
257
|
+
full = recover_passport_mrz(prep.image,
|
|
258
|
+
recover_travel_mrz(prep.image, run_kyc_ocr(prep.image), run_line_ocr), run_ocr, run_line_ocr,
|
|
259
|
+
band_ocr=run_band_ocr)
|
|
260
|
+
full = recover_form_fields(prep.image, full, run_line_ocr)
|
|
261
|
+
full = recover_card_fields(prep.image, full, run_ocr, run_line_ocr,
|
|
262
|
+
unenhanced_image=prep.unenhanced_image)
|
|
263
|
+
source = "ocr"
|
|
264
|
+
result = _structured(full, prep, start, document_type=document_type, country=country, barcodes=barcodes)
|
|
265
|
+
if result is None:
|
|
266
|
+
result = _scan_non_passport(prep, start, full_regions=full, try_structured=False)
|
|
267
|
+
else:
|
|
268
|
+
result = _scan_prepared(prep, start)
|
|
269
|
+
if result.issuing_country is None:
|
|
270
|
+
if result.document_type in _NON_PASSPORT_EXTRACTORS:
|
|
271
|
+
result.issuing_country = "IN"
|
|
272
|
+
elif result.fields and result.fields.country_code:
|
|
273
|
+
from .document_registry import normalize_country
|
|
274
|
+
try:
|
|
275
|
+
result.issuing_country = normalize_country(result.fields.country_code)
|
|
276
|
+
except ValueError:
|
|
277
|
+
pass
|
|
278
|
+
if document_type and result.document_type != document_type:
|
|
279
|
+
result.status = "failure"
|
|
280
|
+
result.errors.append("DOCUMENT_TYPE_MISMATCH")
|
|
281
|
+
if country and result.issuing_country and result.issuing_country != country:
|
|
282
|
+
result.status = "failure"
|
|
283
|
+
result.errors.append("DOCUMENT_COUNTRY_MISMATCH")
|
|
284
|
+
if country and not result.issuing_country:
|
|
285
|
+
result.status = "failure"
|
|
286
|
+
result.errors.append("ISSUING_COUNTRY_UNRESOLVED")
|
|
287
|
+
if include_evidence or document_type or country or result.document_fields is not None:
|
|
288
|
+
result.document_fields = collect_fields(result)
|
|
289
|
+
if include_evidence:
|
|
290
|
+
if result.field_evidence is None:
|
|
291
|
+
result.field_evidence = collect_evidence(result.document_fields, full or [], source=source, mrz_raw=result.mrz_raw)
|
|
292
|
+
elif source == "pdf_text":
|
|
293
|
+
for items in result.field_evidence.values():
|
|
294
|
+
for item in items:
|
|
295
|
+
if item["source"] == "ocr":
|
|
296
|
+
item["source"] = source
|
|
297
|
+
result.image_size = [prep.image.shape[1], prep.image.shape[0]]
|
|
298
|
+
else:
|
|
299
|
+
result.field_evidence = None
|
|
300
|
+
result.processing_ms = _elapsed_ms(start)
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _scan_prepared(prep, start):
|
|
305
|
+
# All document shapes need a complete read. Reuse it for routing and
|
|
306
|
+
# extraction; an already identified card should not also pay for a footer
|
|
307
|
+
# OCR pass. Passport recovery remains bounded and checksum validated.
|
|
308
|
+
from .kyc_ocr import configured_kyc_languages
|
|
309
|
+
configured = configured_kyc_languages()
|
|
310
|
+
english_only = configured == ('en',)
|
|
311
|
+
initial_read = run_kyc_ocr if english_only else run_ocr
|
|
312
|
+
full = recover_travel_mrz(prep.image, initial_read(prep.image), run_line_ocr)
|
|
313
|
+
full = recover_form_fields(prep.image, full, run_line_ocr)
|
|
314
|
+
full = recover_card_fields(prep.image, full, run_ocr, run_line_ocr,
|
|
315
|
+
unenhanced_image=prep.unenhanced_image)
|
|
316
|
+
structured = _structured(full, prep, start)
|
|
317
|
+
if structured is not None:
|
|
318
|
+
return structured
|
|
319
|
+
classification = classify_document(full)
|
|
320
|
+
detected_mrz = parse_mrz(full)
|
|
321
|
+
if (_is_strong_non_passport_classification(classification) and (not configured or english_only)
|
|
322
|
+
and not (detected_mrz and detected_mrz.overall_checksum_valid)):
|
|
323
|
+
return _scan_non_passport(prep, start, full_regions=full, doc_cls=classification, try_structured=False)
|
|
324
|
+
mrz = parse_mrz(full)
|
|
325
|
+
if mrz and mrz.overall_checksum_valid:
|
|
326
|
+
# Returns at once unless the valid MRZ still carries name-padding noise.
|
|
327
|
+
full = recover_passport_mrz(prep.image, full, run_ocr, run_line_ocr, band_ocr=run_band_ocr)
|
|
328
|
+
mrz = parse_mrz(full)
|
|
329
|
+
if mrz and mrz.overall_checksum_valid:
|
|
330
|
+
probe = full
|
|
331
|
+
else:
|
|
332
|
+
# The bottom-page probe costs one pass on part of the page and settles
|
|
333
|
+
# back pages and cleanly cropped MRZs, so try it before recovery, whose
|
|
334
|
+
# re-reads only help a biodata page that both reads left unresolved.
|
|
335
|
+
probe = _extract_targeted_regions(prep.image)
|
|
336
|
+
probe_mrz = parse_mrz(probe)
|
|
337
|
+
probe_valid = bool(probe_mrz and probe_mrz.overall_checksum_valid)
|
|
338
|
+
# Back-page hints in the probe can be background text below a passport
|
|
339
|
+
# photographed high in the frame, so they veto recovery only when the
|
|
340
|
+
# full page shows no MRZ evidence at all.
|
|
341
|
+
back_page = (classify_passport_page(probe).page_type == "passport_non_biodata"
|
|
342
|
+
and not (mrz or has_mrz_anchor(full)))
|
|
343
|
+
if not back_page and not (probe_valid and "MRZ_NAME_PADDING_NOISE" not in probe_mrz.errors):
|
|
344
|
+
# A valid but noisy probe needs only the name cleanup, which keeps
|
|
345
|
+
# the default band enlargement; unresolved pages use the cheaper one.
|
|
346
|
+
full = recover_passport_mrz(prep.image, full, run_ocr, run_line_ocr,
|
|
347
|
+
band_ocr=None if probe_valid else run_band_ocr)
|
|
348
|
+
mrz = parse_mrz(full)
|
|
349
|
+
if mrz and mrz.overall_checksum_valid:
|
|
350
|
+
probe = full
|
|
351
|
+
page = classify_passport_page(probe)
|
|
352
|
+
if page.page_type in {"passport_biodata", "passport_non_biodata"}:
|
|
353
|
+
return _scan_passport(prep, page, probe, start, full_page_regions=full)
|
|
354
|
+
# Sparse output alone does not justify repeating the same English model
|
|
355
|
+
# on identical pixels. Only an additional language changes the evidence.
|
|
356
|
+
if configured and not english_only:
|
|
357
|
+
full = recover_passport_mrz(prep.image,
|
|
358
|
+
recover_travel_mrz(prep.image, run_kyc_ocr(prep.image), run_line_ocr), run_ocr, run_line_ocr,
|
|
359
|
+
band_ocr=run_band_ocr)
|
|
360
|
+
if not full:
|
|
361
|
+
return DocumentScanResult(
|
|
362
|
+
status="failure",
|
|
363
|
+
document_type="unknown",
|
|
364
|
+
page_type="unknown",
|
|
365
|
+
confidence=0.0,
|
|
366
|
+
errors=["NO_TEXT_DETECTED"],
|
|
367
|
+
warnings=prep.warnings,
|
|
368
|
+
processing_ms=_elapsed_ms(start),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
return _scan_non_passport(prep, start, full_regions=full)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _scan_passport(prep, classification, regions, start, *, full_page_regions=None):
|
|
375
|
+
# The crop establishes routing; the full page supplies visual fields and
|
|
376
|
+
# cross-checks even when the cropped MRZ already has valid check digits.
|
|
377
|
+
if full_page_regions is None:
|
|
378
|
+
full_page_regions = recover_passport_mrz(prep.image, run_ocr(prep.image), run_ocr, run_line_ocr,
|
|
379
|
+
band_ocr=run_band_ocr)
|
|
380
|
+
structured = _structured(full_page_regions, prep, start)
|
|
381
|
+
if structured is not None:
|
|
382
|
+
return structured
|
|
383
|
+
if classification.page_type == "passport_non_biodata":
|
|
384
|
+
back_fields = extract_back_page(full_page_regions)
|
|
385
|
+
has_fields = any(
|
|
386
|
+
getattr(back_fields, item.name)
|
|
387
|
+
for item in dataclass_fields(back_fields)
|
|
388
|
+
)
|
|
389
|
+
return DocumentScanResult(
|
|
390
|
+
status="success" if has_fields else "failure",
|
|
391
|
+
document_type="passport",
|
|
392
|
+
page_type="passport_non_biodata",
|
|
393
|
+
confidence=classification.confidence if has_fields else 0.0,
|
|
394
|
+
back_page_fields=back_fields,
|
|
395
|
+
errors=[] if has_fields else ["NO_BACK_PAGE_FIELDS_DETECTED"],
|
|
396
|
+
probe_text=classification.probe_text,
|
|
397
|
+
warnings=prep.warnings + classification.reasons,
|
|
398
|
+
processing_ms=_elapsed_ms(start),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
mrz = parse_mrz(regions)
|
|
402
|
+
if full_page_regions:
|
|
403
|
+
fallback_mrz = parse_mrz(full_page_regions)
|
|
404
|
+
if _candidate_score(fallback_mrz, full_page_regions) > _candidate_score(
|
|
405
|
+
mrz, full_page_regions
|
|
406
|
+
):
|
|
407
|
+
mrz = fallback_mrz
|
|
408
|
+
regions = full_page_regions
|
|
409
|
+
|
|
410
|
+
validation = validate(mrz, regions)
|
|
411
|
+
fields = _build_fields(mrz, regions)
|
|
412
|
+
|
|
413
|
+
mrz_valid = mrz.overall_checksum_valid if mrz else False
|
|
414
|
+
all_warnings = prep.warnings.copy()
|
|
415
|
+
all_errors = validation.errors.copy()
|
|
416
|
+
|
|
417
|
+
if mrz and mrz.errors:
|
|
418
|
+
all_warnings.extend(mrz.errors)
|
|
419
|
+
elif mrz is None:
|
|
420
|
+
all_warnings.append("MRZ_NOT_DETECTED")
|
|
421
|
+
|
|
422
|
+
all_warnings.extend(validation.warnings)
|
|
423
|
+
|
|
424
|
+
overall_confidence = round(
|
|
425
|
+
min(max((validation.confidence * 0.85) + (classification.confidence * 0.15), 0.0), 1.0),
|
|
426
|
+
3,
|
|
427
|
+
)
|
|
428
|
+
low_confidence = 0.3 <= overall_confidence < 0.7
|
|
429
|
+
|
|
430
|
+
if (
|
|
431
|
+
mrz_valid
|
|
432
|
+
and not all_errors
|
|
433
|
+
and fields.passport_number
|
|
434
|
+
and fields.surname
|
|
435
|
+
and overall_confidence >= 0.7
|
|
436
|
+
):
|
|
437
|
+
return DocumentScanResult(
|
|
438
|
+
status="success",
|
|
439
|
+
document_type="passport",
|
|
440
|
+
page_type="passport_biodata",
|
|
441
|
+
confidence=overall_confidence,
|
|
442
|
+
fields=fields,
|
|
443
|
+
mrz_raw=mrz.raw_lines if mrz else None,
|
|
444
|
+
mrz_valid=mrz_valid,
|
|
445
|
+
low_confidence=low_confidence,
|
|
446
|
+
probe_text=classification.probe_text,
|
|
447
|
+
errors=all_errors,
|
|
448
|
+
warnings=all_warnings,
|
|
449
|
+
processing_ms=_elapsed_ms(start),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
return DocumentScanResult(
|
|
453
|
+
status="failure",
|
|
454
|
+
document_type="passport",
|
|
455
|
+
page_type="passport_biodata",
|
|
456
|
+
confidence=overall_confidence,
|
|
457
|
+
fields=fields if _has_meaningful_fields(fields) else None,
|
|
458
|
+
mrz_raw=mrz.raw_lines if mrz else None,
|
|
459
|
+
mrz_valid=mrz_valid,
|
|
460
|
+
low_confidence=low_confidence,
|
|
461
|
+
probe_text=classification.probe_text,
|
|
462
|
+
errors=all_errors or ["LOW_CONFIDENCE_EXTRACTION"],
|
|
463
|
+
warnings=all_warnings,
|
|
464
|
+
processing_ms=_elapsed_ms(start),
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# Maps a classified document type to (result attribute, extractor function).
|
|
469
|
+
_NON_PASSPORT_EXTRACTORS = {
|
|
470
|
+
"pan": ("pan_fields", extract_pan),
|
|
471
|
+
"aadhaar": ("aadhaar_fields", extract_aadhaar),
|
|
472
|
+
"driving_licence": ("driving_licence_fields", extract_driving_licence),
|
|
473
|
+
"voter_id": ("voter_id_fields", extract_voter_id),
|
|
474
|
+
"nrega_job_card": ("nrega_job_card_fields", extract_nrega),
|
|
475
|
+
"npr_letter": ("npr_letter_fields", extract_npr_letter),
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
_STRONG_NON_PASSPORT_REASON_PREFIXES = {
|
|
479
|
+
"pan": ("PAN_KEYWORDS_",),
|
|
480
|
+
"aadhaar": ("AADHAAR_KEYWORDS_",),
|
|
481
|
+
"driving_licence": ("DL_KEYWORDS_",),
|
|
482
|
+
"voter_id": ("VOTER_KEYWORDS_",),
|
|
483
|
+
"nrega_job_card": ("NREGA_",),
|
|
484
|
+
"npr_letter": ("NPR_",),
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _is_strong_non_passport_classification(classification) -> bool:
|
|
489
|
+
prefixes = _STRONG_NON_PASSPORT_REASON_PREFIXES.get(
|
|
490
|
+
classification.document_type
|
|
491
|
+
)
|
|
492
|
+
return bool(
|
|
493
|
+
prefixes
|
|
494
|
+
and classification.confidence >= 0.86
|
|
495
|
+
and any(
|
|
496
|
+
reason.startswith(prefixes)
|
|
497
|
+
for reason in classification.reasons
|
|
498
|
+
)
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _scan_non_passport(
|
|
503
|
+
prep,
|
|
504
|
+
start: float,
|
|
505
|
+
*,
|
|
506
|
+
full_regions=None,
|
|
507
|
+
doc_cls=None,
|
|
508
|
+
try_structured=True,
|
|
509
|
+
) -> DocumentScanResult:
|
|
510
|
+
"""Classify and extract a non-passport KYC document from full-page OCR."""
|
|
511
|
+
if full_regions is None:
|
|
512
|
+
full_regions = recover_passport_mrz(prep.image,
|
|
513
|
+
recover_travel_mrz(prep.image, run_kyc_ocr(prep.image), run_line_ocr), run_ocr, run_line_ocr,
|
|
514
|
+
band_ocr=run_band_ocr)
|
|
515
|
+
if not full_regions:
|
|
516
|
+
return DocumentScanResult(
|
|
517
|
+
status="failure",
|
|
518
|
+
document_type="unknown",
|
|
519
|
+
page_type="unknown",
|
|
520
|
+
confidence=0.0,
|
|
521
|
+
errors=["NO_TEXT_DETECTED"],
|
|
522
|
+
warnings=prep.warnings,
|
|
523
|
+
processing_ms=_elapsed_ms(start),
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
if try_structured:
|
|
527
|
+
structured = _structured(full_regions, prep, start)
|
|
528
|
+
if structured is not None:
|
|
529
|
+
return structured
|
|
530
|
+
mrz = parse_mrz(full_regions)
|
|
531
|
+
if mrz and mrz.overall_checksum_valid:
|
|
532
|
+
page = classify_passport_page(full_regions)
|
|
533
|
+
return _scan_passport(prep, page, full_regions, start, full_page_regions=full_regions)
|
|
534
|
+
if doc_cls is None:
|
|
535
|
+
doc_cls = classify_document(full_regions)
|
|
536
|
+
if doc_cls.document_type == "passport":
|
|
537
|
+
page = classify_passport_page(full_regions)
|
|
538
|
+
if page.page_type in {"passport_biodata", "passport_non_biodata"}:
|
|
539
|
+
return _scan_passport(
|
|
540
|
+
prep, page, full_regions, start, full_page_regions=full_regions
|
|
541
|
+
)
|
|
542
|
+
extractor_entry = _NON_PASSPORT_EXTRACTORS.get(doc_cls.document_type)
|
|
543
|
+
|
|
544
|
+
if extractor_entry is None:
|
|
545
|
+
# Passport missed by the cheap probe, or an unsupported/unknown document.
|
|
546
|
+
return DocumentScanResult(
|
|
547
|
+
status="unsupported_page",
|
|
548
|
+
document_type=doc_cls.document_type,
|
|
549
|
+
page_type="unknown",
|
|
550
|
+
confidence=doc_cls.confidence,
|
|
551
|
+
unsupported_reason="UNSUPPORTED_DOCUMENT",
|
|
552
|
+
probe_text=[],
|
|
553
|
+
warnings=prep.warnings + doc_cls.reasons,
|
|
554
|
+
processing_ms=_elapsed_ms(start),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
attr, extractor_fn = extractor_entry
|
|
558
|
+
trusted_regions = [
|
|
559
|
+
region
|
|
560
|
+
for region in full_regions
|
|
561
|
+
if region.confidence >= MIN_OCR_GEOMETRY_CONFIDENCE_FOR_SUCCESS
|
|
562
|
+
]
|
|
563
|
+
fields = extractor_fn(trusted_regions)
|
|
564
|
+
assessment = assess_kyc_extraction(
|
|
565
|
+
doc_cls.document_type,
|
|
566
|
+
fields,
|
|
567
|
+
trusted_regions,
|
|
568
|
+
)
|
|
569
|
+
confidence = round(
|
|
570
|
+
(doc_cls.confidence * 0.4) + (assessment.confidence * 0.6),
|
|
571
|
+
3,
|
|
572
|
+
)
|
|
573
|
+
if not assessment.complete:
|
|
574
|
+
confidence = min(confidence, 0.69)
|
|
575
|
+
|
|
576
|
+
result = DocumentScanResult(
|
|
577
|
+
status="success" if assessment.complete else "failure",
|
|
578
|
+
document_type=doc_cls.document_type,
|
|
579
|
+
page_type=doc_cls.document_type,
|
|
580
|
+
confidence=confidence,
|
|
581
|
+
low_confidence=0.3 <= confidence < 0.7,
|
|
582
|
+
identifier_valid=assessment.identifier_valid,
|
|
583
|
+
missing_required_fields=assessment.missing_required_fields,
|
|
584
|
+
probe_text=[],
|
|
585
|
+
errors=assessment.errors,
|
|
586
|
+
warnings=prep.warnings + doc_cls.reasons + assessment.warnings,
|
|
587
|
+
processing_ms=_elapsed_ms(start),
|
|
588
|
+
)
|
|
589
|
+
setattr(result, attr, fields)
|
|
590
|
+
return result
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _elapsed_ms(start: float) -> int:
|
|
594
|
+
return int((time.monotonic() - start) * 1000)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _snake_to_camel(name: str) -> str:
|
|
598
|
+
parts = name.split("_")
|
|
599
|
+
return parts[0] + "".join(part.title() for part in parts[1:])
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _dataclass_to_camel_dict(obj) -> Optional[dict]:
|
|
603
|
+
"""Serialise a per-document field dataclass to a camelCase dict (or None)."""
|
|
604
|
+
if obj is None:
|
|
605
|
+
return None
|
|
606
|
+
return {
|
|
607
|
+
_snake_to_camel(f.name): _serialise_dataclass_value(getattr(obj, f.name))
|
|
608
|
+
for f in dataclass_fields(obj)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _serialise_dataclass_value(value):
|
|
613
|
+
"""Recursively serialise nested extractor dataclasses and collections."""
|
|
614
|
+
if hasattr(value, "__dataclass_fields__"):
|
|
615
|
+
return _dataclass_to_camel_dict(value)
|
|
616
|
+
if isinstance(value, list):
|
|
617
|
+
return [_serialise_dataclass_value(item) for item in value]
|
|
618
|
+
if isinstance(value, tuple):
|
|
619
|
+
return [_serialise_dataclass_value(item) for item in value]
|
|
620
|
+
if isinstance(value, dict):
|
|
621
|
+
return {
|
|
622
|
+
key: _serialise_dataclass_value(item)
|
|
623
|
+
for key, item in value.items()
|
|
624
|
+
}
|
|
625
|
+
return value
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _extract_targeted_regions(image) -> list[TextRegion]:
|
|
629
|
+
height = image.shape[0]
|
|
630
|
+
crop_top = int(height * TARGETED_CROP_TOP_RATIO)
|
|
631
|
+
if height <= crop_top:
|
|
632
|
+
return []
|
|
633
|
+
|
|
634
|
+
targeted_regions = run_ocr(image[crop_top:, :])
|
|
635
|
+
return _offset_regions(targeted_regions, y_offset=crop_top)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _offset_regions(regions: list[TextRegion], *, x_offset: int = 0, y_offset: int = 0) -> list[TextRegion]:
|
|
639
|
+
offset_regions: list[TextRegion] = []
|
|
640
|
+
for region in regions:
|
|
641
|
+
bbox = [[point[0] + x_offset, point[1] + y_offset] for point in region.bbox]
|
|
642
|
+
offset_regions.append(TextRegion(text=region.text, bbox=bbox, confidence=region.confidence))
|
|
643
|
+
return offset_regions
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _candidate_score(mrz: Optional[MRZResult], regions: list[TextRegion]) -> tuple:
|
|
647
|
+
# A checksum-valid reading must always outrank one that fails its checksum.
|
|
648
|
+
return (
|
|
649
|
+
bool(mrz and mrz.overall_checksum_valid),
|
|
650
|
+
bool(mrz and "MRZ_NAME_PADDING_NOISE" not in mrz.errors),
|
|
651
|
+
validate(mrz, regions).confidence,
|
|
652
|
+
mrz is not None,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _has_meaningful_fields(fields: PassportFields) -> bool:
|
|
657
|
+
return any(
|
|
658
|
+
value is not None
|
|
659
|
+
for value in (
|
|
660
|
+
fields.surname,
|
|
661
|
+
fields.given_names,
|
|
662
|
+
fields.passport_number,
|
|
663
|
+
fields.nationality,
|
|
664
|
+
fields.date_of_birth,
|
|
665
|
+
fields.expiry_date,
|
|
666
|
+
)
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _build_fields(
|
|
671
|
+
mrz: Optional[MRZResult],
|
|
672
|
+
regions: list[TextRegion],
|
|
673
|
+
) -> PassportFields:
|
|
674
|
+
fields = PassportFields()
|
|
675
|
+
|
|
676
|
+
if mrz:
|
|
677
|
+
fields.surname = mrz.surname.value
|
|
678
|
+
fields.given_names = mrz.given_names.value
|
|
679
|
+
fields.passport_number = mrz.passport_number.value
|
|
680
|
+
fields.nationality = mrz.nationality.value
|
|
681
|
+
fields.date_of_birth = mrz.date_of_birth.value
|
|
682
|
+
fields.sex = mrz.sex.value
|
|
683
|
+
fields.expiry_date = mrz.expiry_date.value
|
|
684
|
+
fields.country_code = mrz.country_code.value
|
|
685
|
+
|
|
686
|
+
if fields.surname and fields.given_names:
|
|
687
|
+
fields.full_name = f"{fields.given_names} {fields.surname}"
|
|
688
|
+
elif fields.surname:
|
|
689
|
+
fields.full_name = fields.surname
|
|
690
|
+
|
|
691
|
+
fields.issue_date = _extract_visual_field(
|
|
692
|
+
regions,
|
|
693
|
+
["DATE OF ISSUE", "ISSUE DATE", "ISSUED", "DÉLIVRANCE"],
|
|
694
|
+
)
|
|
695
|
+
fields.place_of_birth = _extract_visual_field(
|
|
696
|
+
regions,
|
|
697
|
+
["PLACE OF BIRTH", "BIRTHPLACE", "LIEU DE NAISSANCE"],
|
|
698
|
+
)
|
|
699
|
+
return fields
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _extract_visual_field(
|
|
703
|
+
regions: list[TextRegion],
|
|
704
|
+
labels: list[str],
|
|
705
|
+
) -> Optional[str]:
|
|
706
|
+
label_region = find_visual_field(regions, labels)
|
|
707
|
+
if label_region is None:
|
|
708
|
+
return None
|
|
709
|
+
value_region = find_visual_value_near(regions, label_region)
|
|
710
|
+
if value_region is None:
|
|
711
|
+
return None
|
|
712
|
+
return value_region.text.strip()
|