document-ocr 0.0.0-stage → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +260 -2
- package/dist/index.cjs +659 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +293 -0
- package/dist/index.d.ts +293 -0
- package/dist/index.js +616 -0
- package/dist/index.js.map +1 -0
- package/hosting/.dockerignore +17 -0
- package/hosting/.gcloudignore +14 -0
- package/hosting/FEATURES.md +172 -0
- package/hosting/HOSTING.md +268 -0
- package/hosting/LICENSE +21 -0
- package/hosting/README.md +369 -0
- package/hosting/core/__init__.py +0 -0
- package/hosting/core/aadhaar_extractor.py +298 -0
- package/hosting/core/back_page_extractor.py +492 -0
- package/hosting/core/barcodes.py +125 -0
- package/hosting/core/card_recovery.py +244 -0
- package/hosting/core/document_bundle.py +82 -0
- package/hosting/core/document_classifier.py +233 -0
- package/hosting/core/document_input.py +135 -0
- package/hosting/core/document_registry.py +143 -0
- package/hosting/core/driving_licence_extractor.py +510 -0
- package/hosting/core/ead_recovery.py +54 -0
- package/hosting/core/evidence.py +54 -0
- package/hosting/core/form_recovery.py +61 -0
- package/hosting/core/jobs.py +232 -0
- package/hosting/core/kyc_ocr.py +132 -0
- package/hosting/core/kyc_validation.py +411 -0
- package/hosting/core/model_setup.py +13 -0
- package/hosting/core/mrz_parser.py +336 -0
- package/hosting/core/mrz_recovery.py +193 -0
- package/hosting/core/npr_extractor.py +410 -0
- package/hosting/core/nrega_extractor.py +827 -0
- package/hosting/core/ocr_engine.py +207 -0
- package/hosting/core/page_classifier.py +137 -0
- package/hosting/core/pan_extractor.py +183 -0
- package/hosting/core/pipeline.py +712 -0
- package/hosting/core/preprocessor.py +297 -0
- package/hosting/core/recovery_budget.py +67 -0
- package/hosting/core/redaction.py +48 -0
- package/hosting/core/review.html +244 -0
- package/hosting/core/structured_documents.py +422 -0
- package/hosting/core/travel_mrz.py +164 -0
- package/hosting/core/travel_recovery.py +187 -0
- package/hosting/core/validator.py +396 -0
- package/hosting/core/validators.py +179 -0
- package/hosting/core/voter_id_extractor.py +363 -0
- package/hosting/deploy/cloudflare/deploy.sh +44 -0
- package/hosting/deploy/cloudflare/package-lock.json +3088 -0
- package/hosting/deploy/cloudflare/package.json +29 -0
- package/hosting/deploy/cloudflare/src/gateway.ts +42 -0
- package/hosting/deploy/cloudflare/src/index.ts +21 -0
- package/hosting/deploy/cloudflare/test/gateway.test.ts +59 -0
- package/hosting/deploy/cloudflare/tsconfig.json +12 -0
- package/hosting/deploy/cloudflare/worker-configuration.d.ts +16253 -0
- package/hosting/deploy/cloudflare/wrangler.jsonc +26 -0
- package/hosting/deploy/cloudrun/cloudbuild.yaml +18 -0
- package/hosting/deploy/cloudrun/deploy.sh +71 -0
- package/hosting/deploy/cloudrun/render_service.py +47 -0
- package/hosting/deploy/common.sh +14 -0
- package/hosting/deploy/docker/Dockerfile +16 -0
- package/hosting/deploy/docker/server.py +313 -0
- package/hosting/deploy/lambda/Dockerfile.lambda +11 -0
- package/hosting/deploy/lambda/deploy.sh +22 -0
- package/hosting/deploy/lambda/handler.py +132 -0
- package/hosting/deploy/lambda/template.yaml +59 -0
- package/hosting/deploy/local/dev.sh +18 -0
- package/hosting/deploy/server/Caddyfile +5 -0
- package/hosting/deploy/server/compose.jobs.yaml +36 -0
- package/hosting/deploy/server/compose.yaml +38 -0
- package/hosting/deploy/server/deploy.sh +23 -0
- package/hosting/infra/cloudrun/main.tf +104 -0
- package/hosting/infra/cloudrun/terraform.tfvars.example +7 -0
- package/hosting/infra/cloudrun/variables.tf +55 -0
- package/hosting/infra/lambda/main.tf +63 -0
- package/hosting/infra/lambda/terraform.tfvars.example +6 -0
- package/hosting/infra/lambda/variables.tf +37 -0
- package/hosting/pyproject.toml +65 -0
- package/hosting/requirements.lock +134 -0
- package/package.json +76 -4
- package/python/core/__init__.py +0 -0
- package/python/core/aadhaar_extractor.py +298 -0
- package/python/core/back_page_extractor.py +492 -0
- package/python/core/barcodes.py +125 -0
- package/python/core/card_recovery.py +244 -0
- package/python/core/document_bundle.py +82 -0
- package/python/core/document_classifier.py +233 -0
- package/python/core/document_input.py +135 -0
- package/python/core/document_registry.py +143 -0
- package/python/core/driving_licence_extractor.py +510 -0
- package/python/core/ead_recovery.py +54 -0
- package/python/core/evidence.py +54 -0
- package/python/core/form_recovery.py +61 -0
- package/python/core/jobs.py +232 -0
- package/python/core/kyc_ocr.py +132 -0
- package/python/core/kyc_validation.py +411 -0
- package/python/core/model_setup.py +13 -0
- package/python/core/mrz_parser.py +336 -0
- package/python/core/mrz_recovery.py +193 -0
- package/python/core/npr_extractor.py +410 -0
- package/python/core/nrega_extractor.py +827 -0
- package/python/core/ocr_engine.py +207 -0
- package/python/core/page_classifier.py +137 -0
- package/python/core/pan_extractor.py +183 -0
- package/python/core/pipeline.py +712 -0
- package/python/core/preprocessor.py +297 -0
- package/python/core/recovery_budget.py +67 -0
- package/python/core/redaction.py +48 -0
- package/python/core/review.html +244 -0
- package/python/core/structured_documents.py +422 -0
- package/python/core/travel_mrz.py +164 -0
- package/python/core/travel_recovery.py +187 -0
- package/python/core/validator.py +396 -0
- package/python/core/validators.py +179 -0
- package/python/core/voter_id_extractor.py +363 -0
- package/python/pyproject.toml +34 -0
- package/python/requirements.lock +134 -0
- package/python/server.py +313 -0
- package/scripts/dococr.mjs +163 -0
- package/scripts/postinstall.mjs +173 -0
- package/scripts/sync-hosting.mjs +35 -0
- package/scripts/sync-python.sh +20 -0
|
@@ -0,0 +1,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
|
+
)
|