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