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,369 @@
|
|
|
1
|
+
# document-ocr
|
|
2
|
+
|
|
3
|
+
Local-first OCR pipeline for passports, Indian KYC documents, and experimental US identity/immigration/tax documents. It
|
|
4
|
+
preprocesses scans, classifies the document, runs targeted OCR with RapidOCR
|
|
5
|
+
(PP-OCRv5), and extracts structured fields—including passport MRZ data, Indian
|
|
6
|
+
passport back-page fields, and identifier/holder fields for PAN, Aadhaar,
|
|
7
|
+
driving licences, voter IDs, MGNREGA/NREGA job cards, and National Population
|
|
8
|
+
Register (NPR) name-and-address letters.
|
|
9
|
+
|
|
10
|
+
Ships as a Python package with a FastAPI server, plus an npm wrapper at [`packages/passport-ocr`](packages/passport-ocr) that auto-spawns the Python server for Node.js consumers.
|
|
11
|
+
|
|
12
|
+
> [!IMPORTANT]
|
|
13
|
+
> This project is beta software for data extraction. It does not establish
|
|
14
|
+
> document authenticity, verify identity against an issuing authority, detect
|
|
15
|
+
> fraud, or by itself satisfy KYC obligations. Evaluate it on your own legally
|
|
16
|
+
> obtained dataset before production use.
|
|
17
|
+
|
|
18
|
+
Version 3.1 adds US document profiles, PDF417/TD1/visa parsing, country hints,
|
|
19
|
+
field evidence, native PDF text, grouped pages, batch scans, a browser review UI,
|
|
20
|
+
redaction, and optional encrypted jobs. See [FEATURES.md](FEATURES.md) for the
|
|
21
|
+
contracts and coverage limits and [HOSTING.md](HOSTING.md) for Cloud Run, Lambda,
|
|
22
|
+
Cloudflare Containers, servers, Terraform, and the deployment CLI.
|
|
23
|
+
|
|
24
|
+
## Supported documents
|
|
25
|
+
|
|
26
|
+
| Document | Key fields extracted | Identifier validation |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| Passport (biodata) | name, passport no., nationality, DOB, sex, expiry/issue dates, place of birth | TD3 MRZ ICAO check digits |
|
|
29
|
+
| Passport (back page) | father/mother/spouse, address, file no., old passport details | — |
|
|
30
|
+
| PAN card | PAN, name, father's name, DOB | PAN format + holder-type char |
|
|
31
|
+
| Aadhaar (front/back) | Aadhaar no. (+ VID, masked-card support), name, DOB/YOB, gender, address, pincode | Verhoeff checksum |
|
|
32
|
+
| Driving licence | DL no., name, DOB, issue/validity dates (NT + TR), address, blood group, vehicle class | DL format |
|
|
33
|
+
| Voter ID (EPIC) | EPIC no., name, relation name + type, gender, DOB/age | EPIC format |
|
|
34
|
+
| MGNREGA/NREGA job card | job-card no., household head, registration/validity, location, category/BPL, adult members | Conservative hierarchical format |
|
|
35
|
+
| NPR name/address letter | reference no., resident name, address, pincode, issue date | Not offline-verifiable |
|
|
36
|
+
|
|
37
|
+
Together with the existing passport path, this covers the officially valid
|
|
38
|
+
document categories listed in the
|
|
39
|
+
[RBI KYC Master Direction](https://www.rbi.org.in/Scripts/BS_ViewMasDirections.aspx?id=11566);
|
|
40
|
+
PAN is supported as a separate tax identifier.
|
|
41
|
+
|
|
42
|
+
The document type is detected automatically; `/scan` returns the matching
|
|
43
|
+
field block (`fields`/`backPageFields` for passports, or `panFields`,
|
|
44
|
+
`aadhaarFields`, `drivingLicenceFields`, `voterIdFields`,
|
|
45
|
+
`nregaJobCardFields`, or `nprLetterFields`) keyed by `documentType`.
|
|
46
|
+
|
|
47
|
+
NREGA and NPR support is experimental until measured against a representative
|
|
48
|
+
private image dataset. The extraction layer handles multiple label/layout
|
|
49
|
+
variants, but deterministic text-region tests are not evidence of real-image
|
|
50
|
+
accuracy.
|
|
51
|
+
|
|
52
|
+
Country/document hints route through full-page classification rather than relying
|
|
53
|
+
on an ambiguous passport crop. New structured-document evidence can also override
|
|
54
|
+
generic passport-like labels. Unknown or conflicting document hints fail explicitly.
|
|
55
|
+
|
|
56
|
+
## Quickstart
|
|
57
|
+
|
|
58
|
+
### Python
|
|
59
|
+
|
|
60
|
+
Requires Python 3.14 and [`uv`](https://github.com/astral-sh/uv).
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
make install
|
|
64
|
+
make dev # uvicorn on :8000 with reload
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Scan a document:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
curl -F image=@/path/to/document.jpg \
|
|
71
|
+
http://localhost:8000/scan
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Docker
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
make docker-build
|
|
78
|
+
docker run --rm -p 8000:8000 passport-ocr
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The image pre-downloads PP-OCRv5 models at build time so the first request is fast.
|
|
82
|
+
|
|
83
|
+
### Node.js (npm package)
|
|
84
|
+
|
|
85
|
+
Development and publishing use Node.js 26.12.1. The SDK retains its Node.js 18
|
|
86
|
+
or later runtime requirement.
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npm install document-ocr
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
When npm blocks dependency install scripts, approve this package and finish its
|
|
93
|
+
local Python setup:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npm install-scripts approve document-ocr
|
|
97
|
+
npm rebuild document-ocr
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { DocumentOCR } from 'document-ocr';
|
|
102
|
+
|
|
103
|
+
const ocr = new DocumentOCR();
|
|
104
|
+
const result = await ocr.scan(imageBuffer);
|
|
105
|
+
|
|
106
|
+
if (result.status === 'success' && result.documentType === 'passport') {
|
|
107
|
+
console.log(result.fields.passportNumber, result.mrzValid);
|
|
108
|
+
}
|
|
109
|
+
await ocr.stop();
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The package auto-creates a `.venv`, installs the Python deps, and manages the local server lifecycle. See [`packages/passport-ocr/README.md`](packages/passport-ocr/README.md) for full options including HTTP mode.
|
|
113
|
+
|
|
114
|
+
## HTTP API
|
|
115
|
+
|
|
116
|
+
| Method | Path | Description |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| GET | `/health` | Liveness |
|
|
119
|
+
| GET | `/ready` | `503` until OCR models finish loading |
|
|
120
|
+
| GET | `/documents` | Document profiles and runtime capabilities |
|
|
121
|
+
| POST | `/scan` | Single scan with country/document hints and optional evidence |
|
|
122
|
+
| POST | `/scan/batch` | Independent inputs, preserving order |
|
|
123
|
+
| POST | `/scan/document` | Grouped front/back images or all PDF pages, with conflict detection |
|
|
124
|
+
| GET | `/review` | Browser review, corrections and export |
|
|
125
|
+
| POST | `/preview`, `/redact` | Normalized preview or selected raster redactions |
|
|
126
|
+
| POST | `/jobs` | Optional persistent-server queue |
|
|
127
|
+
| GET / DELETE | `/jobs/{id}` | Poll or delete a retained job |
|
|
128
|
+
|
|
129
|
+
`/scan` accepts images and PDFs up to 10 MB. Concurrency is serialized
|
|
130
|
+
internally. Set `API_TOKEN` to require
|
|
131
|
+
`Authorization: Bearer <token>` on document and job endpoints. For internet-facing deployments,
|
|
132
|
+
use platform IAM or an API gateway in addition to application-level controls.
|
|
133
|
+
Incomplete or semantically invalid non-passport extractions return HTTP `422`
|
|
134
|
+
with the full structured failure result.
|
|
135
|
+
|
|
136
|
+
## Output
|
|
137
|
+
|
|
138
|
+
```jsonc
|
|
139
|
+
{
|
|
140
|
+
"status": "success", // success | failure | unsupported_page
|
|
141
|
+
"documentType": "passport", // passport | pan | aadhaar | driving_licence | voter_id | nrega_job_card | npr_letter | unknown
|
|
142
|
+
"pageType": "passport_biodata", // same document values, plus passport_biodata | passport_non_biodata | unknown
|
|
143
|
+
"confidence": 0.91,
|
|
144
|
+
"fields": {
|
|
145
|
+
"surname": "...", "givenNames": "...", "fullName": "...",
|
|
146
|
+
"passportNumber": "...", "nationality": "IND",
|
|
147
|
+
"dateOfBirth": "1990-05-21", "sex": "M",
|
|
148
|
+
"expiryDate": "2030-04-12", "issueDate": "2020-04-13",
|
|
149
|
+
"placeOfBirth": "...", "countryCode": "IND"
|
|
150
|
+
},
|
|
151
|
+
"backPageFields": {
|
|
152
|
+
"fatherName": "...", "motherName": "...", "spouseName": "...",
|
|
153
|
+
"address": "...", "pincode": "...", "city": "...", "state": "...",
|
|
154
|
+
"fileNumber": "...", "oldPassportNumber": "...",
|
|
155
|
+
"oldPassportDateOfIssue": "...", "oldPassportPlaceOfIssue": "..."
|
|
156
|
+
},
|
|
157
|
+
// Exactly one document block is populated, keyed by documentType; the rest
|
|
158
|
+
// are null. (passport uses fields/backPageFields above.)
|
|
159
|
+
"panFields": { "panNumber": "...", "name": "...", "fatherName": "...", "dateOfBirth": "..." },
|
|
160
|
+
"aadhaarFields": { "aadhaarNumber": "...", "name": "...", "dateOfBirth": "...", "yearOfBirth": null,
|
|
161
|
+
"gender": "...", "address": "...", "pincode": "...", "checksumValid": true,
|
|
162
|
+
"aadhaarMasked": false, "aadhaarLast4": "...", "vid": null },
|
|
163
|
+
"drivingLicenceFields": { "dlNumber": "...", "name": "...", "dateOfBirth": "...", "issueDate": "...",
|
|
164
|
+
"validityDate": "...", "address": "...", "relationName": "...", "bloodGroup": "...",
|
|
165
|
+
"classOfVehicle": "...", "validityDateTransport": null },
|
|
166
|
+
"voterIdFields": { "epicNumber": "...", "name": "...", "relationName": "...", "relationType": "father",
|
|
167
|
+
"gender": "...", "dateOfBirth": "...", "age": null },
|
|
168
|
+
"nregaJobCardFields": { "jobCardNumber": "...", "headOfHousehold": "...", "category": "SC",
|
|
169
|
+
"registrationDate": "...", "validityFrom": "...", "validityTo": "...",
|
|
170
|
+
"address": "...", "village": "...", "gramPanchayat": "...", "block": "...",
|
|
171
|
+
"district": "...", "state": "...", "bplStatus": true, "familyId": "...",
|
|
172
|
+
"members": [{ "serialNumber": "1", "name": "...",
|
|
173
|
+
"fatherOrHusbandName": "...", "gender": "FEMALE", "age": 39 }] },
|
|
174
|
+
"nprLetterFields": { "referenceNumber": "...", "name": "...", "address": "...",
|
|
175
|
+
"pincode": "...", "issueDate": "..." },
|
|
176
|
+
"mrzRaw": ["P<IND...", "..."], "mrzValid": true,
|
|
177
|
+
"lowConfidence": false,
|
|
178
|
+
"identifierValid": null, // format/checksum only; never an authenticity verdict
|
|
179
|
+
"missingRequiredFields": [],
|
|
180
|
+
"errors": [], "warnings": [],
|
|
181
|
+
"processingMs": 412
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Pipeline
|
|
186
|
+
|
|
187
|
+
1. `preprocess` — orientation, document boundary detection, perspective correction, quality checks
|
|
188
|
+
2. `classify_passport_page` — biodata vs non-biodata vs not-a-passport (cheap bottom-crop probe)
|
|
189
|
+
3. passport path: `run_ocr` (RapidOCR PP-OCRv5, full-page enrichment and visual cross-checks even when the cropped MRZ is valid) → `parse_mrz` (TD3 MRZ with per-field + overall checksum validation) → `extract_back_page` (bilingual label-aware extraction) → `validate` (cross-checks MRZ vs visual fields, computes confidence)
|
|
190
|
+
4. non-passport path: `classify_document` routes full-page OCR to the matching
|
|
191
|
+
extractor (`pan` / `aadhaar` / `driving_licence` / `voter_id` /
|
|
192
|
+
`nrega_job_card` / `npr_letter`), checks minimum required fields, validates
|
|
193
|
+
identifiers where possible, and fails closed on partial or semantically
|
|
194
|
+
implausible records
|
|
195
|
+
|
|
196
|
+
For non-passport documents, `status: "success"` means the extractor returned
|
|
197
|
+
the document-specific minimum field set and passed a conservative OCR-region
|
|
198
|
+
confidence gate. Alternate model readings for the same detected geometry count
|
|
199
|
+
once. `identifierValid` means only an offline format/checksum check passed. NPR
|
|
200
|
+
references have no universal public checksum, so this value is `null`; it is
|
|
201
|
+
never evidence of authenticity.
|
|
202
|
+
|
|
203
|
+
### Non-passport OCR languages
|
|
204
|
+
|
|
205
|
+
The default recognition behavior is unchanged (`en`, with the existing
|
|
206
|
+
automatic fallback). If the expected KYC population uses a known script, run
|
|
207
|
+
up to four recognition passes only on the non-passport path:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
DOCUMENT_OCR_KYC_LANGS=en,devanagari make dev
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Available values are `en`, `latin`, `devanagari`, `ka` (Kannada), `ta`
|
|
214
|
+
(Tamil), and `te` (Telugu). Extra models increase latency and memory. The
|
|
215
|
+
bundled RapidOCR version has no Bengali recognition model: Bengali-labelled
|
|
216
|
+
extractor fixtures prove parsing behavior only, not Bengali image OCR. Select
|
|
217
|
+
languages and release thresholds from measured benchmark slices. Server
|
|
218
|
+
readiness initializes every configured model so model-download or startup
|
|
219
|
+
failures are reported before the first scan. Unsupported names or more than
|
|
220
|
+
four configured models fail readiness instead of silently falling back.
|
|
221
|
+
|
|
222
|
+
Single entry point: `core.pipeline.scan(image_input)`.
|
|
223
|
+
|
|
224
|
+
### Verification
|
|
225
|
+
|
|
226
|
+
Run `make test` for Python and SDK regressions. Use `make benchmark-public` and
|
|
227
|
+
`make benchmark-http` with the downloaded samples below for actual OCR accuracy
|
|
228
|
+
and API verification. Locally generated smoke fixtures are not used for the
|
|
229
|
+
active evaluation.
|
|
230
|
+
|
|
231
|
+
Use `make benchmark` with a private passport manifest following
|
|
232
|
+
[`benchmarks/PASSPORT_DATASET.md`](benchmarks/PASSPORT_DATASET.md), or
|
|
233
|
+
`make benchmark-kyc KYC_MANIFEST=/secure/path/manifest.json` following
|
|
234
|
+
[`benchmarks/KYC_DATASET.md`](benchmarks/KYC_DATASET.md), to measure actual image
|
|
235
|
+
accuracy. Missing required coverage fails the release gates.
|
|
236
|
+
|
|
237
|
+
See [`AUDIT.md`](AUDIT.md) for historical implementation review. Current accuracy
|
|
238
|
+
claims use the [dataset-only evaluation](benchmarks/ALL_DOCUMENTS_EVALUATION.md).
|
|
239
|
+
|
|
240
|
+
## Deployment
|
|
241
|
+
|
|
242
|
+
The same pipeline runs as a container, on Cloud Run, or on AWS Lambda. Copy
|
|
243
|
+
`.env.deploy.example` to `.env.deploy.<env>` and fill in the relevant values
|
|
244
|
+
first (Docker Hub and/or GCP).
|
|
245
|
+
|
|
246
|
+
Cloud Run and Lambda deployments are authenticated by default. Do not expose
|
|
247
|
+
an identity-document endpoint publicly without authentication, authorization,
|
|
248
|
+
rate limiting, encrypted transport, and an explicit data-retention policy.
|
|
249
|
+
|
|
250
|
+
### Cloud Run (recommended)
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
# Build deploy/docker/Dockerfile, push to Artifact Registry, deploy the service.
|
|
254
|
+
bash deploy/cloudrun/deploy.sh production
|
|
255
|
+
# or via Cloud Build:
|
|
256
|
+
gcloud builds submit --config deploy/cloudrun/cloudbuild.yaml \
|
|
257
|
+
--substitutions=_REGION=asia-south1,_SERVICE=document-ocr
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Service config is generated by [`deploy/cloudrun/render_service.py`](deploy/cloudrun/render_service.py)
|
|
261
|
+
(2 GiB / 2 vCPU, `containerConcurrency: 1` since OCR is serialized). The `economy`
|
|
262
|
+
profile scales to zero; `DOCUMENT_OCR_PROFILE=warm` keeps one instance ready to
|
|
263
|
+
reduce cold starts. See [hosting profiles](HOSTING.md) for deployment settings.
|
|
264
|
+
|
|
265
|
+
### AWS Lambda (container image)
|
|
266
|
+
|
|
267
|
+
The SDK's `mode: 'lambda'` invokes a function that takes `{ "image_base64": "..." }`.
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
cd deploy/lambda
|
|
271
|
+
sam build && sam deploy --guided # uses Dockerfile.lambda + template.yaml
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
The handler ([`deploy/lambda/handler.py`](deploy/lambda/handler.py)) returns the
|
|
275
|
+
raw scan result on success and an `{statusCode, body}` envelope for errors.
|
|
276
|
+
The included SAM template supports IAM-authenticated direct invocation and does
|
|
277
|
+
not create a public API Gateway endpoint.
|
|
278
|
+
|
|
279
|
+
## Tests & benchmarks
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
make test
|
|
283
|
+
make build
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The per-document extractors are covered by deterministic `TextRegion` fixtures
|
|
287
|
+
under `tests/python/test_*_extractor.py`. These tests verify parsing and
|
|
288
|
+
validation behavior; they are not a claim of real-world OCR accuracy.
|
|
289
|
+
|
|
290
|
+
For the legacy passport image benchmark, place its private dataset and
|
|
291
|
+
`manifest.json` under `benchmark-data/` and run:
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
make benchmark
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
For a non-passport KYC dataset, use the versioned manifest and release gates:
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
make benchmark-kyc \
|
|
301
|
+
KYC_MANIFEST=/secure/kyc-eval/manifest.json \
|
|
302
|
+
KYC_DATASET_ROOT=/secure/kyc-eval \
|
|
303
|
+
KYC_REPORT=/secure/kyc-eval/reports/main.json
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The KYC evaluator reports classification, acceptance/false-success,
|
|
307
|
+
exact/normalized field, complete-record, runtime, per-document, and
|
|
308
|
+
design/year/issuer/language/capture-quality slice metrics without copying
|
|
309
|
+
ground-truth values into its report. Required per-document slices fail manifest
|
|
310
|
+
validation when a declared variant is absent. See
|
|
311
|
+
[`benchmarks/KYC_DATASET.md`](benchmarks/KYC_DATASET.md) for the variant matrix
|
|
312
|
+
and annotation workflow.
|
|
313
|
+
|
|
314
|
+
`benchmark-data/` is ignored by Git. Never commit identity documents or
|
|
315
|
+
personal data. See [CONTRIBUTING.md](CONTRIBUTING.md) for fixture rules.
|
|
316
|
+
|
|
317
|
+
Accuracy evaluation uses **downloaded dataset documents only**. See the
|
|
318
|
+
[dataset evaluation report](benchmarks/ALL_DOCUMENTS_EVALUATION.md) for matched
|
|
319
|
+
accuracy, latency, API results and remaining failures.
|
|
320
|
+
|
|
321
|
+
The current sample has 62 images: 39 labelled positives across non-Indian
|
|
322
|
+
passport books, South Dakota licences, US passport-card fronts and W-9 forms,
|
|
323
|
+
seven negative controls, and 16 observations without field truth. Some source
|
|
324
|
+
datasets contain publisher-created synthetic/specimen documents; these are
|
|
325
|
+
identified in the [coverage map](benchmarks/DOCUMENT_DATASET_COVERAGE.md).
|
|
326
|
+
They are externally supplied images and labels, not project-generated fixtures.
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
.venv/bin/python -m benchmarks.public_data fetch-samples
|
|
330
|
+
.venv/bin/python -m benchmarks.public_data fetch-samples --manifest benchmarks/public_expansion_samples.json
|
|
331
|
+
.venv/bin/python -m benchmarks.multidoc_accuracy --output benchmark-data/public/current.json
|
|
332
|
+
.venv/bin/python -m benchmarks.http_accuracy --output benchmark-data/http/current.json
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The [real HTTP evaluator](benchmarks/HTTP_EVALUATION.md) submits those same images
|
|
336
|
+
through the production server at one, two and four clients, checks PNG/JPEG/
|
|
337
|
+
image-only PDF inputs and hint/evidence options, and exercises batch, grouped
|
|
338
|
+
pages and queued jobs. Format conversions and repetitions do not add independent
|
|
339
|
+
people. Local HTTP timing is not a deployed-worker or serverless latency guarantee.
|
|
340
|
+
|
|
341
|
+
[Core CI](.github/workflows/ocr-evaluation.yml) and
|
|
342
|
+
[public HTTP CI](.github/workflows/ocr-http-public.yml) compare the same downloaded
|
|
343
|
+
samples, scorer, model files and environment across revisions. Correctness and
|
|
344
|
+
latency are separate gates; a regression pass can still have unmet absolute
|
|
345
|
+
targets. Locally generated image contracts and the agent-transcribed EAD specimen
|
|
346
|
+
are excluded from automatic accuracy evaluation and its headline results.
|
|
347
|
+
|
|
348
|
+
The [public dataset catalog](benchmarks/PUBLIC_DATASETS.md) and
|
|
349
|
+
[additional source review](benchmarks/ADDITIONAL_SOURCE_REVIEW.md) retain links
|
|
350
|
+
without downloading whole datasets. The bounded fetcher downloads only selected
|
|
351
|
+
images and labels with pinned checksums. **Only four supported profiles currently
|
|
352
|
+
have labelled dataset coverage. Real Indian passport and full Indian KYC accuracy
|
|
353
|
+
remain unmeasured.** Missing dataset samples remain explicit coverage gaps.
|
|
354
|
+
[Historical OCR results](benchmarks/PUBLIC_BASELINE.md) and
|
|
355
|
+
[passport recovery comparisons](benchmarks/MRZ_RECOVERY_FOLLOWUP.md) remain available.
|
|
356
|
+
|
|
357
|
+
## Privacy and security
|
|
358
|
+
|
|
359
|
+
Local Python and npm-local modes process documents on the same machine and do
|
|
360
|
+
not include telemetry or document storage. HTTP and Lambda modes transmit
|
|
361
|
+
documents to infrastructure controlled by the operator.
|
|
362
|
+
|
|
363
|
+
Read [PRIVACY.md](PRIVACY.md) before deploying and report vulnerabilities using
|
|
364
|
+
[SECURITY.md](SECURITY.md).
|
|
365
|
+
|
|
366
|
+
## License
|
|
367
|
+
|
|
368
|
+
[MIT](LICENSE). Dependencies retain their own licenses; see
|
|
369
|
+
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
|
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
|