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,232 @@
|
|
|
1
|
+
"""Optional encrypted SQLite queue for a persistent server volume.
|
|
2
|
+
|
|
3
|
+
Run a separate worker: python -m core.jobs worker. This is deliberately disabled
|
|
4
|
+
unless DOCUMENT_OCR_JOBS_DIR and DOCUMENT_OCR_JOB_KEY are configured. Do not put
|
|
5
|
+
the database on ephemeral/serverless storage or a network filesystem.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import base64
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import signal
|
|
18
|
+
import sqlite3
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
|
|
22
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
23
|
+
|
|
24
|
+
MAX_JOBS = 1000
|
|
25
|
+
MAX_ATTEMPTS = 3
|
|
26
|
+
LEASE_SECONDS = 3600
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JobStore:
|
|
30
|
+
def __init__(self, directory: str | Path, key: str, *, retention_seconds: int = 86400):
|
|
31
|
+
if not 60 <= retention_seconds <= 604800:
|
|
32
|
+
raise ValueError('INVALID_JOB_RETENTION')
|
|
33
|
+
self.cipher = Fernet(key.encode())
|
|
34
|
+
self.retention = retention_seconds
|
|
35
|
+
directory = Path(directory)
|
|
36
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
37
|
+
self.path = directory / 'jobs.sqlite3'
|
|
38
|
+
with self.connection() as db:
|
|
39
|
+
db.execute('PRAGMA journal_mode=WAL')
|
|
40
|
+
db.execute('''CREATE TABLE IF NOT EXISTS jobs (
|
|
41
|
+
id TEXT PRIMARY KEY, status TEXT NOT NULL, created REAL NOT NULL,
|
|
42
|
+
expires REAL NOT NULL, payload BLOB NOT NULL, result BLOB,
|
|
43
|
+
attempts INTEGER NOT NULL DEFAULT 0, lease_until REAL, lease_token TEXT,
|
|
44
|
+
error TEXT, notify INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
notification_attempts INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
notify_after REAL NOT NULL DEFAULT 0, notified INTEGER NOT NULL DEFAULT 0
|
|
47
|
+
)''')
|
|
48
|
+
db.execute('CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value BLOB NOT NULL)')
|
|
49
|
+
db.execute('BEGIN IMMEDIATE')
|
|
50
|
+
sentinel = db.execute("SELECT value FROM metadata WHERE key='encryption_key_check'").fetchone()
|
|
51
|
+
try:
|
|
52
|
+
if sentinel is not None:
|
|
53
|
+
if self.cipher.decrypt(sentinel[0]) != b'document-ocr-jobs-v1':
|
|
54
|
+
raise ValueError('JOB_KEY_MISMATCH')
|
|
55
|
+
else:
|
|
56
|
+
# Validate any pre-sentinel queue before migrating it.
|
|
57
|
+
previous = db.execute("SELECT payload,result FROM jobs WHERE length(payload)>0 OR result IS NOT NULL LIMIT 1").fetchone()
|
|
58
|
+
if previous is not None:
|
|
59
|
+
self.cipher.decrypt(previous['payload'] or previous['result'])
|
|
60
|
+
db.execute("INSERT INTO metadata (key,value) VALUES ('encryption_key_check',?)",
|
|
61
|
+
(self.cipher.encrypt(b'document-ocr-jobs-v1'),))
|
|
62
|
+
except InvalidToken as exc:
|
|
63
|
+
raise ValueError('JOB_KEY_MISMATCH') from exc
|
|
64
|
+
os.chmod(self.path, 0o600)
|
|
65
|
+
|
|
66
|
+
@contextmanager
|
|
67
|
+
def connection(self):
|
|
68
|
+
db = sqlite3.connect(self.path, timeout=15)
|
|
69
|
+
db.row_factory = sqlite3.Row
|
|
70
|
+
try:
|
|
71
|
+
with db:
|
|
72
|
+
yield db
|
|
73
|
+
finally:
|
|
74
|
+
db.close()
|
|
75
|
+
|
|
76
|
+
def _encrypt(self, value) -> bytes:
|
|
77
|
+
return self.cipher.encrypt(json.dumps(value, separators=(',', ':')).encode())
|
|
78
|
+
|
|
79
|
+
def _decrypt(self, value):
|
|
80
|
+
return json.loads(self.cipher.decrypt(value))
|
|
81
|
+
|
|
82
|
+
def enqueue(self, images: list[bytes], options: dict, *, grouped: bool = False, notify: bool = False) -> dict:
|
|
83
|
+
if not 1 <= len(images) <= 10 or sum(map(len, images)) > 10 * 1024 * 1024:
|
|
84
|
+
raise ValueError('INVALID_JOB_SIZE')
|
|
85
|
+
if notify and not webhook_configuration():
|
|
86
|
+
raise ValueError('WEBHOOK_NOT_CONFIGURED')
|
|
87
|
+
payload = self._encrypt({'images': [base64.b64encode(i).decode() for i in images],
|
|
88
|
+
'options': options, 'grouped': grouped})
|
|
89
|
+
now, identifier = time.time(), str(uuid.uuid4())
|
|
90
|
+
with self.connection() as db:
|
|
91
|
+
db.execute('BEGIN IMMEDIATE')
|
|
92
|
+
db.execute('DELETE FROM jobs WHERE expires <= ?', (now,))
|
|
93
|
+
if db.execute('SELECT count(*) FROM jobs').fetchone()[0] >= MAX_JOBS:
|
|
94
|
+
raise ValueError('JOB_QUEUE_FULL')
|
|
95
|
+
db.execute('INSERT INTO jobs (id,status,created,expires,payload,notify) VALUES (?,?,?,?,?,?)',
|
|
96
|
+
(identifier, 'queued', now, now + self.retention, payload, int(notify)))
|
|
97
|
+
return self.get(identifier)
|
|
98
|
+
|
|
99
|
+
def get(self, identifier: str) -> dict | None:
|
|
100
|
+
with self.connection() as db:
|
|
101
|
+
row = db.execute('SELECT * FROM jobs WHERE id=? AND expires>?', (identifier, time.time())).fetchone()
|
|
102
|
+
if row is None:
|
|
103
|
+
return None
|
|
104
|
+
result = {'id': row['id'], 'status': row['status'], 'createdAt': row['created'],
|
|
105
|
+
'expiresAt': row['expires'], 'attempts': row['attempts'], 'error': row['error']}
|
|
106
|
+
if row['result']:
|
|
107
|
+
result['result'] = self._decrypt(row['result'])
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
def delete(self, identifier: str) -> bool:
|
|
111
|
+
with self.connection() as db:
|
|
112
|
+
return db.execute('DELETE FROM jobs WHERE id=?', (identifier,)).rowcount > 0
|
|
113
|
+
|
|
114
|
+
def claim(self):
|
|
115
|
+
now = time.time()
|
|
116
|
+
with self.connection() as db:
|
|
117
|
+
db.execute('BEGIN IMMEDIATE')
|
|
118
|
+
db.execute('DELETE FROM jobs WHERE expires<=?', (now,))
|
|
119
|
+
db.execute("UPDATE jobs SET status='failed',error='WORKER_RETRIES_EXHAUSTED',payload=? WHERE status='running' AND lease_until<=? AND attempts>=?", (b'', now, MAX_ATTEMPTS))
|
|
120
|
+
row = db.execute("SELECT * FROM jobs WHERE (status='queued' OR (status='running' AND lease_until<=?)) AND attempts<? ORDER BY created LIMIT 1", (now, MAX_ATTEMPTS)).fetchone()
|
|
121
|
+
if row is None:
|
|
122
|
+
return None
|
|
123
|
+
token = str(uuid.uuid4())
|
|
124
|
+
db.execute("UPDATE jobs SET status='running',attempts=attempts+1,lease_until=?,lease_token=? WHERE id=?", (now+LEASE_SECONDS, token, row['id']))
|
|
125
|
+
return row['id'], token, row['payload']
|
|
126
|
+
|
|
127
|
+
def finish(self, identifier: str, token: str, result=None, *, error: str | None = None):
|
|
128
|
+
encrypted = self._encrypt(result) if result is not None else None
|
|
129
|
+
with self.connection() as db:
|
|
130
|
+
db.execute("UPDATE jobs SET status=?,result=?,payload=?,error=?,lease_until=NULL WHERE id=? AND lease_token=? AND status='running' AND expires>?",
|
|
131
|
+
('failed' if error else 'succeeded', encrypted, b'', error, identifier, token, time.time()))
|
|
132
|
+
|
|
133
|
+
def run_once(self, processor=None) -> bool:
|
|
134
|
+
claim = self.claim()
|
|
135
|
+
if not claim:
|
|
136
|
+
return False
|
|
137
|
+
identifier, token, encrypted = claim
|
|
138
|
+
try:
|
|
139
|
+
payload = self._decrypt(encrypted)
|
|
140
|
+
if processor is None:
|
|
141
|
+
from .document_bundle import scan_batch, scan_document
|
|
142
|
+
processor = scan_document if payload['grouped'] else scan_batch
|
|
143
|
+
images = [base64.b64decode(i, validate=True) for i in payload['images']]
|
|
144
|
+
result = processor(images, **payload['options'])
|
|
145
|
+
self.finish(identifier, token, result)
|
|
146
|
+
except InvalidToken:
|
|
147
|
+
with self.connection() as db:
|
|
148
|
+
db.execute("UPDATE jobs SET status='failed',error='JOB_PAYLOAD_DECRYPTION_FAILED',lease_until=NULL WHERE id=? AND lease_token=?", (identifier, token))
|
|
149
|
+
except Exception:
|
|
150
|
+
# Stored errors never include input text, paths, secrets or model output.
|
|
151
|
+
self.finish(identifier, token, error='JOB_PROCESSING_FAILED')
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
def deliver_notification(self, sender=None) -> bool:
|
|
155
|
+
config = webhook_configuration()
|
|
156
|
+
if not config:
|
|
157
|
+
return False
|
|
158
|
+
now = time.time()
|
|
159
|
+
with self.connection() as db:
|
|
160
|
+
db.execute('BEGIN IMMEDIATE')
|
|
161
|
+
row = db.execute("SELECT id,status FROM jobs WHERE notify=1 AND notified=0 AND notification_attempts<6 AND notify_after<=? AND expires>? AND status IN ('succeeded','failed') ORDER BY created LIMIT 1", (now, now)).fetchone()
|
|
162
|
+
if row is None:
|
|
163
|
+
return False
|
|
164
|
+
# Claim notification before network IO. Crash-safe at-least-once;
|
|
165
|
+
# consumers deduplicate the stable id/status event ID.
|
|
166
|
+
db.execute('UPDATE jobs SET notification_attempts=notification_attempts+1,notify_after=? WHERE id=?', (now+300, row['id']))
|
|
167
|
+
url, secret = config
|
|
168
|
+
body = json.dumps({'id': row['id'], 'status': row['status']}, separators=(',', ':')).encode()
|
|
169
|
+
timestamp = str(int(now))
|
|
170
|
+
digest = hmac.new(secret.encode(), timestamp.encode()+b'.'+body, hashlib.sha256).hexdigest()
|
|
171
|
+
try:
|
|
172
|
+
if sender is None:
|
|
173
|
+
import requests
|
|
174
|
+
sender = requests.post
|
|
175
|
+
response = sender(url, data=body, headers={'Content-Type': 'application/json',
|
|
176
|
+
'X-Document-OCR-Timestamp': timestamp, 'X-Document-OCR-Signature': 'sha256='+digest,
|
|
177
|
+
'X-Document-OCR-Event': row['id']+':'+row['status']}, timeout=10, allow_redirects=False)
|
|
178
|
+
if 200 <= response.status_code < 300:
|
|
179
|
+
with self.connection() as db:
|
|
180
|
+
db.execute('UPDATE jobs SET notified=1 WHERE id=?', (row['id'],))
|
|
181
|
+
except Exception:
|
|
182
|
+
pass # Persisted retry schedule above survives process restarts.
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def webhook_configuration():
|
|
187
|
+
from urllib.parse import urlsplit
|
|
188
|
+
url, secret = os.getenv('DOCUMENT_OCR_WEBHOOK_URL'), os.getenv('DOCUMENT_OCR_WEBHOOK_SECRET')
|
|
189
|
+
if not url and not secret:
|
|
190
|
+
return None
|
|
191
|
+
if not url or not secret or len(secret) < 32:
|
|
192
|
+
raise ValueError('INVALID_WEBHOOK_CONFIGURATION')
|
|
193
|
+
parsed = urlsplit(url)
|
|
194
|
+
if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
|
|
195
|
+
raise ValueError('INVALID_WEBHOOK_URL')
|
|
196
|
+
return url, secret
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def configured_store() -> JobStore | None:
|
|
200
|
+
directory, key = os.getenv('DOCUMENT_OCR_JOBS_DIR'), os.getenv('DOCUMENT_OCR_JOB_KEY')
|
|
201
|
+
if not directory:
|
|
202
|
+
return None
|
|
203
|
+
if not key:
|
|
204
|
+
raise ValueError('MISSING_DOCUMENT_OCR_JOB_KEY')
|
|
205
|
+
return JobStore(directory, key, retention_seconds=int(os.getenv('DOCUMENT_OCR_JOB_RETENTION_SECONDS', '86400')))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main():
|
|
209
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
210
|
+
parser.add_argument('command', choices=['worker'])
|
|
211
|
+
parser.parse_args()
|
|
212
|
+
store = configured_store()
|
|
213
|
+
if store is None:
|
|
214
|
+
parser.error('Configure DOCUMENT_OCR_JOBS_DIR and DOCUMENT_OCR_JOB_KEY')
|
|
215
|
+
webhook_configuration()
|
|
216
|
+
from .model_setup import warm_up
|
|
217
|
+
warm_up()
|
|
218
|
+
stopping = False
|
|
219
|
+
def stop(*_args):
|
|
220
|
+
nonlocal stopping
|
|
221
|
+
stopping = True
|
|
222
|
+
signal.signal(signal.SIGTERM, stop)
|
|
223
|
+
signal.signal(signal.SIGINT, stop)
|
|
224
|
+
while not stopping:
|
|
225
|
+
worked = store.run_once()
|
|
226
|
+
store.deliver_notification()
|
|
227
|
+
if not worked:
|
|
228
|
+
time.sleep(1)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == '__main__':
|
|
232
|
+
main()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""KYC-only OCR language selection and result merging.
|
|
2
|
+
|
|
3
|
+
Passport OCR continues to use the existing ``run_ocr`` behavior. Operators
|
|
4
|
+
with a known non-passport document population can opt into additional
|
|
5
|
+
recognition models with ``DOCUMENT_OCR_KYC_LANGS``:
|
|
6
|
+
|
|
7
|
+
DOCUMENT_OCR_KYC_LANGS=en,devanagari
|
|
8
|
+
|
|
9
|
+
Running several recognizers increases latency and memory, so the default is the
|
|
10
|
+
existing automatic OCR pass. This module does not guess a script from an
|
|
11
|
+
English model's failed output; accuracy by language must be measured with the
|
|
12
|
+
private KYC benchmark.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .ocr_engine import TextRegion, run_ocr
|
|
23
|
+
|
|
24
|
+
KYC_LANGS_ENV = "DOCUMENT_OCR_KYC_LANGS"
|
|
25
|
+
SUPPORTED_KYC_LANGS = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"en",
|
|
28
|
+
"latin",
|
|
29
|
+
"devanagari",
|
|
30
|
+
"ka",
|
|
31
|
+
"ta",
|
|
32
|
+
"te",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
MAX_CONFIGURED_LANGUAGES = 4
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class KycOCRConfigError(ValueError):
|
|
39
|
+
"""Raised when KYC recognition-model configuration is invalid."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def configured_kyc_languages(raw: str | None = None) -> tuple[str, ...]:
|
|
43
|
+
"""Parse and validate the optional KYC recognition-model list."""
|
|
44
|
+
configured = os.getenv(KYC_LANGS_ENV) if raw is None else raw
|
|
45
|
+
if not configured or not configured.strip():
|
|
46
|
+
return ()
|
|
47
|
+
|
|
48
|
+
requested = [
|
|
49
|
+
token.strip().lower()
|
|
50
|
+
for token in configured.split(",")
|
|
51
|
+
if token.strip()
|
|
52
|
+
]
|
|
53
|
+
invalid = sorted(set(requested) - SUPPORTED_KYC_LANGS)
|
|
54
|
+
if invalid:
|
|
55
|
+
raise KycOCRConfigError(
|
|
56
|
+
f"Unsupported KYC OCR languages: {invalid}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
languages = list(dict.fromkeys(requested))
|
|
60
|
+
if len(languages) > MAX_CONFIGURED_LANGUAGES:
|
|
61
|
+
raise KycOCRConfigError(
|
|
62
|
+
f"At most {MAX_CONFIGURED_LANGUAGES} KYC OCR languages may be used"
|
|
63
|
+
)
|
|
64
|
+
return tuple(languages)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run_kyc_ocr(
|
|
68
|
+
image: np.ndarray,
|
|
69
|
+
*,
|
|
70
|
+
languages: Iterable[str] | None = None,
|
|
71
|
+
) -> list[TextRegion]:
|
|
72
|
+
"""Run the default or explicitly configured OCR passes for KYC documents."""
|
|
73
|
+
selected = (
|
|
74
|
+
tuple(
|
|
75
|
+
dict.fromkeys(
|
|
76
|
+
str(language).strip().lower()
|
|
77
|
+
for language in languages
|
|
78
|
+
if str(language).strip()
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
if languages is not None
|
|
82
|
+
else configured_kyc_languages()
|
|
83
|
+
)
|
|
84
|
+
if not selected:
|
|
85
|
+
return run_ocr(image)
|
|
86
|
+
|
|
87
|
+
invalid = [
|
|
88
|
+
language
|
|
89
|
+
for language in selected
|
|
90
|
+
if language not in SUPPORTED_KYC_LANGS
|
|
91
|
+
]
|
|
92
|
+
if invalid:
|
|
93
|
+
raise KycOCRConfigError(
|
|
94
|
+
f"Unsupported KYC OCR languages: {sorted(set(invalid))}"
|
|
95
|
+
)
|
|
96
|
+
if len(selected) > MAX_CONFIGURED_LANGUAGES:
|
|
97
|
+
raise KycOCRConfigError(
|
|
98
|
+
f"At most {MAX_CONFIGURED_LANGUAGES} KYC OCR languages may be used"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
regions: list[TextRegion] = []
|
|
102
|
+
for language in selected:
|
|
103
|
+
regions.extend(run_ocr(image, lang=language))
|
|
104
|
+
return _deduplicate_regions(regions)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _deduplicate_regions(regions: list[TextRegion]) -> list[TextRegion]:
|
|
108
|
+
"""Deduplicate repeated readings without discarding another script's text.
|
|
109
|
+
|
|
110
|
+
Confidence values produced by different recognition models are not
|
|
111
|
+
calibrated against one another. Keep distinct text alternatives for the
|
|
112
|
+
same box, while retaining only the highest-confidence copy of an identical
|
|
113
|
+
normalized reading.
|
|
114
|
+
"""
|
|
115
|
+
deduplicated: dict[
|
|
116
|
+
tuple[tuple[tuple[int, int], ...], str],
|
|
117
|
+
TextRegion,
|
|
118
|
+
] = {}
|
|
119
|
+
order: list[tuple[tuple[tuple[int, int], ...], str]] = []
|
|
120
|
+
|
|
121
|
+
for region in regions:
|
|
122
|
+
geometry = tuple(tuple(point) for point in region.bbox)
|
|
123
|
+
normalized_text = " ".join(region.text.split()).casefold()
|
|
124
|
+
key = (geometry, normalized_text)
|
|
125
|
+
previous = deduplicated.get(key)
|
|
126
|
+
if previous is None:
|
|
127
|
+
order.append(key)
|
|
128
|
+
deduplicated[key] = region
|
|
129
|
+
elif region.confidence > previous.confidence:
|
|
130
|
+
deduplicated[key] = region
|
|
131
|
+
|
|
132
|
+
return [deduplicated[key] for key in order]
|