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,244 @@
1
+ """Bounded pixel rereads for an identified, incomplete US passport-card front."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ import cv2
7
+ import numpy as np
8
+
9
+ from .ocr_engine import TextRegion
10
+ from .structured_documents import _us_date, extract_structured_document
11
+
12
+ _FIELD_LABELS = {
13
+ 'document_number': re.compile(r'^(?:passport\s*(?:card[\s\'’]*)?(?:number|no\.?)|card (?:number|no\.?))(?![A-Za-z])', re.I),
14
+ 'expiry_date': re.compile(r'^(?:card expires|expires(?:\s*on)?|date of expiry|expiration date|expiry date|EXP)(?![A-Za-z])', re.I),
15
+ }
16
+
17
+ _TRUNCATED_DATE = re.compile(r'(?:\d{1,2}\s*[A-Za-z]{3,9}\s*\d{1,3}|\d{1,2}[-/]\d{1,2}[-/]\d{1,3})')
18
+
19
+
20
+ def _read_box(image, corners, width, height, reader) -> list[TextRegion]:
21
+ corners = np.asarray(corners, np.float32)
22
+ if (not 10 <= width <= 1600 or not 8 <= height <= 1200
23
+ or corners.shape != (4, 2) or not np.isfinite(corners).all()):
24
+ return []
25
+ target = np.float32([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]])
26
+ matrix = cv2.getPerspectiveTransform(corners, target)
27
+ if abs(np.linalg.det(matrix)) < 1e-12:
28
+ return []
29
+ crop = cv2.warpPerspective(image, matrix, (width, height), borderValue=(255, 255, 255))
30
+ inverse = np.linalg.inv(matrix)
31
+ result = []
32
+ for row in reader(crop):
33
+ points = np.asarray(row.bbox, np.float32)
34
+ if points.shape == (4, 2) and np.isfinite(points).all() and np.isfinite(row.confidence):
35
+ original = cv2.perspectiveTransform(points[None], inverse)[0]
36
+ result.append(TextRegion(row.text, original.round().astype(int).tolist(), row.confidence))
37
+ return result
38
+
39
+
40
+ def _card_text_box(image, regions):
41
+ """Follow a confident issuer heading and nearby text with the same angle."""
42
+ headers = [row for row in regions if row.confidence >= .9 and len(row.bbox) == 4
43
+ and re.fullmatch(r'UNITED STATES(?: OF AMERICA)?', row.text.strip(), re.I)]
44
+ for header in sorted(headers, key=lambda row: len(row.text), reverse=True):
45
+ points = np.asarray(header.bbox, np.float32)
46
+ if not np.isfinite(points).all():
47
+ continue
48
+ origin = points[0]
49
+ along = points[1] - origin
50
+ width = np.linalg.norm(along)
51
+ if width < 200:
52
+ continue
53
+ along /= width
54
+ axes = np.array([along, [-along[1], along[0]]])
55
+ cluster = []
56
+ for row in regions:
57
+ points = np.asarray(row.bbox, np.float32)
58
+ if points.shape != (4, 2) or not np.isfinite(points).all():
59
+ continue
60
+ direction = points[1] - points[0]
61
+ length = np.linalg.norm(direction)
62
+ local = (points - origin) @ axes.T
63
+ if (length > 0 and direction @ along / length >= .96
64
+ and local[:, 0].min() > -.15 * width and local[:, 0].max() < 1.2 * width
65
+ and local[:, 1].min() > -.08 * width and local[:, 1].max() < .85 * width):
66
+ cluster.append(local)
67
+ if len(cluster) < 5:
68
+ continue
69
+ points = np.vstack(cluster)
70
+ left, top = points.min(axis=0) - [width * .07, width * .03]
71
+ right, bottom = points.max(axis=0) + [width * .07, width * .05]
72
+ crop_width, crop_height = int(right - left), int(bottom - top)
73
+ h, w = image.shape[:2]
74
+ if not (crop_width <= 1600 and crop_height <= 1200
75
+ and 0 < crop_width * crop_height < w * h * .7):
76
+ continue
77
+ corners = np.array([[left, top], [right, top], [right, bottom], [left, bottom]]) @ axes + origin
78
+ if (corners[:, 0].min() < 0 or corners[:, 1].min() < 0
79
+ or corners[:, 0].max() >= w or corners[:, 1].max() >= h):
80
+ continue
81
+ return corners, crop_width, crop_height
82
+ return None
83
+
84
+
85
+ def _expiry_date_region(regions):
86
+ """Select one truncated date directly below one confident expiry label."""
87
+ labels = [row for row in regions if row.confidence >= .9
88
+ and _FIELD_LABELS['expiry_date'].fullmatch(row.text.strip())]
89
+ if len(labels) != 1:
90
+ return None
91
+ label = np.asarray(labels[0].bbox, np.float32)
92
+ if label.shape != (4, 2) or not np.isfinite(label).all():
93
+ return None
94
+ along = label[1] - label[0]
95
+ width = np.linalg.norm(along)
96
+ if width < 20:
97
+ return None
98
+ along /= width
99
+ axes = np.array([along, [-along[1], along[0]]])
100
+ local_label = (label - label[0]) @ axes.T
101
+ height = np.ptp(local_label[:, 1])
102
+ if height < 8:
103
+ return None
104
+ candidates = []
105
+ for row in regions:
106
+ if not _TRUNCATED_DATE.fullmatch(row.text.strip()) or _us_date(row.text) is not None:
107
+ continue
108
+ points = np.asarray(row.bbox, np.float32)
109
+ if points.shape != (4, 2) or not np.isfinite(points).all():
110
+ continue
111
+ direction = points[1] - points[0]
112
+ length = np.linalg.norm(direction)
113
+ local = (points - label[0]) @ axes.T
114
+ left, top = local.min(axis=0)
115
+ right, bottom = local.max(axis=0)
116
+ # Compare the top-edge centers. Taking the extreme corner exaggerates
117
+ # overlap when a long date and its short label have slightly different
118
+ # detected angles despite sharing the same printed baseline.
119
+ gap = local[:2, 1].mean() - local_label[:, 1].max()
120
+ if (length > 0 and direction @ along / length >= .98
121
+ and abs(left) <= max(20, height * 2)
122
+ and -.25 * height <= gap <= 2.5 * height
123
+ and .65 * height <= bottom - top <= 2.5 * height
124
+ and 0 < right - left <= width * 3):
125
+ candidates.append(row)
126
+ return candidates[0] if len(candidates) == 1 else None
127
+
128
+
129
+ def _read_expiry_date(image, regions, recognize_line, unenhanced_image=None):
130
+ """At most two direct reads, with agreement required for denoise recovery."""
131
+ anchor = _expiry_date_region(regions)
132
+ if anchor is None:
133
+ return []
134
+ tl, tr, br, bl = np.asarray(anchor.bbox, np.float32)
135
+ along = tr - tl
136
+ corners = np.array([tl - .05 * along, tr + .05 * along,
137
+ br + .05 * along, bl - .05 * along])
138
+ h, w = image.shape[:2]
139
+ if (corners[:, 0].min() < 0 or corners[:, 1].min() < 0
140
+ or corners[:, 0].max() >= w or corners[:, 1].max() >= h):
141
+ return []
142
+ width, height = int(np.linalg.norm(along) * 1.1), int(np.linalg.norm(bl - tl))
143
+ prefix = re.sub(r'[\s/-]', '', anchor.text).upper()
144
+
145
+ def valid(read):
146
+ # Preserve all characters already observed in the truncated date. The
147
+ # missing suffix must come from pixels and parse as a complete date.
148
+ return (len(read) == 1 and _us_date(read[0].text) is not None
149
+ and re.sub(r'[\s/-]', '', read[0].text).upper().startswith(prefix))
150
+
151
+ direct = _read_box(image, corners, width, height, recognize_line)
152
+ if not valid(direct):
153
+ # A security background can obscure the final digit after CLAHE. Keep
154
+ # the exact observed prefix, and reread the same coordinates once in
155
+ # the original-color plane. Never run both this and denoise recovery.
156
+ if (len(direct) == 1 and direct[0].confidence >= .9
157
+ and re.sub(r'[\s/-]', '', direct[0].text).upper() == prefix
158
+ and unenhanced_image is not None and unenhanced_image is not image
159
+ and unenhanced_image.shape == image.shape):
160
+ original = _read_box(unenhanced_image, corners, width, height, recognize_line)
161
+ if valid(original) and original[0].confidence >= .9:
162
+ return original
163
+ return []
164
+ if direct[0].confidence >= .9:
165
+ return direct
166
+ if direct[0].confidence < .8:
167
+ return []
168
+ denoised = _read_box(image, corners, width, height,
169
+ lambda crop: recognize_line(cv2.medianBlur(crop, 3)))
170
+ if (valid(denoised) and denoised[0].confidence >= .9
171
+ and _us_date(denoised[0].text) == _us_date(direct[0].text)):
172
+ return denoised
173
+ return []
174
+
175
+
176
+ def recover_card_fields(image, regions: list[TextRegion], ocr, recognize_line, *,
177
+ unenhanced_image=None) -> list[TextRegion]:
178
+ before = extract_structured_document(regions)
179
+ if (before is None or before.document_type != 'passport_card' or before.issuing_country != 'US'
180
+ or before.complete or before.errors or len(before.fields) < 3
181
+ or not {'document_number', 'expiry_date'}.intersection(before.missing_required_fields)):
182
+ return regions
183
+
184
+ def acceptable(after):
185
+ return bool(after and after.complete and after.issuing_country == before.issuing_country
186
+ and all(after.fields.get(key) == value for key, value in before.fields.items())
187
+ and all(after.field_evidence.get(key)
188
+ and min(item['confidence'] for item in after.field_evidence[key]) >= .9
189
+ for key in after.fields.keys() - before.fields.keys()))
190
+
191
+ output = list(regions)
192
+ if before.missing_required_fields == ['expiry_date']:
193
+ # No detector rerun: one observed date box, at most two direct reads.
194
+ # Cards missing other fields keep the existing number/cluster budget.
195
+ date_read = _read_expiry_date(image, regions, recognize_line, unenhanced_image)
196
+ if date_read:
197
+ candidate = output + date_read
198
+ if acceptable(extract_structured_document(candidate)):
199
+ return candidate
200
+ if 'document_number' not in before.fields:
201
+ # Locate an observed card-number label fragment. The fresh pixel read
202
+ # must contain the exact label; its spelling is never patched in code.
203
+ anchor = next((row for row in regions if row.confidence >= .9 and len(row.bbox) == 4
204
+ and re.fullmatch(r'.{1,24}\bcard\s+(?:number|no\.?)', row.text.strip(), re.I)
205
+ and not re.match(r'passport\s*card', row.text.strip(), re.I)), None)
206
+ if anchor:
207
+ points = np.asarray(anchor.bbox, np.float32)
208
+ if np.isfinite(points).all():
209
+ tl, tr, br, bl = points
210
+ along = tr - tl
211
+ width, height = int(np.linalg.norm(along)), int(np.linalg.norm(bl - tl))
212
+ read = _read_box(image, [tl - .02 * along, tr + .02 * along,
213
+ br + .02 * along, bl - .02 * along], width, height, recognize_line)
214
+ if (len(read) == 1 and read[0].confidence >= .9
215
+ and re.fullmatch(r'passport\s*card\s+(?:number|no\.?)', read[0].text.strip(), re.I)):
216
+ output.extend(read)
217
+ current = extract_structured_document(output)
218
+ if current is None:
219
+ return regions
220
+ if current.complete:
221
+ return output if acceptable(current) else regions
222
+ geometry = _card_text_box(image, regions)
223
+ if geometry is None:
224
+ return regions
225
+ reread = _read_box(image, *geometry, ocr)
226
+ independently_read = extract_structured_document(reread)
227
+ if independently_read is None or independently_read.document_type != before.document_type or independently_read.errors:
228
+ return regions
229
+ if any(key in independently_read.fields and independently_read.fields[key] != value
230
+ for key, value in before.fields.items()):
231
+ return regions
232
+ # Keep unrelated reread text (especially large specimen watermarks) out of
233
+ # existing field association. Admit only missing labels and their values.
234
+ additions = []
235
+ for key, label in _FIELD_LABELS.items():
236
+ if key in current.fields or key not in independently_read.fields:
237
+ continue
238
+ evidence_boxes = [item['bbox'] for item in independently_read.field_evidence.get(key, [])]
239
+ additions.extend(row for row in reread if row.confidence >= .9
240
+ and (label.match(row.text.strip()) or row.bbox in evidence_boxes))
241
+ candidate = output + additions
242
+ after = extract_structured_document(candidate)
243
+ # Every newly admitted field must have high-confidence observed evidence.
244
+ return candidate if acceptable(after) else regions
@@ -0,0 +1,82 @@
1
+ """Batch independent documents, or reconcile explicitly grouped document pages."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+
6
+ from .document_input import MAX_PAGES, input_bytes, pdf_pages
7
+ from .document_registry import validate_document_hint
8
+ from .pipeline import scan, _scan_page, DocumentScanResult
9
+ from .preprocessor import PreprocessResult, ImageQualityError
10
+
11
+
12
+ def scan_batch(images, **options) -> dict:
13
+ if not 1 <= len(images) <= MAX_PAGES:
14
+ raise ValueError('INVALID_BATCH_SIZE')
15
+ results = [scan(data, **options).to_dict() for data in images]
16
+ successes = sum(r['status'] == 'success' for r in results)
17
+ return {'status': 'success' if successes == len(results) else 'partial' if successes else 'failure',
18
+ 'results': results, 'errors': []}
19
+
20
+
21
+ def scan_document(images, **options) -> dict:
22
+ """Merge same-family pages only; disagreements require human review.
23
+
24
+ Independent records belong in scan_batch. A conflicting field is omitted
25
+ from the merged fields, with all page values retained in conflicts/results.
26
+ """
27
+ if not 1 <= len(images) <= MAX_PAGES:
28
+ raise ValueError('INVALID_BATCH_SIZE')
29
+ document_type, country = validate_document_hint(options.get('document_type'), options.get('country'))
30
+ results = []
31
+ for data in images:
32
+ data = input_bytes(data)
33
+ if data.startswith(b'%PDF-'):
34
+ try:
35
+ pages = pdf_pages(data)
36
+ if len(results) + len(pages) > MAX_PAGES:
37
+ raise ValueError('TOO_MANY_PAGES')
38
+ for page in pages:
39
+ result = _scan_page(PreprocessResult(page.image), time.monotonic(), document_type,
40
+ country, True, native=page.regions)
41
+ results.append(result.to_dict())
42
+ except ImageQualityError as exc:
43
+ results.append(DocumentScanResult('failure', 'unknown', 'unknown', 0, errors=[str(exc)]).to_dict())
44
+ else:
45
+ results.append(scan(data, document_type=document_type, country=country, include_evidence=True).to_dict())
46
+ if len(results) > MAX_PAGES:
47
+ raise ValueError('TOO_MANY_PAGES')
48
+ return reconcile_pages(results, include_evidence=options.get('include_evidence', False))
49
+
50
+
51
+ def reconcile_pages(results: list[dict], *, include_evidence: bool = True) -> dict:
52
+ values = {}
53
+ for page, result in enumerate(results, 1):
54
+ for key, value in result.get('documentFields', {}).items():
55
+ if value is not None and value != '' and value != []:
56
+ values.setdefault(key, []).append({'page': page, 'value': value})
57
+ if result['documentType'] != 'unknown':
58
+ values.setdefault('documentType', []).append({'page': page, 'value': result['documentType']})
59
+ if result.get('issuingCountry'):
60
+ values.setdefault('issuingCountry', []).append({'page': page, 'value': result['issuingCountry']})
61
+ for items in result.get('fieldEvidence', {}).values():
62
+ for item in items:
63
+ item['page'] = page
64
+ if not include_evidence:
65
+ result.pop('fieldEvidence', None)
66
+ result.pop('imageSize', None)
67
+ merged, conflicts = {}, []
68
+ for key, entries in values.items():
69
+ first = entries[0]['value']
70
+ # Only whitespace/case differences can be merged without a domain rule.
71
+ def canonical(value):
72
+ return ' '.join(value.upper().split()) if isinstance(value, str) else value
73
+ if any(canonical(e['value']) != canonical(first) for e in entries):
74
+ conflicts.append({'field': key, 'values': entries})
75
+ elif key not in {'documentType', 'issuingCountry'}:
76
+ merged[key] = first
77
+ if any(conflict['field'] == 'documentType' for conflict in conflicts):
78
+ merged = {}
79
+ errors = ['CONFLICTING_DOCUMENT_PAGES'] if conflicts else []
80
+ successful = sum(r['status'] == 'success' for r in results)
81
+ status = 'failure' if conflicts else ('success' if successful == len(results) else 'partial' if successful else 'failure')
82
+ return {'status': status, 'results': results, 'documentFields': merged, 'conflicts': conflicts, 'errors': errors}
@@ -0,0 +1,233 @@
1
+ """
2
+ Document-type router.
3
+
4
+ Given OCR regions for a full document image, decides which supported document
5
+ type it is — passport, PAN, Aadhaar, driving licence, voter ID, NREGA job card,
6
+ or NPR letter — using a combination of keyword hints and identifier-format
7
+ signals. The pipeline uses it as the full-page KYC router and as a conservative
8
+ confirmation step when generic biodata labels make the cheap passport probe
9
+ ambiguous.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ from .ocr_engine import TextRegion
17
+ from .page_classifier import _has_mrz_like_lines, _normalise_text
18
+ from .validators import (
19
+ extract_aadhaar_number,
20
+ is_valid_aadhaar,
21
+ normalize_dl,
22
+ normalize_epic,
23
+ normalize_pan,
24
+ )
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Keyword hints (normalised: upper-case, alphanumerics only). Deliberately
28
+ # avoid generic phrases like "GOVERNMENT OF INDIA" that appear on several docs.
29
+ # ---------------------------------------------------------------------------
30
+
31
+ _PAN_KEYWORDS = ["INCOME TAX DEPARTMENT", "PERMANENT ACCOUNT NUMBER", "INCOME TAX"]
32
+ _AADHAAR_KEYWORDS = [
33
+ "AADHAAR", "AADHAR", "UNIQUE IDENTIFICATION", "UIDAI", "MERA AADHAAR",
34
+ "ENROLLMENT NO", "VID",
35
+ ]
36
+ _DL_KEYWORDS = [
37
+ "DRIVING LICENCE", "DRIVING LICENSE", "TRANSPORT DEPARTMENT",
38
+ "MOTOR VEHICLE", "FORM 7",
39
+ ]
40
+ _VOTER_KEYWORDS = [
41
+ "ELECTION COMMISSION", "ELECTORS PHOTO IDENTITY", "ELECTOR",
42
+ "ELECTION", "IDENTITY CARD",
43
+ ]
44
+ _NREGA_EXPLICIT_KEYWORDS = [
45
+ "MAHATMA GANDHI NATIONAL RURAL EMPLOYMENT GUARANTEE ACT",
46
+ "MAHATMA GANDHI NATIONAL RURAL EMPLOYMENT GUARANTEE SCHEME",
47
+ "MAHATMA GANDHI NREGA",
48
+ "MGNREGA",
49
+ ]
50
+ _NREGA_JOB_CARD_KEYWORDS = ["JOB CARD", "EMPLOYMENT CARD"]
51
+ _NREGA_RURAL_CONTEXT = [
52
+ "GRAM PANCHAYAT",
53
+ "RURAL EMPLOYMENT",
54
+ "EMPLOYMENT GUARANTEE",
55
+ "HOUSEHOLD MEMBERS WILLING TO WORK",
56
+ ]
57
+ _NPR_TITLE_KEYWORDS = ["NATIONAL POPULATION REGISTER"]
58
+ _NPR_ISSUER_KEYWORDS = ["REGISTRAR GENERAL", "CENSUS COMMISSIONER"]
59
+ _PASSPORT_KEYWORDS = ["PASSPORT", "REPUBLIC OF INDIA PASSPORT"]
60
+
61
+
62
+ @dataclass
63
+ class DocumentClassification:
64
+ document_type: str
65
+ confidence: float
66
+ reasons: list[str] = field(default_factory=list)
67
+ probe_text: list[str] = field(default_factory=list)
68
+
69
+
70
+ def _count_keyword_matches(normalised_regions: list[str], keywords: list[str]) -> int:
71
+ matched: set[str] = set()
72
+ for keyword in keywords:
73
+ norm_kw = _normalise_text(keyword)
74
+ padded = f" {norm_kw} "
75
+ for region_text in normalised_regions:
76
+ if padded in f" {region_text} ":
77
+ matched.add(norm_kw)
78
+ break
79
+ # Treat nested phrases as one piece of evidence. For example,
80
+ # ``ELECTION COMMISSION`` must not also count as an independent match for
81
+ # ``ELECTION`` merely because both strings occur in the same phrase.
82
+ independent = {
83
+ keyword
84
+ for keyword in matched
85
+ if not any(
86
+ keyword != other
87
+ and f" {keyword} " in f" {other} "
88
+ for other in matched
89
+ )
90
+ }
91
+ return len(independent)
92
+
93
+
94
+ def _any_token(regions: list[TextRegion], normalizer) -> bool:
95
+ return any(normalizer(r.text) is not None for r in regions if r.text.strip())
96
+
97
+
98
+ def classify_document(regions: list[TextRegion]) -> DocumentClassification:
99
+ """Classify a full-page document into one of the supported types."""
100
+ probe_text = [r.text.strip() for r in regions if r.text.strip()][:15]
101
+ normalised_regions = [_normalise_text(r.text) for r in regions if r.text.strip()]
102
+ joined_raw = " ".join(r.text for r in regions if r.text.strip())
103
+ joined_normalised = _normalise_text(joined_raw)
104
+
105
+ # --- per-type evidence ---
106
+ pan_kw = _count_keyword_matches(normalised_regions, _PAN_KEYWORDS)
107
+ aadhaar_kw = _count_keyword_matches(normalised_regions, _AADHAAR_KEYWORDS)
108
+ dl_kw = _count_keyword_matches(normalised_regions, _DL_KEYWORDS)
109
+ voter_kw = _count_keyword_matches(normalised_regions, _VOTER_KEYWORDS)
110
+ nrega_explicit_kw = _count_keyword_matches(
111
+ normalised_regions, _NREGA_EXPLICIT_KEYWORDS
112
+ )
113
+ nrega_explicit_kw = max(
114
+ nrega_explicit_kw,
115
+ _count_keyword_matches([joined_normalised], _NREGA_EXPLICIT_KEYWORDS),
116
+ )
117
+ nrega_job_card_kw = _count_keyword_matches(
118
+ normalised_regions, _NREGA_JOB_CARD_KEYWORDS
119
+ )
120
+ nrega_context_kw = _count_keyword_matches(
121
+ normalised_regions, _NREGA_RURAL_CONTEXT
122
+ )
123
+ npr_title_kw = _count_keyword_matches(normalised_regions, _NPR_TITLE_KEYWORDS)
124
+ npr_title_kw = max(
125
+ npr_title_kw,
126
+ _count_keyword_matches([joined_normalised], _NPR_TITLE_KEYWORDS),
127
+ )
128
+ npr_issuer_kw = _count_keyword_matches(normalised_regions, _NPR_ISSUER_KEYWORDS)
129
+ passport_kw = _count_keyword_matches(normalised_regions, _PASSPORT_KEYWORDS)
130
+
131
+ nrega_hindi_titles = (
132
+ "राष्ट्रीय ग्रामीण रोजगार गारंटी",
133
+ "महात्मा गांधी राष्ट्रीय ग्रामीण रोजगार गारंटी",
134
+ )
135
+ has_nrega_hindi_title = any(
136
+ title in joined_raw for title in nrega_hindi_titles
137
+ )
138
+ has_nrega_hindi_card_context = (
139
+ "जॉब कार्ड" in joined_raw
140
+ and any(
141
+ phrase in joined_raw
142
+ for phrase in ("ग्राम पंचायत", "रोजगार गारंटी")
143
+ )
144
+ )
145
+ has_nrega_acronym = any(
146
+ f" NREGA " in f" {region_text} " for region_text in normalised_regions
147
+ )
148
+ has_npr_hindi_title = (
149
+ "राष्ट्रीय जनसंख्या रजिस्टर" in joined_raw
150
+ )
151
+ has_npr_acronym = any(
152
+ f" NPR " in f" {region_text} " for region_text in normalised_regions
153
+ )
154
+
155
+ has_pan = _any_token(regions, normalize_pan)
156
+ aadhaar_number = extract_aadhaar_number(joined_raw)
157
+ aadhaar_checksum_ok = is_valid_aadhaar(joined_raw)
158
+ has_dl = _any_token(regions, normalize_dl)
159
+ has_epic = _any_token(regions, normalize_epic)
160
+ has_mrz = _has_mrz_like_lines(regions)
161
+
162
+ scores: dict[str, float] = {}
163
+ reasons: dict[str, list[str]] = {}
164
+
165
+ def add(doc: str, score: float, *why: str):
166
+ scores[doc] = scores.get(doc, 0.0) + score
167
+ reasons.setdefault(doc, []).extend(why)
168
+
169
+ # Keyword weight: 2 per distinct phrase. Format/identifier weight: 3.
170
+ if pan_kw:
171
+ add("pan", 2 * pan_kw, f"PAN_KEYWORDS_{pan_kw}")
172
+ if has_pan:
173
+ add("pan", 3, "PAN_FORMAT")
174
+
175
+ if aadhaar_kw:
176
+ add("aadhaar", 2 * aadhaar_kw, f"AADHAAR_KEYWORDS_{aadhaar_kw}")
177
+ if aadhaar_number:
178
+ add("aadhaar", 3, "AADHAAR_NUMBER")
179
+ if aadhaar_checksum_ok:
180
+ add("aadhaar", 2, "AADHAAR_CHECKSUM_VALID")
181
+
182
+ if dl_kw:
183
+ add("driving_licence", 2 * dl_kw, f"DL_KEYWORDS_{dl_kw}")
184
+ if has_dl:
185
+ add("driving_licence", 3, "DL_FORMAT")
186
+
187
+ if voter_kw:
188
+ add("voter_id", 2 * voter_kw, f"VOTER_KEYWORDS_{voter_kw}")
189
+ if has_epic:
190
+ add("voter_id", 3, "EPIC_FORMAT")
191
+
192
+ # NREGA cards do not have one public, nationally check-summed identifier
193
+ # shape. Require an explicit scheme title/acronym, or "Job Card" together
194
+ # with rural-employment context. A generic employee "job card" is not enough.
195
+ if nrega_explicit_kw or has_nrega_hindi_title:
196
+ add(
197
+ "nrega_job_card",
198
+ 9 + min(nrega_explicit_kw, 2),
199
+ "NREGA_EXPLICIT_TITLE",
200
+ )
201
+ elif (
202
+ (has_nrega_acronym and (nrega_job_card_kw or nrega_context_kw))
203
+ or (nrega_job_card_kw and nrega_context_kw)
204
+ or has_nrega_hindi_card_context
205
+ ):
206
+ add("nrega_job_card", 8, "NREGA_JOB_CARD_CONTEXT")
207
+
208
+ # "NPR" is also used by unrelated organisations. Accept the acronym only
209
+ # alongside the issuing authority; the full English/Hindi title is strong
210
+ # enough by itself.
211
+ if npr_title_kw or has_npr_hindi_title:
212
+ add("npr_letter", 9, "NPR_EXPLICIT_TITLE")
213
+ elif has_npr_acronym and npr_issuer_kw:
214
+ add("npr_letter", 8, "NPR_ISSUER_CONTEXT")
215
+
216
+ if passport_kw:
217
+ add("passport", 2 * passport_kw, f"PASSPORT_KEYWORDS_{passport_kw}")
218
+ if has_mrz:
219
+ add("passport", 4, "MRZ_DETECTED")
220
+
221
+ if not scores:
222
+ return DocumentClassification("unknown", 0.2, ["NO_DOCUMENT_HINTS"], probe_text)
223
+
224
+ best_doc = max(scores, key=lambda d: scores[d])
225
+ best_score = scores[best_doc]
226
+
227
+ confidence = round(min(0.5 + 0.12 * best_score, 0.98), 3)
228
+ return DocumentClassification(
229
+ document_type=best_doc,
230
+ confidence=confidence,
231
+ reasons=reasons.get(best_doc, []),
232
+ probe_text=probe_text,
233
+ )