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,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Format and checksum validators for Indian KYC document identifiers.
|
|
3
|
+
|
|
4
|
+
Kept separate from the passport MRZ checksum (core/mrz_parser.icao_check_digit)
|
|
5
|
+
because these documents use different identifier schemes:
|
|
6
|
+
|
|
7
|
+
* PAN — fixed alphanumeric format (no checksum that is publicly verifiable)
|
|
8
|
+
* Aadhaar — 12 digits, last digit a Verhoeff checksum over the first 11
|
|
9
|
+
* EPIC — 3 letters + 7 digits (Voter ID)
|
|
10
|
+
* DL — state code + RTO + year + serial (format varies; loose check)
|
|
11
|
+
* NREGA — state-prefixed hierarchical job-card number (format only)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# PAN — Permanent Account Number
|
|
20
|
+
# Format: 5 letters + 4 digits + 1 letter, e.g. ABCDE1234F.
|
|
21
|
+
# The 4th letter encodes holder type (P=individual, C=company, ...).
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
_PAN_RE = re.compile(
|
|
25
|
+
r"(?<![A-Z0-9])(?:[A-Z][ \t-]*){5}(?:[0-9][ \t-]*){4}[A-Z](?![A-Z0-9])"
|
|
26
|
+
)
|
|
27
|
+
_PAN_HOLDER_TYPES = set("ABCFGHLJPTK")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _extract_identifier(text: str, pattern: re.Pattern[str]) -> str | None:
|
|
31
|
+
"""Extract a whole identifier, allowing OCR spaces and printed hyphens.
|
|
32
|
+
|
|
33
|
+
Matching before removing separators preserves token boundaries, so an
|
|
34
|
+
overlong identifier cannot be silently truncated into a valid one.
|
|
35
|
+
"""
|
|
36
|
+
match = pattern.search(text.upper())
|
|
37
|
+
return re.sub(r"[ \t-]", "", match.group(0)) if match else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def normalize_pan(text: str) -> str | None:
|
|
41
|
+
"""Extract a PAN-shaped token from noisy OCR text, or None."""
|
|
42
|
+
return _extract_identifier(text, _PAN_RE)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_valid_pan(text: str) -> bool:
|
|
46
|
+
pan = normalize_pan(text)
|
|
47
|
+
if pan is None:
|
|
48
|
+
return False
|
|
49
|
+
return pan[3] in _PAN_HOLDER_TYPES
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# EPIC — Voter ID (Elector's Photo Identity Card)
|
|
54
|
+
# Format: 3 letters + 7 digits, e.g. ABC1234567.
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
_EPIC_RE = re.compile(
|
|
58
|
+
r"(?<![A-Z0-9])(?:[A-Z][ \t-]*){3}(?:[0-9][ \t-]*){6}[0-9](?![A-Z0-9])"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def normalize_epic(text: str) -> str | None:
|
|
63
|
+
return _extract_identifier(text, _EPIC_RE)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_valid_epic(text: str) -> bool:
|
|
67
|
+
return normalize_epic(text) is not None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Driving Licence
|
|
72
|
+
# Format varies by state, but most follow: 2-letter state code, 2-digit RTO,
|
|
73
|
+
# optional space, then 11 digits (often YYYY + 7-digit serial), e.g.
|
|
74
|
+
# MH1220110012345, DL0420110149646, HR-06 19850034761.
|
|
75
|
+
# We accept a loose shape and surface the compact form.
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
_DL_RE = re.compile(
|
|
79
|
+
r"(?<![A-Z0-9])(?:[A-Z][ \t-]*){2}(?:[0-9][ \t-]*){12}[0-9](?![A-Z0-9])"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def normalize_dl(text: str) -> str | None:
|
|
84
|
+
return _extract_identifier(text, _DL_RE)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_valid_dl(text: str) -> bool:
|
|
88
|
+
return normalize_dl(text) is not None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# NREGA / MGNREGA job-card number
|
|
93
|
+
# State-issued identifiers vary in component widths, but the common public
|
|
94
|
+
# form is a two-letter state prefix followed by at least three numeric
|
|
95
|
+
# hierarchy components, for example RJ-27-001-002-0008147/00.
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
_NREGA_JOB_CARD_RE = re.compile(
|
|
99
|
+
r"^[A-Z]{2}-[0-9]{1,10}(?:[-/][0-9]{1,10}){2,7}$"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def normalize_nrega_job_card(text: str) -> str | None:
|
|
104
|
+
"""Return a canonical NREGA job-card number, or ``None``.
|
|
105
|
+
|
|
106
|
+
This is a conservative shape check, not an issuer lookup or authenticity
|
|
107
|
+
check. OCR character correction belongs in the extractor, where it can be
|
|
108
|
+
applied only to a labelled job-card-number candidate.
|
|
109
|
+
"""
|
|
110
|
+
compact = text.upper().strip()
|
|
111
|
+
compact = compact.replace("–", "-").replace("—", "-")
|
|
112
|
+
compact = re.sub(r"\s+", "", compact)
|
|
113
|
+
return compact if _NREGA_JOB_CARD_RE.fullmatch(compact) else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_valid_nrega_job_card(text: str) -> bool:
|
|
117
|
+
return normalize_nrega_job_card(text) is not None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Aadhaar — 12 digits, 12th digit is a Verhoeff checksum over the first 11.
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
# Verhoeff algorithm tables (dihedral group D5).
|
|
125
|
+
_VERHOEFF_D = (
|
|
126
|
+
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
|
|
127
|
+
(1, 2, 3, 4, 0, 6, 7, 8, 9, 5),
|
|
128
|
+
(2, 3, 4, 0, 1, 7, 8, 9, 5, 6),
|
|
129
|
+
(3, 4, 0, 1, 2, 8, 9, 5, 6, 7),
|
|
130
|
+
(4, 0, 1, 2, 3, 9, 5, 6, 7, 8),
|
|
131
|
+
(5, 9, 8, 7, 6, 0, 4, 3, 2, 1),
|
|
132
|
+
(6, 5, 9, 8, 7, 1, 0, 4, 3, 2),
|
|
133
|
+
(7, 6, 5, 9, 8, 2, 1, 0, 4, 3),
|
|
134
|
+
(8, 7, 6, 5, 9, 3, 2, 1, 0, 4),
|
|
135
|
+
(9, 8, 7, 6, 5, 4, 3, 2, 1, 0),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
_VERHOEFF_P = (
|
|
139
|
+
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
|
|
140
|
+
(1, 5, 7, 6, 2, 8, 3, 0, 9, 4),
|
|
141
|
+
(5, 8, 0, 3, 7, 9, 6, 1, 4, 2),
|
|
142
|
+
(8, 9, 1, 6, 0, 4, 3, 5, 2, 7),
|
|
143
|
+
(9, 4, 5, 3, 1, 2, 6, 8, 7, 0),
|
|
144
|
+
(4, 2, 8, 6, 5, 7, 3, 9, 0, 1),
|
|
145
|
+
(2, 7, 9, 3, 8, 0, 6, 4, 1, 5),
|
|
146
|
+
(7, 0, 4, 6, 9, 1, 3, 2, 5, 8),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def verhoeff_validate(number: str) -> bool:
|
|
151
|
+
"""True if the digit string passes the Verhoeff checksum (check digit included)."""
|
|
152
|
+
if not number.isascii() or not number.isdigit():
|
|
153
|
+
return False
|
|
154
|
+
c = 0
|
|
155
|
+
for i, digit in enumerate(reversed(number)):
|
|
156
|
+
c = _VERHOEFF_D[c][_VERHOEFF_P[i % 8][int(digit)]]
|
|
157
|
+
return c == 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
_AADHAAR_DIGITS_RE = re.compile(r"\b([0-9]{4})[ \t]*([0-9]{4})[ \t]*([0-9]{4})\b")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def extract_aadhaar_number(text: str) -> str | None:
|
|
164
|
+
"""Find a 12-digit Aadhaar number in text and return it grouped 'XXXX XXXX XXXX'."""
|
|
165
|
+
match = _AADHAAR_DIGITS_RE.search(text)
|
|
166
|
+
if not match:
|
|
167
|
+
return None
|
|
168
|
+
return f"{match.group(1)} {match.group(2)} {match.group(3)}"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def is_valid_aadhaar(text: str) -> bool:
|
|
172
|
+
grouped = extract_aadhaar_number(text)
|
|
173
|
+
if grouped is None:
|
|
174
|
+
return False
|
|
175
|
+
digits = grouped.replace(" ", "")
|
|
176
|
+
# Aadhaar never starts with 0 or 1.
|
|
177
|
+
if digits[0] in "01":
|
|
178
|
+
return False
|
|
179
|
+
return verhoeff_validate(digits)
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Extractor for Indian Voter ID (EPIC / Elector's Photo Identity Card) cards.
|
|
3
|
+
|
|
4
|
+
Fields: EPIC number, elector name, relation (father's/husband's/mother's) name,
|
|
5
|
+
relation type, gender, date of birth or age.
|
|
6
|
+
|
|
7
|
+
Robust against the real-world variation seen on these cards:
|
|
8
|
+
|
|
9
|
+
* Old laminated cards and new PVC cards, bilingual Hindi/English labels.
|
|
10
|
+
* EPIC number anywhere on the card (validated via core.validators.normalize_epic).
|
|
11
|
+
* "Elector's Name" / "Name" for the holder vs "Father's Name" /
|
|
12
|
+
"Husband's Name" / "Mother's Name" for the relation — the two must never
|
|
13
|
+
collapse into one another.
|
|
14
|
+
* Value laid out to the right of the label ("Name : X") or below it.
|
|
15
|
+
* Gender given as M / F / Male / Female or Hindi पुरुष / महिला, possibly only
|
|
16
|
+
as a bare value with a Hindi-only ("लिंग") label.
|
|
17
|
+
* "Date of Birth" when present, otherwise "Age as on 1.1.YYYY" / "Age".
|
|
18
|
+
|
|
19
|
+
The EPIC number format is validated in core.validators. This module only reads
|
|
20
|
+
from core.validator / core.validators helpers — it does not mutate any shared
|
|
21
|
+
state.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
from .ocr_engine import TextRegion
|
|
31
|
+
from .validator import (
|
|
32
|
+
find_label_value,
|
|
33
|
+
find_visual_field,
|
|
34
|
+
find_visual_value_near,
|
|
35
|
+
find_visual_value_right,
|
|
36
|
+
)
|
|
37
|
+
from .validators import normalize_epic
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Patterns
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
# dd/mm/yyyy (also tolerates '.' or '-' separators and 2-digit years).
|
|
44
|
+
_DATE_RE = re.compile(r"\b(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})\b")
|
|
45
|
+
|
|
46
|
+
# "Age as on 1.1.2024 : 34" / "Age 34" / "Age : 34". We deliberately anchor on
|
|
47
|
+
# the word AGE and take the *last* small integer on the fragment so the year in
|
|
48
|
+
# "as on 1.1.2024" is never mistaken for the age.
|
|
49
|
+
_AGE_LABEL_RE = re.compile(r"\bAGE\b", re.IGNORECASE)
|
|
50
|
+
_SMALL_INT_RE = re.compile(r"\b(\d{1,3})\b")
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Label vocabularies (English; Hindi handled separately/spatially)
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
# Holder-name labels. Ordered most-specific first; find_visual_field scores
|
|
57
|
+
# exact/whole-phrase matches above generic substrings, so the specific
|
|
58
|
+
# "ELECTOR'S NAME" wins over a bare "NAME" when both are present.
|
|
59
|
+
_NAME_LABELS = [
|
|
60
|
+
"ELECTOR S NAME",
|
|
61
|
+
"ELECTORS NAME",
|
|
62
|
+
"ELECTOR NAME",
|
|
63
|
+
"NAME OF ELECTOR",
|
|
64
|
+
"NAME",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
# Relation labels split by type so we can report relation_type.
|
|
68
|
+
_FATHER_LABELS = [
|
|
69
|
+
"FATHER S NAME", "FATHERS NAME", "FATHER NAME", "NAME OF FATHER", "FATHER",
|
|
70
|
+
]
|
|
71
|
+
_HUSBAND_LABELS = [
|
|
72
|
+
"HUSBAND S NAME", "HUSBANDS NAME", "HUSBAND NAME", "NAME OF HUSBAND", "HUSBAND",
|
|
73
|
+
]
|
|
74
|
+
_MOTHER_LABELS = [
|
|
75
|
+
"MOTHER S NAME", "MOTHERS NAME", "MOTHER NAME", "NAME OF MOTHER", "MOTHER",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
_GENDER_LABELS = ["SEX", "GENDER"]
|
|
79
|
+
_DOB_LABELS = ["DATE OF BIRTH", "DOB"]
|
|
80
|
+
_AGE_LABELS = ["AGE AS ON", "AGE"]
|
|
81
|
+
|
|
82
|
+
# Tokens that indicate a region is a label / header, not a value, when we fall
|
|
83
|
+
# back to scanning for a bare gender value.
|
|
84
|
+
_GENDER_VALUE_TOKENS = {"MALE", "FEMALE", "TRANSGENDER", "M", "F", "T"}
|
|
85
|
+
_HINDI_MALE = "पुरुष"
|
|
86
|
+
_HINDI_FEMALE = "महिला"
|
|
87
|
+
_HINDI_TRANS = "अन्य"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class VoterIdFields:
|
|
92
|
+
epic_number: Optional[str] = None
|
|
93
|
+
name: Optional[str] = None
|
|
94
|
+
relation_name: Optional[str] = None
|
|
95
|
+
# NEW: "father" / "husband" / "mother" when the relation label is identifiable.
|
|
96
|
+
relation_type: Optional[str] = None
|
|
97
|
+
gender: Optional[str] = None
|
|
98
|
+
date_of_birth: Optional[str] = None
|
|
99
|
+
age: Optional[str] = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Small helpers
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def _ascii_letters(text: str) -> str:
|
|
107
|
+
return re.sub(r"[^A-Z]", "", text.upper())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_relation_label(text: str) -> bool:
|
|
111
|
+
"""True if the region text looks like a father/husband/mother label."""
|
|
112
|
+
letters = _ascii_letters(text)
|
|
113
|
+
return any(tok in letters for tok in ("FATHER", "HUSBAND", "MOTHER"))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _clean_value(text: Optional[str]) -> Optional[str]:
|
|
117
|
+
"""Trim a value and strip a leading 'Label :' fragment / stray punctuation."""
|
|
118
|
+
if not text:
|
|
119
|
+
return None
|
|
120
|
+
value = text.strip()
|
|
121
|
+
# Drop a leading "... :" colon-prefixed label fragment that some OCR runs glue
|
|
122
|
+
# onto the value region (e.g. "Name : DEEPAK MEHTA").
|
|
123
|
+
if ":" in value:
|
|
124
|
+
tail = value.split(":")[-1].strip()
|
|
125
|
+
if tail:
|
|
126
|
+
value = tail
|
|
127
|
+
value = value.strip(" :,-")
|
|
128
|
+
return value or None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _find_epic(regions: list[TextRegion]) -> Optional[str]:
|
|
132
|
+
"""First EPIC-shaped token anywhere on the card."""
|
|
133
|
+
for region in regions:
|
|
134
|
+
epic = normalize_epic(region.text)
|
|
135
|
+
if epic:
|
|
136
|
+
return epic
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Name vs relation disambiguation
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def _find_holder_name(regions: list[TextRegion]) -> Optional[str]:
|
|
145
|
+
"""Resolve the elector's own name without collapsing into the relation name.
|
|
146
|
+
|
|
147
|
+
`find_visual_field` matches a bare "NAME" against "FATHER'S NAME" too, so we
|
|
148
|
+
locate the label region ourselves and reject it if it is actually a relation
|
|
149
|
+
label, then read the value to its right (or below).
|
|
150
|
+
"""
|
|
151
|
+
label = find_visual_field(regions, _NAME_LABELS)
|
|
152
|
+
if label is None:
|
|
153
|
+
return None
|
|
154
|
+
# If the only "NAME" match is a relation label, there is no usable holder
|
|
155
|
+
# name label — bail rather than returning the relation's value.
|
|
156
|
+
if _is_relation_label(label.text):
|
|
157
|
+
return None
|
|
158
|
+
value = find_label_value(regions, _NAME_LABELS)
|
|
159
|
+
if value is None:
|
|
160
|
+
return None
|
|
161
|
+
# Guard: the resolved value must not itself be a relation label or the
|
|
162
|
+
# relation value bleeding up/down.
|
|
163
|
+
if _is_relation_label(value):
|
|
164
|
+
return None
|
|
165
|
+
return _clean_value(value)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _find_relation(regions: list[TextRegion]) -> tuple[Optional[str], Optional[str]]:
|
|
169
|
+
"""Return (relation_name, relation_type) from father/husband/mother labels."""
|
|
170
|
+
for labels, rtype in (
|
|
171
|
+
(_FATHER_LABELS, "father"),
|
|
172
|
+
(_HUSBAND_LABELS, "husband"),
|
|
173
|
+
(_MOTHER_LABELS, "mother"),
|
|
174
|
+
):
|
|
175
|
+
label = find_visual_field(regions, labels)
|
|
176
|
+
if label is None:
|
|
177
|
+
continue
|
|
178
|
+
# find_visual_field can match the wrong-family label via a shared "NAME"
|
|
179
|
+
# token; require the chosen label to actually contain the relation word.
|
|
180
|
+
if not _is_relation_label(label.text):
|
|
181
|
+
continue
|
|
182
|
+
letters = _ascii_letters(label.text)
|
|
183
|
+
if rtype.upper() not in letters:
|
|
184
|
+
continue
|
|
185
|
+
value = find_label_value(regions, labels)
|
|
186
|
+
if value is None:
|
|
187
|
+
continue
|
|
188
|
+
cleaned = _clean_value(value)
|
|
189
|
+
if cleaned:
|
|
190
|
+
return cleaned, rtype
|
|
191
|
+
return None, None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Gender
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def _normalise_gender(value: Optional[str]) -> Optional[str]:
|
|
199
|
+
if not value:
|
|
200
|
+
return None
|
|
201
|
+
text = value.strip()
|
|
202
|
+
upper = text.upper()
|
|
203
|
+
# Whole-word / substring checks on the ASCII form.
|
|
204
|
+
if "FEMALE" in upper:
|
|
205
|
+
return "FEMALE"
|
|
206
|
+
if "MALE" in upper:
|
|
207
|
+
return "MALE"
|
|
208
|
+
if "TRANSGENDER" in upper or "OTHER" in upper:
|
|
209
|
+
return "TRANSGENDER"
|
|
210
|
+
# Hindi.
|
|
211
|
+
if _HINDI_FEMALE in text:
|
|
212
|
+
return "FEMALE"
|
|
213
|
+
if _HINDI_MALE in text:
|
|
214
|
+
return "MALE"
|
|
215
|
+
if _HINDI_TRANS in text:
|
|
216
|
+
return "TRANSGENDER"
|
|
217
|
+
# Bare single-letter codes.
|
|
218
|
+
letters = _ascii_letters(text)
|
|
219
|
+
if letters == "F":
|
|
220
|
+
return "FEMALE"
|
|
221
|
+
if letters == "M":
|
|
222
|
+
return "MALE"
|
|
223
|
+
if letters == "T":
|
|
224
|
+
return "TRANSGENDER"
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _scan_gender(regions: list[TextRegion]) -> Optional[str]:
|
|
229
|
+
"""Fallback: find a region whose text *is* a gender value.
|
|
230
|
+
|
|
231
|
+
Used when the gender label is Hindi-only (so find_visual_field can't see it)
|
|
232
|
+
or the value sits without a recoverable English label.
|
|
233
|
+
"""
|
|
234
|
+
for region in regions:
|
|
235
|
+
text = region.text.strip()
|
|
236
|
+
# Hindi value regions.
|
|
237
|
+
if _HINDI_FEMALE in text or _HINDI_MALE in text or _HINDI_TRANS in text:
|
|
238
|
+
g = _normalise_gender(text)
|
|
239
|
+
if g:
|
|
240
|
+
return g
|
|
241
|
+
letters = _ascii_letters(text)
|
|
242
|
+
if letters in _GENDER_VALUE_TOKENS:
|
|
243
|
+
g = _normalise_gender(text)
|
|
244
|
+
if g:
|
|
245
|
+
return g
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _find_gender(regions: list[TextRegion]) -> Optional[str]:
|
|
250
|
+
# 1) Label-driven (Sex / Gender → value right/below).
|
|
251
|
+
labelled = _normalise_gender(find_label_value(regions, _GENDER_LABELS))
|
|
252
|
+
if labelled:
|
|
253
|
+
return labelled
|
|
254
|
+
# 2) Spatial: a "Sex"/"Gender" label whose value we read directly.
|
|
255
|
+
label = find_visual_field(regions, _GENDER_LABELS)
|
|
256
|
+
if label is not None:
|
|
257
|
+
value = find_visual_value_right(regions, label) or find_visual_value_near(
|
|
258
|
+
regions, label
|
|
259
|
+
)
|
|
260
|
+
if value is not None:
|
|
261
|
+
g = _normalise_gender(value.text)
|
|
262
|
+
if g:
|
|
263
|
+
return g
|
|
264
|
+
# 3) Content scan (Hindi-only label, or bare M/F/पुरुष/महिला token).
|
|
265
|
+
return _scan_gender(regions)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
# Date of birth / age
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def _find_dob(regions: list[TextRegion]) -> Optional[str]:
|
|
273
|
+
"""A dd/mm/yyyy date tied to a DOB label, else the first plausible date."""
|
|
274
|
+
labelled = find_label_value(regions, _DOB_LABELS)
|
|
275
|
+
if labelled:
|
|
276
|
+
m = _DATE_RE.search(labelled)
|
|
277
|
+
if m:
|
|
278
|
+
return m.group(1)
|
|
279
|
+
# Spatial read of the DOB label's neighbour (value may be a separate region).
|
|
280
|
+
label = find_visual_field(regions, _DOB_LABELS)
|
|
281
|
+
if label is not None:
|
|
282
|
+
value = find_visual_value_right(regions, label) or find_visual_value_near(
|
|
283
|
+
regions, label
|
|
284
|
+
)
|
|
285
|
+
if value is not None:
|
|
286
|
+
m = _DATE_RE.search(value.text)
|
|
287
|
+
if m:
|
|
288
|
+
return m.group(1)
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _find_age(regions: list[TextRegion], joined: str) -> Optional[str]:
|
|
293
|
+
"""Integer age from an 'Age'/'Age as on ...' label or fragment.
|
|
294
|
+
|
|
295
|
+
Resilient to "Age as on 1.1.2024 : 34" by ignoring the date-like year and
|
|
296
|
+
taking the trailing small integer; also handles a separate value region.
|
|
297
|
+
"""
|
|
298
|
+
# Label-driven, value to the right / below.
|
|
299
|
+
label = find_visual_field(regions, _AGE_LABELS)
|
|
300
|
+
if label is not None and "AGE" in _ascii_letters(label.text):
|
|
301
|
+
value = find_visual_value_right(regions, label) or find_visual_value_near(
|
|
302
|
+
regions, label
|
|
303
|
+
)
|
|
304
|
+
if value is not None:
|
|
305
|
+
age = _age_from_fragment(value.text)
|
|
306
|
+
if age:
|
|
307
|
+
return age
|
|
308
|
+
# Age may be glued onto the label region itself ("Age : 34").
|
|
309
|
+
age = _age_from_fragment(label.text)
|
|
310
|
+
if age:
|
|
311
|
+
return age
|
|
312
|
+
# Whole-card fallback: scan each region containing the word AGE.
|
|
313
|
+
for region in regions:
|
|
314
|
+
if _AGE_LABEL_RE.search(region.text):
|
|
315
|
+
age = _age_from_fragment(region.text)
|
|
316
|
+
if age:
|
|
317
|
+
return age
|
|
318
|
+
# Last resort: the joined text (handles age split oddly across regions).
|
|
319
|
+
m = _AGE_LABEL_RE.search(joined)
|
|
320
|
+
if m:
|
|
321
|
+
age = _age_from_fragment(joined[m.start():])
|
|
322
|
+
if age:
|
|
323
|
+
return age
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _age_from_fragment(text: str) -> Optional[str]:
|
|
328
|
+
"""Extract an age integer from a fragment, skipping any embedded date.
|
|
329
|
+
|
|
330
|
+
"Age as on 1.1.2024 : 34" → "34"; "Age 34" → "34"; "34" → "34".
|
|
331
|
+
"""
|
|
332
|
+
# Remove any dd/mm/yyyy-style date so its components aren't read as the age.
|
|
333
|
+
without_dates = _DATE_RE.sub(" ", text)
|
|
334
|
+
# Also remove bare 4-digit years that survive (e.g. "as on 2024").
|
|
335
|
+
without_years = re.sub(r"\b(19|20)\d{2}\b", " ", without_dates)
|
|
336
|
+
ints = _SMALL_INT_RE.findall(without_years)
|
|
337
|
+
for candidate in ints:
|
|
338
|
+
n = int(candidate)
|
|
339
|
+
if 1 <= n <= 120:
|
|
340
|
+
return candidate
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
# Public API
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
def extract_voter_id(regions: list[TextRegion]) -> VoterIdFields:
|
|
349
|
+
fields = VoterIdFields()
|
|
350
|
+
joined = " ".join(r.text for r in regions if r.text.strip())
|
|
351
|
+
|
|
352
|
+
fields.epic_number = _find_epic(regions)
|
|
353
|
+
fields.name = _find_holder_name(regions)
|
|
354
|
+
fields.relation_name, fields.relation_type = _find_relation(regions)
|
|
355
|
+
fields.gender = _find_gender(regions)
|
|
356
|
+
|
|
357
|
+
dob = _find_dob(regions)
|
|
358
|
+
if dob:
|
|
359
|
+
fields.date_of_birth = dob
|
|
360
|
+
else:
|
|
361
|
+
fields.age = _find_age(regions, joined)
|
|
362
|
+
|
|
363
|
+
return fields
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
cd "$(dirname "$0")"
|
|
4
|
+
source ../common.sh
|
|
5
|
+
hosting_defaults
|
|
6
|
+
require_command node
|
|
7
|
+
require_command npm
|
|
8
|
+
if [[ ! -x node_modules/.bin/wrangler ]]; then npm ci --ignore-scripts --no-audit --no-fund; fi
|
|
9
|
+
if [[ -z "${IMAGE_URI:-}" ]]; then
|
|
10
|
+
require_command docker
|
|
11
|
+
docker buildx version >/dev/null 2>&1 || { printf 'Docker Buildx is required to build Cloudflare container images.\n' >&2; exit 1; }
|
|
12
|
+
fi
|
|
13
|
+
CONFIG=.wrangler.deploy.json
|
|
14
|
+
SECRETS_FILE=''
|
|
15
|
+
trap 'rm -f "$CONFIG"; if [[ -n "$SECRETS_FILE" ]]; then rm -f "$SECRETS_FILE"; fi' EXIT
|
|
16
|
+
if [[ -n "${API_TOKEN:-}" ]]; then
|
|
17
|
+
SECRETS_FILE="$(mktemp)"
|
|
18
|
+
chmod 600 "$SECRETS_FILE"
|
|
19
|
+
node --input-type=module - "$SECRETS_FILE" <<'JS'
|
|
20
|
+
import fs from 'node:fs'
|
|
21
|
+
fs.writeFileSync(process.argv[2], JSON.stringify({ API_TOKEN: process.env.API_TOKEN }))
|
|
22
|
+
JS
|
|
23
|
+
fi
|
|
24
|
+
node --input-type=module <<'JS'
|
|
25
|
+
import fs from 'node:fs'
|
|
26
|
+
const config = JSON.parse(fs.readFileSync('wrangler.jsonc', 'utf8'))
|
|
27
|
+
config.name = process.env.DOCUMENT_OCR_NAME
|
|
28
|
+
config.vars.DOCUMENT_OCR_KYC_LANGS = process.env.DOCUMENT_OCR_KYC_LANGS
|
|
29
|
+
config.vars.CONTAINER_IDLE_TIMEOUT = process.env.DOCUMENT_OCR_PROFILE === 'warm' ? '1h' : '10m'
|
|
30
|
+
config.vars.CONTAINER_INSTANCES = process.env.DOCUMENT_OCR_MAX_INSTANCES || '2'
|
|
31
|
+
const count = Number(config.vars.CONTAINER_INSTANCES)
|
|
32
|
+
if (!Number.isInteger(count) || count < 1 || count > 100) throw new Error('DOCUMENT_OCR_MAX_INSTANCES must be 1..100')
|
|
33
|
+
config.containers[0].max_instances = count
|
|
34
|
+
config.containers[0].image_vars.DOCUMENT_OCR_KYC_LANGS = process.env.DOCUMENT_OCR_KYC_LANGS
|
|
35
|
+
if (process.env.IMAGE_URI) config.containers[0].image = process.env.IMAGE_URI
|
|
36
|
+
fs.writeFileSync('.wrangler.deploy.json', JSON.stringify(config, null, 2))
|
|
37
|
+
JS
|
|
38
|
+
# A temporary mode-0600 file passes environment secrets without CLI-value leakage.
|
|
39
|
+
# Otherwise use existing Wrangler secrets; required-secret validation fails closed.
|
|
40
|
+
if [[ -n "$SECRETS_FILE" ]]; then
|
|
41
|
+
node_modules/.bin/wrangler deploy --config "$CONFIG" --secrets-file "$SECRETS_FILE" "$@"
|
|
42
|
+
else
|
|
43
|
+
node_modules/.bin/wrangler deploy --config "$CONFIG" "$@"
|
|
44
|
+
fi
|