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,134 @@
|
|
|
1
|
+
# This file was autogenerated by uv via the following command:
|
|
2
|
+
# uv pip compile pyproject.toml -o requirements.lock --python-version 3.14 --universal
|
|
3
|
+
annotated-doc==0.0.5
|
|
4
|
+
# via fastapi
|
|
5
|
+
annotated-types==0.8.0
|
|
6
|
+
# via pydantic
|
|
7
|
+
anyio==4.15.1
|
|
8
|
+
# via
|
|
9
|
+
# starlette
|
|
10
|
+
# watchfiles
|
|
11
|
+
certifi==2026.7.22
|
|
12
|
+
# via requests
|
|
13
|
+
cffi==2.1.1 ; platform_python_implementation != 'PyPy'
|
|
14
|
+
# via cryptography
|
|
15
|
+
charset-normalizer==3.5.2
|
|
16
|
+
# via requests
|
|
17
|
+
click==8.5.0
|
|
18
|
+
# via
|
|
19
|
+
# india-document-ocr (pyproject.toml)
|
|
20
|
+
# uvicorn
|
|
21
|
+
colorama==0.4.6 ; sys_platform == 'win32'
|
|
22
|
+
# via
|
|
23
|
+
# colorlog
|
|
24
|
+
# tqdm
|
|
25
|
+
colorlog==6.12.0
|
|
26
|
+
# via rapidocr
|
|
27
|
+
cryptography==50.0.2
|
|
28
|
+
# via india-document-ocr (pyproject.toml)
|
|
29
|
+
fastapi==0.143.0
|
|
30
|
+
# via india-document-ocr (pyproject.toml)
|
|
31
|
+
flatbuffers==25.12.19
|
|
32
|
+
# via onnxruntime
|
|
33
|
+
h11==0.16.0
|
|
34
|
+
# via uvicorn
|
|
35
|
+
httptools==0.9.0
|
|
36
|
+
# via uvicorn
|
|
37
|
+
idna==3.20
|
|
38
|
+
# via
|
|
39
|
+
# india-document-ocr (pyproject.toml)
|
|
40
|
+
# anyio
|
|
41
|
+
# requests
|
|
42
|
+
numpy==2.5.4
|
|
43
|
+
# via
|
|
44
|
+
# india-document-ocr (pyproject.toml)
|
|
45
|
+
# onnxruntime
|
|
46
|
+
# opencv-python
|
|
47
|
+
# rapidocr
|
|
48
|
+
# shapely
|
|
49
|
+
omegaconf==2.4.0
|
|
50
|
+
# via rapidocr
|
|
51
|
+
onnxruntime==1.31.0
|
|
52
|
+
# via india-document-ocr (pyproject.toml)
|
|
53
|
+
opencv-python==4.13.0.92
|
|
54
|
+
# via
|
|
55
|
+
# india-document-ocr (pyproject.toml)
|
|
56
|
+
# rapidocr
|
|
57
|
+
opentelemetry-api==1.45.1
|
|
58
|
+
# via fastapi
|
|
59
|
+
packaging==26.3
|
|
60
|
+
# via onnxruntime
|
|
61
|
+
pillow==12.3.0
|
|
62
|
+
# via
|
|
63
|
+
# india-document-ocr (pyproject.toml)
|
|
64
|
+
# pillow-heif
|
|
65
|
+
# rapidocr
|
|
66
|
+
pillow-heif==1.8.1
|
|
67
|
+
# via india-document-ocr (pyproject.toml)
|
|
68
|
+
protobuf==7.36.2
|
|
69
|
+
# via onnxruntime
|
|
70
|
+
pyclipper==1.4.0
|
|
71
|
+
# via rapidocr
|
|
72
|
+
pycountry==26.2.16
|
|
73
|
+
# via india-document-ocr (pyproject.toml)
|
|
74
|
+
pycparser==3.11 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
|
|
75
|
+
# via cffi
|
|
76
|
+
pydantic==2.14.1
|
|
77
|
+
# via fastapi
|
|
78
|
+
pydantic-core==2.50.1
|
|
79
|
+
# via pydantic
|
|
80
|
+
pypdfium2==5.14.0
|
|
81
|
+
# via india-document-ocr (pyproject.toml)
|
|
82
|
+
python-dotenv==1.2.4
|
|
83
|
+
# via uvicorn
|
|
84
|
+
python-multipart==0.0.32
|
|
85
|
+
# via india-document-ocr (pyproject.toml)
|
|
86
|
+
pyyaml==6.0.3
|
|
87
|
+
# via
|
|
88
|
+
# omegaconf
|
|
89
|
+
# rapidocr
|
|
90
|
+
# uvicorn
|
|
91
|
+
rapidfuzz==3.14.6
|
|
92
|
+
# via india-document-ocr (pyproject.toml)
|
|
93
|
+
rapidocr==3.9.2
|
|
94
|
+
# via india-document-ocr (pyproject.toml)
|
|
95
|
+
requests==2.34.2
|
|
96
|
+
# via
|
|
97
|
+
# india-document-ocr (pyproject.toml)
|
|
98
|
+
# rapidocr
|
|
99
|
+
shapely==2.2.0
|
|
100
|
+
# via rapidocr
|
|
101
|
+
six==1.17.0
|
|
102
|
+
# via rapidocr
|
|
103
|
+
starlette==1.7.0
|
|
104
|
+
# via
|
|
105
|
+
# india-document-ocr (pyproject.toml)
|
|
106
|
+
# fastapi
|
|
107
|
+
tqdm==4.70.1
|
|
108
|
+
# via rapidocr
|
|
109
|
+
typing-extensions==4.16.0
|
|
110
|
+
# via
|
|
111
|
+
# anyio
|
|
112
|
+
# fastapi
|
|
113
|
+
# opentelemetry-api
|
|
114
|
+
# pydantic
|
|
115
|
+
# pydantic-core
|
|
116
|
+
# typing-inspection
|
|
117
|
+
typing-inspection==0.4.4
|
|
118
|
+
# via
|
|
119
|
+
# fastapi
|
|
120
|
+
# pydantic
|
|
121
|
+
urllib3==2.8.0
|
|
122
|
+
# via
|
|
123
|
+
# india-document-ocr (pyproject.toml)
|
|
124
|
+
# requests
|
|
125
|
+
uvicorn==0.54.0
|
|
126
|
+
# via india-document-ocr (pyproject.toml)
|
|
127
|
+
uvloop==0.23.0 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
|
|
128
|
+
# via uvicorn
|
|
129
|
+
watchfiles==1.3.0
|
|
130
|
+
# via uvicorn
|
|
131
|
+
websockets==17.2
|
|
132
|
+
# via uvicorn
|
|
133
|
+
zxing-cpp==3.1.1
|
|
134
|
+
# via india-document-ocr (pyproject.toml)
|
package/package.json
CHANGED
|
@@ -1,6 +1,78 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "document-ocr",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "3.1.3",
|
|
4
|
+
"description": "Local and hosted OCR SDK for passports, Indian KYC and experimental US identity, immigration and tax documents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"require": "./dist/index.cjs"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.cjs",
|
|
14
|
+
"module": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"python",
|
|
19
|
+
"scripts",
|
|
20
|
+
"README.md",
|
|
21
|
+
"hosting"
|
|
22
|
+
],
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/piyush-gambhir/document-ocr"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/piyush-gambhir/document-ocr#readme",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/piyush-gambhir/document-ocr/issues"
|
|
30
|
+
},
|
|
31
|
+
"author": "Piyush Gambhir",
|
|
32
|
+
"keywords": [
|
|
33
|
+
"passport",
|
|
34
|
+
"ocr",
|
|
35
|
+
"mrz",
|
|
36
|
+
"rapidocr",
|
|
37
|
+
"onnx",
|
|
38
|
+
"aadhaar",
|
|
39
|
+
"pan",
|
|
40
|
+
"kyc",
|
|
41
|
+
"nrega",
|
|
42
|
+
"npr",
|
|
43
|
+
"document-scanning",
|
|
44
|
+
"standalone"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"postinstall": "node scripts/postinstall.mjs",
|
|
48
|
+
"sync-python": "bash scripts/sync-python.sh",
|
|
49
|
+
"build": "bash scripts/sync-python.sh && node scripts/sync-hosting.mjs && tsup",
|
|
50
|
+
"dev": "tsup --watch",
|
|
51
|
+
"typecheck": "node node_modules/@typescript/native/bin/tsc --noEmit",
|
|
52
|
+
"test": "vitest run ../../tests/typescript",
|
|
53
|
+
"typecheck:compat": "node node_modules/typescript/bin/tsc --noEmit"
|
|
54
|
+
},
|
|
55
|
+
"optionalDependencies": {
|
|
56
|
+
"@aws-sdk/client-lambda": "^3.1156.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "^26.6.5",
|
|
60
|
+
"tsup": "^9.0.0",
|
|
61
|
+
"typescript": "~6.0.3",
|
|
62
|
+
"vitest": "^5.0.5",
|
|
63
|
+
"@typescript/native": "npm:typescript@^7.0.2"
|
|
64
|
+
},
|
|
65
|
+
"overrides": {
|
|
66
|
+
"esbuild": "^0.28.2"
|
|
67
|
+
},
|
|
68
|
+
"engines": {
|
|
69
|
+
"node": ">=18"
|
|
70
|
+
},
|
|
71
|
+
"license": "MIT",
|
|
72
|
+
"publishConfig": {
|
|
73
|
+
"access": "public"
|
|
74
|
+
},
|
|
75
|
+
"bin": {
|
|
76
|
+
"document-ocr": "./scripts/dococr.mjs"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Extractor for Indian Aadhaar cards (front and back).
|
|
3
|
+
|
|
4
|
+
Front fields: Aadhaar number, name, date/year of birth, gender.
|
|
5
|
+
Back fields: address, pincode.
|
|
6
|
+
|
|
7
|
+
Aadhaar has no Latin field labels for the name (it sits above the DOB line), so
|
|
8
|
+
the name is found spatially relative to the DOB/gender block. The Aadhaar number
|
|
9
|
+
and its Verhoeff checksum are handled in core.validators.
|
|
10
|
+
|
|
11
|
+
Robustness notes:
|
|
12
|
+
* VID (a 16-digit Virtual ID, grouped 4-4-4-4) is masked out of the text
|
|
13
|
+
before the 12-digit Aadhaar is extracted, otherwise the VID's first twelve
|
|
14
|
+
digits would be mis-captured as the Aadhaar number.
|
|
15
|
+
* Masked Aadhaar ("XXXX XXXX 9012") is recognised: the visible tail is
|
|
16
|
+
surfaced via ``aadhaar_masked`` / ``aadhaar_last4`` and ``checksum_valid``
|
|
17
|
+
stays False (we never crash on a missing/partial number).
|
|
18
|
+
* Gender is normalised across English and Hindi (Devanagari) spellings.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from .ocr_engine import TextRegion
|
|
28
|
+
from .validator import find_label_value, find_visual_field
|
|
29
|
+
from .validators import extract_aadhaar_number, is_valid_aadhaar
|
|
30
|
+
|
|
31
|
+
_DATE_RE = re.compile(r"\b(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{4})\b")
|
|
32
|
+
_YEAR_RE = re.compile(r"\b(19\d{2}|20\d{2})\b")
|
|
33
|
+
_PINCODE_RE = re.compile(r"\b(\d{6})\b")
|
|
34
|
+
|
|
35
|
+
# A Virtual ID is 16 digits, printed grouped 4-4-4-4 (optionally spaced) — or as
|
|
36
|
+
# a bare 16-digit run. Either form must be removed before Aadhaar extraction.
|
|
37
|
+
_VID_RE = re.compile(r"\b\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\b")
|
|
38
|
+
_VID_LABEL_RE = re.compile(r"\bVID\b", re.IGNORECASE)
|
|
39
|
+
|
|
40
|
+
# Masked Aadhaar: leading groups are X'd out and only the final 4 digits show,
|
|
41
|
+
# e.g. "XXXX XXXX 9012" / "xxxxxxxx9012" / "**** **** 9012".
|
|
42
|
+
_MASKED_AADHAAR_RE = re.compile(
|
|
43
|
+
r"(?:[Xx*]{4}[\s-]?){2}(\d{4})\b"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_GENDER_WORDS = {
|
|
47
|
+
"MALE": "MALE",
|
|
48
|
+
"FEMALE": "FEMALE",
|
|
49
|
+
"TRANSGENDER": "TRANSGENDER",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Hindi (Devanagari) gender words map to the same canonical values.
|
|
53
|
+
_GENDER_HINDI = {
|
|
54
|
+
"पुरुष": "MALE",
|
|
55
|
+
"महिला": "FEMALE",
|
|
56
|
+
"स्त्री": "FEMALE",
|
|
57
|
+
"किन्नर": "TRANSGENDER",
|
|
58
|
+
"ट्रांसजेंडर": "TRANSGENDER",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Header / label noise that must never be mistaken for the holder name.
|
|
62
|
+
_NAME_STOPWORDS = {
|
|
63
|
+
"GOVERNMENT", "GOVT", "INDIA", "BHARAT", "AADHAAR", "AADHAR", "UNIQUE",
|
|
64
|
+
"IDENTIFICATION", "AUTHORITY", "UIDAI", "MALE", "FEMALE", "TRANSGENDER",
|
|
65
|
+
"DOB", "YEAR", "BIRTH", "YOB", "ADDRESS", "MOBILE", "VID", "ENROLLMENT",
|
|
66
|
+
"ENROLMENT", "GENDER", "DATE", "NUMBER", "CARD", "DOWNLOAD", "ISSUE",
|
|
67
|
+
"ISSUED", "HELP", "EMAIL", "WWW", "PHONE", "MERA", "AADHAR", "VALID",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Care-of prefixes that introduce the address on the back.
|
|
71
|
+
_CARE_OF_RE = re.compile(r"\b([SCDWscdw])\s*/\s*[Oo]\b")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class AadhaarFields:
|
|
76
|
+
aadhaar_number: Optional[str] = None
|
|
77
|
+
name: Optional[str] = None
|
|
78
|
+
date_of_birth: Optional[str] = None
|
|
79
|
+
year_of_birth: Optional[str] = None
|
|
80
|
+
gender: Optional[str] = None
|
|
81
|
+
address: Optional[str] = None
|
|
82
|
+
pincode: Optional[str] = None
|
|
83
|
+
checksum_valid: bool = False
|
|
84
|
+
# --- additive fields (do not rename existing ones) ---
|
|
85
|
+
aadhaar_masked: bool = False
|
|
86
|
+
aadhaar_last4: Optional[str] = None
|
|
87
|
+
vid: Optional[str] = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _devanagari_ratio(text: str) -> float:
|
|
91
|
+
"""Fraction of alphabetic characters that fall in the Devanagari block."""
|
|
92
|
+
letters = [c for c in text if c.isalpha()]
|
|
93
|
+
if not letters:
|
|
94
|
+
return 0.0
|
|
95
|
+
devanagari = sum(1 for c in letters if "ऀ" <= c <= "ॿ")
|
|
96
|
+
return devanagari / len(letters)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_name_like(text: str) -> bool:
|
|
100
|
+
stripped = text.strip()
|
|
101
|
+
if len(stripped) < 3 or any(ch.isdigit() for ch in stripped):
|
|
102
|
+
return False
|
|
103
|
+
letters = [c for c in stripped if c.isalpha()]
|
|
104
|
+
if not letters:
|
|
105
|
+
return False
|
|
106
|
+
# Mostly Latin letters (Aadhaar prints the name in English too).
|
|
107
|
+
if _devanagari_ratio(stripped) > 0.5:
|
|
108
|
+
return False
|
|
109
|
+
if sum(1 for c in letters if ord(c) > 127) / len(letters) > 0.5:
|
|
110
|
+
return False
|
|
111
|
+
# A care-of address line ("S/O ...") is not the holder name.
|
|
112
|
+
if _CARE_OF_RE.search(stripped):
|
|
113
|
+
return False
|
|
114
|
+
words = re.sub(r"[^A-Z ]", " ", stripped.upper()).split()
|
|
115
|
+
if not words:
|
|
116
|
+
return False
|
|
117
|
+
return not any(w in _NAME_STOPWORDS for w in words)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _find_gender(regions: list[TextRegion]) -> Optional[str]:
|
|
121
|
+
for region in regions:
|
|
122
|
+
text = region.text
|
|
123
|
+
# English / Latin gender words, matched as whole words.
|
|
124
|
+
upper = re.sub(r"[^A-Z]", " ", text.upper())
|
|
125
|
+
padded = f" {upper} "
|
|
126
|
+
for word, value in _GENDER_WORDS.items():
|
|
127
|
+
if f" {word} " in padded:
|
|
128
|
+
return value
|
|
129
|
+
# Hindi (Devanagari) gender words.
|
|
130
|
+
for word, value in _GENDER_HINDI.items():
|
|
131
|
+
if word in text:
|
|
132
|
+
return value
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _find_dob_region(regions: list[TextRegion]) -> Optional[TextRegion]:
|
|
137
|
+
"""Locate the region carrying the date/year of birth.
|
|
138
|
+
|
|
139
|
+
Prefer a region that both mentions a DOB/birth label *and* carries a date;
|
|
140
|
+
fall back to any birth-labelled region, then to any bare date region.
|
|
141
|
+
"""
|
|
142
|
+
labelled_with_date: Optional[TextRegion] = None
|
|
143
|
+
labelled: Optional[TextRegion] = None
|
|
144
|
+
bare_date: Optional[TextRegion] = None
|
|
145
|
+
|
|
146
|
+
for region in regions:
|
|
147
|
+
upper = region.text.upper()
|
|
148
|
+
has_birth_label = (
|
|
149
|
+
"DOB" in upper
|
|
150
|
+
or "YEAR OF BIRTH" in upper
|
|
151
|
+
or "YOB" in upper
|
|
152
|
+
or "BIRTH" in upper
|
|
153
|
+
or "जन्म" in region.text # Hindi "janm" (birth)
|
|
154
|
+
)
|
|
155
|
+
has_date = bool(_DATE_RE.search(region.text))
|
|
156
|
+
if has_birth_label and has_date and labelled_with_date is None:
|
|
157
|
+
labelled_with_date = region
|
|
158
|
+
elif has_birth_label and labelled is None:
|
|
159
|
+
labelled = region
|
|
160
|
+
elif has_date and bare_date is None:
|
|
161
|
+
bare_date = region
|
|
162
|
+
|
|
163
|
+
return labelled_with_date or labelled or bare_date
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _find_name(
|
|
167
|
+
regions: list[TextRegion],
|
|
168
|
+
dob_region: Optional[TextRegion],
|
|
169
|
+
*,
|
|
170
|
+
is_front: bool,
|
|
171
|
+
) -> Optional[str]:
|
|
172
|
+
candidates = [r for r in regions if _is_name_like(r.text)]
|
|
173
|
+
if not candidates:
|
|
174
|
+
return None
|
|
175
|
+
if dob_region is not None:
|
|
176
|
+
dob_top = min(p[1] for p in dob_region.bbox)
|
|
177
|
+
dob_left = min(p[0] for p in dob_region.bbox)
|
|
178
|
+
above = [r for r in candidates if max(p[1] for p in r.bbox) <= dob_top + 5]
|
|
179
|
+
if above:
|
|
180
|
+
# Closest line above the DOB, breaking ties by horizontal proximity.
|
|
181
|
+
above.sort(
|
|
182
|
+
key=lambda r: (
|
|
183
|
+
dob_top - max(p[1] for p in r.bbox),
|
|
184
|
+
abs(min(p[0] for p in r.bbox) - dob_left),
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
return above[0].text.strip()
|
|
188
|
+
# No DOB anchor: only guess a name on a front page (a back/address page has
|
|
189
|
+
# no holder name, and address/locality lines must not be fabricated as one).
|
|
190
|
+
if not is_front:
|
|
191
|
+
return None
|
|
192
|
+
# Fall back to the topmost name-like line (the "Government of India" band is
|
|
193
|
+
# already filtered out by the stopword list).
|
|
194
|
+
candidates.sort(key=lambda r: min(p[1] for p in r.bbox))
|
|
195
|
+
return candidates[0].text.strip()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _find_address(regions: list[TextRegion]) -> Optional[str]:
|
|
199
|
+
label = find_visual_field(regions, ["ADDRESS", "पता"])
|
|
200
|
+
if label is None:
|
|
201
|
+
return None
|
|
202
|
+
label_bottom = max(p[1] for p in label.bbox)
|
|
203
|
+
label_top = min(p[1] for p in label.bbox)
|
|
204
|
+
lines = []
|
|
205
|
+
for region in regions:
|
|
206
|
+
if region is label:
|
|
207
|
+
continue
|
|
208
|
+
top = min(p[1] for p in region.bbox)
|
|
209
|
+
bottom = max(p[1] for p in region.bbox)
|
|
210
|
+
# Keep lines that begin at/below the label and within a reasonable span.
|
|
211
|
+
# Also allow a line that shares the label's row (inline "Address: ...").
|
|
212
|
+
on_label_row = bottom > label_top and top < label_bottom
|
|
213
|
+
if not on_label_row and (top < label_bottom - 10 or top - label_bottom > 300):
|
|
214
|
+
continue
|
|
215
|
+
text = region.text.strip()
|
|
216
|
+
if _devanagari_ratio(text) > 0.5:
|
|
217
|
+
continue # skip mostly-Devanagari noise lines
|
|
218
|
+
# Skip a standalone Aadhaar/VID number line printed below the address.
|
|
219
|
+
if not any(c.isalpha() for c in text) and sum(c.isdigit() for c in text) >= 8:
|
|
220
|
+
continue
|
|
221
|
+
if len(text) >= 2:
|
|
222
|
+
lines.append((top, text))
|
|
223
|
+
lines.sort(key=lambda x: x[0])
|
|
224
|
+
if not lines:
|
|
225
|
+
# Address may be on the same line as the label ("Address: ...").
|
|
226
|
+
inline = find_label_value(regions, ["ADDRESS"])
|
|
227
|
+
return inline
|
|
228
|
+
return ", ".join(text for _, text in lines[:6])
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _strip_vid(text: str) -> tuple[str, Optional[str]]:
|
|
232
|
+
"""Remove 16-digit VID sequences from ``text`` and return (cleaned, vid).
|
|
233
|
+
|
|
234
|
+
Both spaced ("9148 6541 8231 2156") and bare ("9148654182312156") 16-digit
|
|
235
|
+
forms are stripped so the downstream 12-digit Aadhaar matcher cannot slice
|
|
236
|
+
the VID's leading twelve digits. The first VID found is surfaced.
|
|
237
|
+
"""
|
|
238
|
+
found: Optional[str] = None
|
|
239
|
+
|
|
240
|
+
def _replace(match: re.Match[str]) -> str:
|
|
241
|
+
nonlocal found
|
|
242
|
+
if found is None:
|
|
243
|
+
digits = re.sub(r"\D", "", match.group(0))
|
|
244
|
+
found = f"{digits[0:4]} {digits[4:8]} {digits[8:12]} {digits[12:16]}"
|
|
245
|
+
return " "
|
|
246
|
+
|
|
247
|
+
cleaned = _VID_RE.sub(_replace, text)
|
|
248
|
+
return cleaned, found
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def extract_aadhaar(regions: list[TextRegion]) -> AadhaarFields:
|
|
252
|
+
fields = AadhaarFields()
|
|
253
|
+
joined = " ".join(r.text for r in regions if r.text.strip())
|
|
254
|
+
|
|
255
|
+
# Strip any 16-digit VID first so it can't be misread as the Aadhaar number.
|
|
256
|
+
cleaned, fields.vid = _strip_vid(joined)
|
|
257
|
+
|
|
258
|
+
fields.aadhaar_number = extract_aadhaar_number(cleaned)
|
|
259
|
+
fields.checksum_valid = is_valid_aadhaar(cleaned)
|
|
260
|
+
|
|
261
|
+
# Masked Aadhaar handling: when no full number is present but a masked form
|
|
262
|
+
# ("XXXX XXXX 9012") is, surface the visible tail without claiming validity.
|
|
263
|
+
if fields.aadhaar_number is None:
|
|
264
|
+
masked = _MASKED_AADHAAR_RE.search(cleaned)
|
|
265
|
+
if masked:
|
|
266
|
+
fields.aadhaar_masked = True
|
|
267
|
+
fields.aadhaar_last4 = masked.group(1)
|
|
268
|
+
elif fields.aadhaar_number:
|
|
269
|
+
fields.aadhaar_last4 = fields.aadhaar_number.split()[-1]
|
|
270
|
+
|
|
271
|
+
fields.gender = _find_gender(regions)
|
|
272
|
+
|
|
273
|
+
dob_region = _find_dob_region(regions)
|
|
274
|
+
if dob_region is not None:
|
|
275
|
+
date_match = _DATE_RE.search(dob_region.text)
|
|
276
|
+
if date_match:
|
|
277
|
+
fields.date_of_birth = date_match.group(1)
|
|
278
|
+
else:
|
|
279
|
+
year_match = _YEAR_RE.search(dob_region.text)
|
|
280
|
+
if year_match:
|
|
281
|
+
fields.year_of_birth = year_match.group(1)
|
|
282
|
+
|
|
283
|
+
# A page is "front-like" when it carries a DOB/year or a gender word, and is
|
|
284
|
+
# not an address (back) page. The name heuristic only fabricates a fallback
|
|
285
|
+
# name on front-like pages.
|
|
286
|
+
has_address_label = find_visual_field(regions, ["ADDRESS", "पता"]) is not None
|
|
287
|
+
is_front = (
|
|
288
|
+
dob_region is not None or fields.gender is not None
|
|
289
|
+
) and not has_address_label
|
|
290
|
+
fields.name = _find_name(regions, dob_region, is_front=is_front)
|
|
291
|
+
|
|
292
|
+
address = _find_address(regions)
|
|
293
|
+
if address:
|
|
294
|
+
fields.address = address
|
|
295
|
+
pincode = _PINCODE_RE.search(address)
|
|
296
|
+
fields.pincode = pincode.group(1) if pincode else None
|
|
297
|
+
|
|
298
|
+
return fields
|