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,510 @@
1
+ """
2
+ Extractor for Indian Driving Licences (DL).
3
+
4
+ Fields: DL number, name, relation (S/D/W of), date of birth, date of issue,
5
+ validity date, address, blood group, class of vehicle (COV).
6
+
7
+ Indian DL layouts vary a great deal by issuing state/RTO and are frequently
8
+ bilingual (Hindi/English and regional scripts). Extraction is therefore
9
+ label- and format-driven rather than positional:
10
+
11
+ * The DL number is recovered with ``core.validators.normalize_dl`` (state
12
+ code + RTO + year + serial), tolerating spaces/hyphens and the common
13
+ case where OCR splits the number across two adjacent regions.
14
+ * A DL card carries several ``DD/MM/YYYY`` dates (DOB, issue, one or two
15
+ validity dates). Each date is assigned strictly by the label adjacent to
16
+ it (right-then-below), never by raw reading order, so re-ordered or
17
+ multi-column layouts do not scramble the fields.
18
+ * When both a non-transport (NT) and a transport (TR) validity date are
19
+ present, the non-transport / primary validity is preferred (see
20
+ ``_find_validity_date``).
21
+ * Addresses may be multi-line and may appear twice (Present + Permanent);
22
+ the present/current address is preferred (see ``_find_address``).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from dataclasses import dataclass
29
+ from typing import Optional
30
+
31
+ from .ocr_engine import TextRegion
32
+ from .validator import (
33
+ find_label_value,
34
+ find_visual_field,
35
+ find_visual_value_near,
36
+ find_visual_value_right,
37
+ )
38
+ from .validators import normalize_dl
39
+
40
+ # A DD/MM/YYYY-style date anywhere in a string (also tolerates 2-digit years
41
+ # and '.'/'-'/'/' separators that OCR emits interchangeably).
42
+ _DATE_RE = re.compile(r"\b(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})\b")
43
+
44
+ # Blood group as a standalone token, e.g. "B+", "O-", "AB+VE", "A POSITIVE".
45
+ # A trailing sign/word is REQUIRED so a bare "B" inside a label like "BG" or a
46
+ # word like "BIHAR" is not mistaken for a blood group.
47
+ _BLOOD_GROUP_RE = re.compile(
48
+ r"\b(AB|A|B|O)\s*"
49
+ r"(\+VE|-VE|\+|-|POS(?:ITIVE)?|NEG(?:ATIVE)?)"
50
+ r"(?![A-Z])",
51
+ re.IGNORECASE,
52
+ )
53
+ # Canonical class-of-vehicle tokens that appear on Indian DLs.
54
+ _COV_TOKENS = [
55
+ "MCWOG", "MCWG", "MCEX50CC", "MGV", "HGMV", "HMV", "HPMV", "LMV-NT",
56
+ "LMV-TR", "LMV", "TRANS", "TRACTOR", "ERIG", "ROAD ROLLER", "MC", "FVG",
57
+ "PSV", "3WT", "3WNT", "INVCRG",
58
+ ]
59
+
60
+ _NAME_LABELS = ["NAME", "HOLDER S NAME", "HOLDERS NAME", "HOLDER NAME"]
61
+ # S/D/W of => relation/guardian. Many OCRs collapse the slashes.
62
+ _RELATION_LABELS = [
63
+ "SON DAUGHTER WIFE OF",
64
+ "S D W OF",
65
+ "S O",
66
+ "D O",
67
+ "W O",
68
+ "C O",
69
+ "SON OF",
70
+ "DAUGHTER OF",
71
+ "WIFE OF",
72
+ "FATHER",
73
+ "GUARDIAN",
74
+ ]
75
+ _DOB_LABELS = ["DATE OF BIRTH", "DOB", "BIRTH"]
76
+ _DOI_LABELS = ["DATE OF ISSUE", "ISSUE DATE", "DOI", "ISSUE"]
77
+ # Generic validity labels (used when there is a single validity date).
78
+ _VALIDITY_LABELS = [
79
+ "VALID TILL",
80
+ "VALID UPTO",
81
+ "VALID UNTIL",
82
+ "VALIDITY",
83
+ "DATE OF EXPIRY",
84
+ "EXPIRY",
85
+ ]
86
+ # Non-transport (primary) validity labels — preferred when both NT and TR exist.
87
+ _VALIDITY_NT_LABELS = [
88
+ "VALIDITY NT",
89
+ "VALID TILL NT",
90
+ "NON TRANSPORT",
91
+ "NT",
92
+ ]
93
+ # Transport validity labels — only used if no NT/primary date is found.
94
+ _VALIDITY_TR_LABELS = [
95
+ "VALIDITY TR",
96
+ "VALID TILL TR",
97
+ "TRANSPORT",
98
+ "TR",
99
+ ]
100
+ _BLOOD_GROUP_LABELS = ["BLOOD GROUP", "BG", "BLOOD"]
101
+ _COV_LABELS = [
102
+ "CLASS OF VEHICLE",
103
+ "CLASS OF VEHICLES",
104
+ "COV",
105
+ "CL OF VEH",
106
+ "AUTHORISATION TO DRIVE",
107
+ "AUTHORIZATION TO DRIVE",
108
+ ]
109
+ # Present/current address preferred over permanent.
110
+ _PRESENT_ADDRESS_LABELS = [
111
+ "PRESENT ADDRESS",
112
+ "CURRENT ADDRESS",
113
+ "TEMPORARY ADDRESS",
114
+ "TEMP ADDRESS",
115
+ ]
116
+ _PERMANENT_ADDRESS_LABELS = ["PERMANENT ADDRESS", "PERM ADDRESS"]
117
+ _GENERIC_ADDRESS_LABELS = ["ADDRESS", "ADD", "ADDR"]
118
+
119
+
120
+ @dataclass
121
+ class DrivingLicenceFields:
122
+ dl_number: Optional[str] = None
123
+ name: Optional[str] = None
124
+ date_of_birth: Optional[str] = None
125
+ issue_date: Optional[str] = None
126
+ validity_date: Optional[str] = None
127
+ address: Optional[str] = None
128
+ # --- Added fields (must be synced to TS DrivingLicenceFields) ---
129
+ relation_name: Optional[str] = None
130
+ blood_group: Optional[str] = None
131
+ class_of_vehicle: Optional[str] = None
132
+ # Secondary (transport) validity date, when a card lists both NT and TR.
133
+ validity_date_transport: Optional[str] = None
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # DL number
138
+ # ---------------------------------------------------------------------------
139
+
140
+ def _find_dl_number(regions: list[TextRegion]) -> Optional[str]:
141
+ """Recover the DL number, including the case where OCR splits it in two.
142
+
143
+ First try a direct ``normalize_dl`` scan over each region (handles
144
+ ``MH12 20110012345``, ``DL-0420110149646``, ``TN0120200001234`` …). If
145
+ that fails, try concatenating spatially adjacent regions on the same row,
146
+ since some cards render the state/RTO prefix and the serial as separate
147
+ OCR tokens (e.g. ``"MH12"`` then ``"20110012345"``).
148
+ """
149
+ for region in regions:
150
+ dl = normalize_dl(region.text)
151
+ if dl:
152
+ return dl
153
+
154
+ # Adjacency fallback: join same-row neighbours left-to-right and re-scan.
155
+ ordered = sorted(
156
+ regions,
157
+ key=lambda r: (
158
+ round(min(p[1] for p in r.bbox) / 20), # row band
159
+ min(p[0] for p in r.bbox),
160
+ ),
161
+ )
162
+ for i, region in enumerate(ordered):
163
+ top = min(p[1] for p in region.bbox)
164
+ bottom = max(p[1] for p in region.bbox)
165
+ height = max(bottom - top, 1)
166
+ combined = region.text
167
+ for nxt in ordered[i + 1 : i + 4]:
168
+ n_top = min(p[1] for p in nxt.bbox)
169
+ n_bottom = max(p[1] for p in nxt.bbox)
170
+ overlap = min(bottom, n_bottom) - max(top, n_top)
171
+ if overlap < height * 0.4:
172
+ continue
173
+ combined += " " + nxt.text
174
+ dl = normalize_dl(combined)
175
+ if dl:
176
+ return dl
177
+ return None
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Dates
182
+ # ---------------------------------------------------------------------------
183
+
184
+ def _first_date(text: Optional[str]) -> Optional[str]:
185
+ if not text:
186
+ return None
187
+ m = _DATE_RE.search(text)
188
+ return m.group(1) if m else None
189
+
190
+
191
+ def _labelled_date(
192
+ regions: list[TextRegion],
193
+ labels: list[str],
194
+ *,
195
+ label_region: Optional[TextRegion] = None,
196
+ ) -> Optional[str]:
197
+ """Resolve a date by the label adjacent to it (right then below).
198
+
199
+ The label's *own* text is also inspected: some compact layouts print the
200
+ value on the same region as the label (``"DOB: 09/03/1986"``).
201
+ """
202
+ if label_region is None:
203
+ label_region = find_visual_field(regions, labels)
204
+ if label_region is None:
205
+ return None
206
+
207
+ # Date embedded in the label region itself.
208
+ self_date = _first_date(label_region.text)
209
+ if self_date:
210
+ return self_date
211
+
212
+ value = find_visual_value_right(regions, label_region) or find_visual_value_near(
213
+ regions, label_region
214
+ )
215
+ if value is not None:
216
+ return _first_date(value.text)
217
+ return None
218
+
219
+
220
+ def _find_validity_date(regions: list[TextRegion]) -> tuple[Optional[str], Optional[str]]:
221
+ """Return ``(primary_validity, transport_validity)``.
222
+
223
+ Indian DLs may carry two validity dates: NT (non-transport) and TR
224
+ (transport). We prefer the non-transport / primary date for
225
+ ``validity_date`` and expose the transport date separately.
226
+
227
+ Resolution order for the primary date:
228
+ 1. An explicit non-transport (NT) label.
229
+ 2. A generic "Valid Till / Valid Upto / Validity" label.
230
+ The transport date is taken from an explicit TR label when present.
231
+ """
232
+ nt_label = find_visual_field(regions, _VALIDITY_NT_LABELS)
233
+ tr_label = find_visual_field(regions, _VALIDITY_TR_LABELS)
234
+ generic_label = find_visual_field(regions, _VALIDITY_LABELS)
235
+
236
+ transport = _labelled_date(regions, _VALIDITY_TR_LABELS, label_region=tr_label)
237
+
238
+ # Avoid letting a generic "VALIDITY" match collide with the TR row: if the
239
+ # generic match resolved to the same region as the TR label, ignore it.
240
+ primary = None
241
+ if nt_label is not None:
242
+ primary = _labelled_date(regions, _VALIDITY_NT_LABELS, label_region=nt_label)
243
+ if primary is None and generic_label is not None and generic_label is not tr_label:
244
+ primary = _labelled_date(regions, _VALIDITY_LABELS, label_region=generic_label)
245
+
246
+ # If we only found a transport date, surface it as the primary too so the
247
+ # field is never silently empty when a validity date plainly exists.
248
+ if primary is None and transport is not None:
249
+ primary = transport
250
+
251
+ return primary, transport
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # Name / relation
256
+ # ---------------------------------------------------------------------------
257
+
258
+ # Any region whose normalised text matches one of these is a field label, not a
259
+ # value — used to reject a label that was mistakenly picked up as a name/relation.
260
+ _ALL_LABEL_WORDS = frozenset(
261
+ word
262
+ for label_set in (
263
+ _NAME_LABELS,
264
+ _RELATION_LABELS,
265
+ _DOB_LABELS,
266
+ _DOI_LABELS,
267
+ _VALIDITY_LABELS,
268
+ _VALIDITY_NT_LABELS,
269
+ _VALIDITY_TR_LABELS,
270
+ _BLOOD_GROUP_LABELS,
271
+ _COV_LABELS,
272
+ _PRESENT_ADDRESS_LABELS,
273
+ _PERMANENT_ADDRESS_LABELS,
274
+ _GENERIC_ADDRESS_LABELS,
275
+ )
276
+ for word in label_set
277
+ )
278
+
279
+
280
+ def _is_known_label(text: str) -> bool:
281
+ norm = re.sub(r"[^A-Z0-9]+", " ", text.upper()).strip()
282
+ return norm in _ALL_LABEL_WORDS
283
+
284
+
285
+ def _looks_like_value(text: str) -> bool:
286
+ """True if a string looks like a usable name/value rather than empty noise."""
287
+ cleaned = text.strip()
288
+ return len(re.sub(r"[^A-Za-z]", "", cleaned)) >= 2
289
+
290
+
291
+ def _find_name(regions: list[TextRegion]) -> Optional[str]:
292
+ """Resolve the holder name, avoiding the relation (S/D/W of) value.
293
+
294
+ ``find_visual_field`` for "NAME" can latch onto a relation label whose
295
+ text also contains "NAME" in bilingual cards; we explicitly prefer a label
296
+ that is exactly/primarily a name label and is not a relation label.
297
+ """
298
+ value = find_label_value(regions, _NAME_LABELS)
299
+ if value and _looks_like_value(value) and not _is_known_label(value):
300
+ return value.strip()
301
+ return None
302
+
303
+
304
+ def _find_relation(regions: list[TextRegion]) -> Optional[str]:
305
+ value = find_label_value(regions, _RELATION_LABELS)
306
+ if value and _looks_like_value(value) and not _is_known_label(value):
307
+ return value.strip()
308
+ return None
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Blood group / class of vehicle
313
+ # ---------------------------------------------------------------------------
314
+
315
+ def _normalise_blood_group(text: str) -> Optional[str]:
316
+ upper = text.upper()
317
+ # Never read a blood group out of the label words themselves.
318
+ if re.search(r"BLOOD|GROUP|\bBG\b", upper):
319
+ return None
320
+ m = _BLOOD_GROUP_RE.search(upper)
321
+ if not m:
322
+ return None
323
+ group = m.group(1)
324
+ suffix = m.group(2)
325
+ sign = "+" if suffix[0] in "+P" or suffix.upper().startswith("POS") else "-"
326
+ return f"{group}{sign}"
327
+
328
+
329
+ def _find_blood_group(regions: list[TextRegion]) -> Optional[str]:
330
+ labelled = find_label_value(regions, _BLOOD_GROUP_LABELS)
331
+ if labelled and (group := _normalise_blood_group(labelled)):
332
+ return group
333
+ label = find_visual_field(regions, _BLOOD_GROUP_LABELS)
334
+ if label is not None:
335
+ # Value may sit to the right, below, or (rarely) on the label region.
336
+ for candidate_text in (
337
+ getattr(find_visual_value_right(regions, label), "text", "") or "",
338
+ getattr(find_visual_value_near(regions, label), "text", "") or "",
339
+ label.text,
340
+ ):
341
+ bg = _normalise_blood_group(candidate_text)
342
+ if bg:
343
+ return bg
344
+ return None
345
+
346
+
347
+ def _find_class_of_vehicle(regions: list[TextRegion]) -> Optional[str]:
348
+ """Collect class-of-vehicle (COV) tokens (e.g. ``MCWG``, ``LMV``).
349
+
350
+ Returns the COV value adjacent to a COV label; falls back to scanning for
351
+ canonical COV tokens anywhere on the card (multiple classes joined).
352
+ """
353
+ label = find_visual_field(regions, _COV_LABELS)
354
+ if label is not None:
355
+ value = find_visual_value_right(regions, label) or find_visual_value_near(
356
+ regions, label
357
+ )
358
+ if value is not None:
359
+ tokens = _extract_cov_tokens(value.text)
360
+ if tokens:
361
+ return ", ".join(tokens)
362
+
363
+ # Fallback: scan the whole card for canonical COV tokens.
364
+ found: list[str] = []
365
+ for region in regions:
366
+ for tok in _extract_cov_tokens(region.text):
367
+ if tok not in found:
368
+ found.append(tok)
369
+ return ", ".join(found) if found else None
370
+
371
+
372
+ def _extract_cov_tokens(text: str) -> list[str]:
373
+ upper = re.sub(r"\s+", " ", text.upper())
374
+ found: list[str] = []
375
+ for tok in _COV_TOKENS: # longest-first ordering in the list avoids LMV ⊂ LMV-NT
376
+ if re.search(rf"(?<![A-Z0-9-]){re.escape(tok)}(?![A-Z0-9])", upper):
377
+ if not any(tok in existing for existing in found):
378
+ found.append(tok)
379
+ return found
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # Address
384
+ # ---------------------------------------------------------------------------
385
+
386
+ def _collect_address_block(
387
+ regions: list[TextRegion], label: TextRegion
388
+ ) -> Optional[str]:
389
+ """Collect the (possibly multi-line) address block beneath/right of a label."""
390
+ label_bottom = max(p[1] for p in label.bbox)
391
+ label_top = min(p[1] for p in label.bbox)
392
+ label_left = min(p[0] for p in label.bbox)
393
+
394
+ lines: list[tuple[int, int, str]] = []
395
+
396
+ # Inline value on the same row as the label (e.g. "Address : 14 Nehru Nagar").
397
+ inline = find_visual_value_right(regions, label)
398
+ if inline is not None and not _is_other_label(inline.text):
399
+ lines.append((label_top, min(p[0] for p in inline.bbox), inline.text.strip()))
400
+
401
+ # Candidate rows below the label, left-aligned with the label column.
402
+ candidates: list[tuple[int, int, str]] = []
403
+ for region in regions:
404
+ if region is label or region is inline:
405
+ continue
406
+ top = min(p[1] for p in region.bbox)
407
+ left = min(p[0] for p in region.bbox)
408
+ # Must start below the label and within a reasonable block height.
409
+ if top <= label_bottom - 5 or top - label_bottom > 220:
410
+ continue
411
+ # Roughly left-aligned with the label column.
412
+ if abs(left - label_left) > 320:
413
+ continue
414
+ text = region.text.strip()
415
+ if len(text) < 2:
416
+ continue
417
+ candidates.append((top, left, text))
418
+
419
+ # Walk the rows top-to-bottom and stop at the first row that begins a
420
+ # *different* field (its label), so the next field's value cannot leak in.
421
+ candidates.sort(key=lambda x: (x[0], x[1]))
422
+ stop_y: Optional[int] = None
423
+ for top, _left, text in candidates:
424
+ if _is_other_label(text):
425
+ stop_y = top
426
+ break
427
+ for top, left, text in candidates:
428
+ if stop_y is not None and top >= stop_y:
429
+ continue
430
+ lines.append((top, left, text))
431
+
432
+ if not lines:
433
+ return None
434
+
435
+ lines.sort(key=lambda x: (x[0], x[1]))
436
+ seen: set[str] = set()
437
+ ordered: list[str] = []
438
+ for _, _, text in lines:
439
+ if text not in seen:
440
+ seen.add(text)
441
+ ordered.append(text)
442
+ return ", ".join(ordered[:6])
443
+
444
+
445
+ _OTHER_LABEL_HINTS = [
446
+ "DL NO",
447
+ "LICENCE NO",
448
+ "LICENSE NO",
449
+ "NAME",
450
+ "DATE OF BIRTH",
451
+ "DOB",
452
+ "DATE OF ISSUE",
453
+ "VALID",
454
+ "VALIDITY",
455
+ "BLOOD GROUP",
456
+ "CLASS OF VEHICLE",
457
+ "COV",
458
+ "SIGNATURE",
459
+ "ISSUING AUTHORITY",
460
+ "AUTHORITY",
461
+ ]
462
+
463
+
464
+ def _is_other_label(text: str) -> bool:
465
+ """True if a region reads like a *different* field label (stop the block)."""
466
+ upper = re.sub(r"[^A-Z0-9]+", " ", text.upper()).strip()
467
+ padded = f" {upper} "
468
+ return any(f" {hint} " in padded for hint in _OTHER_LABEL_HINTS)
469
+
470
+
471
+ def _find_address(regions: list[TextRegion]) -> Optional[str]:
472
+ """Collect the address block, preferring the present/current address.
473
+
474
+ Order of preference:
475
+ 1. Present / Current / Temporary address.
476
+ 2. Permanent address.
477
+ 3. Generic "Address" label.
478
+ """
479
+ for label_set in (
480
+ _PRESENT_ADDRESS_LABELS,
481
+ _PERMANENT_ADDRESS_LABELS,
482
+ _GENERIC_ADDRESS_LABELS,
483
+ ):
484
+ label = find_visual_field(regions, label_set)
485
+ if label is None:
486
+ continue
487
+ block = _collect_address_block(regions, label)
488
+ if block:
489
+ return block
490
+ return None
491
+
492
+
493
+ # ---------------------------------------------------------------------------
494
+ # Public API
495
+ # ---------------------------------------------------------------------------
496
+
497
+ def extract_driving_licence(regions: list[TextRegion]) -> DrivingLicenceFields:
498
+ fields = DrivingLicenceFields()
499
+ fields.dl_number = _find_dl_number(regions)
500
+ fields.name = _find_name(regions)
501
+ fields.relation_name = _find_relation(regions)
502
+ fields.date_of_birth = _labelled_date(regions, _DOB_LABELS)
503
+ fields.issue_date = _labelled_date(regions, _DOI_LABELS)
504
+ primary_validity, transport_validity = _find_validity_date(regions)
505
+ fields.validity_date = primary_validity
506
+ fields.validity_date_transport = transport_validity
507
+ fields.blood_group = _find_blood_group(regions)
508
+ fields.class_of_vehicle = _find_class_of_vehicle(regions)
509
+ fields.address = _find_address(regions)
510
+ return fields
@@ -0,0 +1,54 @@
1
+ """Read an incomplete EAD once without contrast enhancement; expose conflicts."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ import numpy as np
7
+
8
+ from .card_recovery import _read_box
9
+ from .structured_documents import StructuredExtraction, _finish, extract_structured_document
10
+
11
+
12
+ def recover_ead_fields(unenhanced_image, initial: StructuredExtraction, ocr, recognize_line) -> StructuredExtraction | None:
13
+ if (unenhanced_image is None or initial.document_type != 'us_ead'
14
+ or initial.issuing_country != 'US' or initial.complete or len(initial.fields) < 3
15
+ or 'DOCUMENT_TYPE_NOT_CONFIRMED' in initial.errors):
16
+ return None
17
+ regions = ocr(unenhanced_image)
18
+ fresh = extract_structured_document(regions)
19
+ if fresh is None or fresh.document_type != 'us_ead' or fresh.issuing_country != 'US':
20
+ return None
21
+ if 'expiry_date' in fresh.missing_required_fields:
22
+ # An observed truncated label locates the pixels; only a fresh read of
23
+ # the complete label can associate a date with expiry.
24
+ anchor = next((row for row in regions if row.confidence >= .9 and len(row.bbox) == 4
25
+ and re.fullmatch(r'card\s+expir(?:e|es)?[.:]?', row.text.strip(), re.I)), None)
26
+ if anchor:
27
+ points = np.asarray(anchor.bbox, np.float32)
28
+ if np.isfinite(points).all():
29
+ tl, tr, br, bl = points
30
+ along = tr - tl
31
+ width, height = int(np.linalg.norm(along) * 1.2), int(np.linalg.norm(bl - tl))
32
+ read = _read_box(unenhanced_image, [tl - .1 * along, tr + .1 * along,
33
+ br + .1 * along, bl - .1 * along], width, height, recognize_line)
34
+ if (len(read) == 1 and read[0].confidence >= .9
35
+ and re.fullmatch(r'card\s+expires[.:]?', read[0].text.strip(), re.I)):
36
+ regions = regions + read
37
+ fresh = extract_structured_document(regions)
38
+ if (fresh is None or fresh.errors or set(initial.fields) - set(fresh.fields)
39
+ or (len(fresh.fields) <= len(initial.fields)
40
+ and len(fresh.missing_required_fields) >= len(initial.missing_required_fields))):
41
+ return None
42
+ added = fresh.fields.keys() - initial.fields.keys()
43
+ if any(not fresh.field_evidence.get(key)
44
+ or min(item['confidence'] for item in fresh.field_evidence[key]) < .9 for key in added):
45
+ return None
46
+ conflicts = [key for key, value in initial.fields.items()
47
+ if value not in (None, '') and fresh.fields[key] != value]
48
+ for key in conflicts:
49
+ fresh.field_evidence.setdefault(key, []).extend(initial.field_evidence.get(key, []))
50
+ fresh.errors.append('OCR_VIEW_CONFLICT_' + key.upper())
51
+ fresh.errors.extend(initial.errors)
52
+ fresh.checks['ocr_view_agreement'] = not conflicts
53
+ fresh.warnings.append('UNENHANCED_OCR_RECOVERY')
54
+ return _finish(fresh)
@@ -0,0 +1,54 @@
1
+ """Conservative links from returned values to source text; never invent boxes."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import fields
6
+
7
+ from .ocr_engine import TextRegion
8
+
9
+
10
+ def camel(name: str) -> str:
11
+ parts = name.split('_')
12
+ return parts[0] + ''.join(p.title() for p in parts[1:])
13
+
14
+
15
+ def document_fields(result) -> dict:
16
+ if result.document_fields is not None:
17
+ return result.document_fields
18
+ output = {}
19
+ for attr in ('fields', 'back_page_fields', 'pan_fields', 'aadhaar_fields',
20
+ 'driving_licence_fields', 'voter_id_fields', 'nrega_job_card_fields', 'npr_letter_fields'):
21
+ block = getattr(result, attr, None)
22
+ if block is not None:
23
+ output.update({f.name: getattr(block, f.name) for f in fields(block)})
24
+ return output
25
+
26
+
27
+ def _norm(value: str) -> str:
28
+ return ''.join(c for c in value.casefold() if c.isalnum())
29
+
30
+
31
+ def field_evidence(values: dict, regions: list[TextRegion], *, source: str = 'ocr', mrz_raw=None) -> dict:
32
+ evidence = {}
33
+ mrz_fields = {'surname', 'given_names', 'full_name', 'passport_number', 'nationality',
34
+ 'date_of_birth', 'sex', 'expiry_date', 'country_code'}
35
+ for key, value in values.items():
36
+ if not isinstance(value, (str, int)) or not str(value).strip():
37
+ continue
38
+ needle = _norm(str(value))
39
+ hits = []
40
+ for region in regions:
41
+ is_mrz = bool(mrz_raw and region.text.replace(' ', '') in mrz_raw)
42
+ date = re.fullmatch(r'(\d{4})-(\d{2})-(\d{2})', str(value))
43
+ match = needle in _norm(region.text)
44
+ if date:
45
+ y, m, d = date.groups()
46
+ match |= any(x in _norm(region.text) for x in (d+m+y, m+d+y))
47
+ if is_mrz and key in mrz_fields:
48
+ match = True
49
+ if match and (len(needle) > 1 or is_mrz):
50
+ hits.append({'text': region.text, 'bbox': region.bbox,
51
+ 'source': 'mrz' if is_mrz else source, 'confidence': region.confidence})
52
+ if hits:
53
+ evidence[key] = hits
54
+ return evidence
@@ -0,0 +1,61 @@
1
+ """Read a boxed W-9 TIN when text detection splits or omits individual digits."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import cv2
6
+ import numpy as np
7
+
8
+ from .ocr_engine import TextRegion
9
+ from .structured_documents import _detect_text_type, extract_structured_document
10
+
11
+
12
+ def recover_form_fields(image, regions: list[TextRegion], recognize_line) -> list[TextRegion]:
13
+ if _detect_text_type(regions)[0] != 'us_w9':
14
+ return regions
15
+ parsed = extract_structured_document(regions)
16
+ if parsed and parsed.fields.get('taxpayer_id'):
17
+ return regions
18
+ output = list(regions)
19
+ # At most two reads for each of the two labelled TIN boxes. The original
20
+ # full image determines evidence coordinates; no expected digits are used.
21
+ for label in ('social security number', 'employer identification number'):
22
+ anchor = next((r for r in regions if r.text.strip().lower() == label), None)
23
+ if not anchor or len(anchor.bbox) != 4:
24
+ continue
25
+ points = np.asarray(anchor.bbox)
26
+ left, top = points.min(axis=0)
27
+ right, bottom = points.max(axis=0)
28
+ height = bottom - top
29
+ if height < 8 or right - left < height * 4:
30
+ continue
31
+ x1, y1 = max(0, int(left - 4)), max(0, int(bottom))
32
+ x2, y2 = min(image.shape[1], int(left + height * 18)), min(image.shape[0], int(bottom + height * 2.8))
33
+ if x2 <= x1 or y2 <= y1:
34
+ continue
35
+ crop = image[y1:y2, x1:x2]
36
+ for row in recognize_line(crop):
37
+ # Box borders and printed hyphens can be recognized as separators.
38
+ # Letters/guessed substitutions and non-nine-digit reads fail closed.
39
+ if row.confidence < .8 or not re.fullmatch(r'[0-9\s|\[\]{}()_.:;\-]+', row.text):
40
+ continue
41
+ digits = re.sub(r'\D', '', row.text)
42
+ if len(digits) != 9:
43
+ continue
44
+ if row.confidence < .9:
45
+ # Printed box borders lower the whole-string confidence. For
46
+ # an uncertain read, require all nine digits to agree after
47
+ # removing long straight ruling lines and reading once more.
48
+ gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
49
+ ink = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
50
+ vertical = cv2.morphologyEx(ink, cv2.MORPH_OPEN, np.ones((int(height * 1.6), 1), np.uint8))
51
+ horizontal = cv2.morphologyEx(ink, cv2.MORPH_OPEN, np.ones((1, int(height * 3)), np.uint8))
52
+ cleaned = crop.copy()
53
+ cleaned[cv2.bitwise_or(vertical, horizontal) > 0] = 255
54
+ confirmation = recognize_line(cleaned)
55
+ if (len(confirmation) != 1 or confirmation[0].confidence < .8
56
+ or not re.fullmatch(r'[0-9\s|\[\]{}()_.:;\-]+', confirmation[0].text)
57
+ or re.sub(r'\D', '', confirmation[0].text) != digits):
58
+ continue
59
+ output.append(TextRegion(label + ': ' + digits,
60
+ [[x1, y1], [x2, y1], [x2, y2], [x1, y2]], row.confidence))
61
+ return output