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,132 @@
|
|
|
1
|
+
"""AWS direct invoke/API Gateway handler with bounded inline or allowlisted S3 input."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import binascii
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from core.kyc_ocr import KycOCRConfigError
|
|
11
|
+
from core.ocr_engine import OCRModelInitError
|
|
12
|
+
from core.pipeline import scan
|
|
13
|
+
from core.preprocessor import ImageQualityError
|
|
14
|
+
from core.document_registry import validate_document_hint
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger('document-ocr.lambda')
|
|
17
|
+
MAX_DECODED_SIZE = 10 * 1024 * 1024
|
|
18
|
+
MAX_ENCODED_SIZE = ((MAX_DECODED_SIZE + 2) // 3) * 4
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _response(status: int, payload: dict):
|
|
22
|
+
return {'statusCode': status, 'headers': {'Content-Type': 'application/json', 'Cache-Control': 'no-store'},
|
|
23
|
+
'body': json.dumps(payload)}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _error(status_code: int, code: str):
|
|
27
|
+
return _response(status_code, {'error': code})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _event_payload(event):
|
|
31
|
+
if not isinstance(event, dict):
|
|
32
|
+
raise ValueError('MISSING_IMAGE')
|
|
33
|
+
if 'body' not in event:
|
|
34
|
+
return event, False
|
|
35
|
+
body = event['body']
|
|
36
|
+
if not isinstance(body, str):
|
|
37
|
+
raise ValueError('INVALID_REQUEST_BODY')
|
|
38
|
+
if len(body) > MAX_ENCODED_SIZE + 65536:
|
|
39
|
+
raise ValueError('FILE_TOO_LARGE')
|
|
40
|
+
try:
|
|
41
|
+
if event.get('isBase64Encoded'):
|
|
42
|
+
body = base64.b64decode(body, validate=True).decode('utf-8')
|
|
43
|
+
parsed = json.loads(body)
|
|
44
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
45
|
+
raise ValueError('INVALID_REQUEST_BODY') from exc
|
|
46
|
+
if not isinstance(parsed, dict):
|
|
47
|
+
raise ValueError('INVALID_REQUEST_BODY')
|
|
48
|
+
return parsed, True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _read_s3(reference):
|
|
52
|
+
if not isinstance(reference, dict):
|
|
53
|
+
raise ValueError('INVALID_S3_REFERENCE')
|
|
54
|
+
bucket, key = reference.get('bucket'), reference.get('key')
|
|
55
|
+
version = reference.get('version_id')
|
|
56
|
+
allowed_bucket, prefix = os.getenv('DOCUMENT_OCR_S3_BUCKET'), os.getenv('DOCUMENT_OCR_S3_PREFIX', '')
|
|
57
|
+
if not allowed_bucket:
|
|
58
|
+
raise ValueError('S3_NOT_CONFIGURED')
|
|
59
|
+
if bucket != allowed_bucket or not isinstance(key, str) or not key or not key.startswith(prefix):
|
|
60
|
+
raise ValueError('S3_OBJECT_NOT_ALLOWED')
|
|
61
|
+
if version is not None and (not isinstance(version, str) or not version):
|
|
62
|
+
raise ValueError('INVALID_S3_REFERENCE')
|
|
63
|
+
# The execution role grants GetObject/GetObjectVersion only for this prefix.
|
|
64
|
+
# S3 keys are opaque: no decoding, filesystem traversal or remote URL fetch.
|
|
65
|
+
import boto3
|
|
66
|
+
args = {'Bucket': bucket, 'Key': key}
|
|
67
|
+
if version is not None:
|
|
68
|
+
args['VersionId'] = version
|
|
69
|
+
response = boto3.client('s3').get_object(**args)
|
|
70
|
+
body = response['Body']
|
|
71
|
+
try:
|
|
72
|
+
if response.get('ContentLength', 0) > MAX_DECODED_SIZE:
|
|
73
|
+
raise ValueError('FILE_TOO_LARGE')
|
|
74
|
+
data = body.read(MAX_DECODED_SIZE + 1)
|
|
75
|
+
if len(data) > MAX_DECODED_SIZE:
|
|
76
|
+
raise ValueError('FILE_TOO_LARGE')
|
|
77
|
+
return data
|
|
78
|
+
finally:
|
|
79
|
+
body.close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read_image(payload):
|
|
83
|
+
if 's3' in payload and 'image_base64' in payload:
|
|
84
|
+
raise ValueError('AMBIGUOUS_IMAGE_SOURCE')
|
|
85
|
+
if 's3' in payload:
|
|
86
|
+
return _read_s3(payload['s3'])
|
|
87
|
+
image = payload.get('image_base64')
|
|
88
|
+
if not isinstance(image, str) or not image:
|
|
89
|
+
raise ValueError('MISSING_IMAGE')
|
|
90
|
+
if len(image) > MAX_ENCODED_SIZE:
|
|
91
|
+
raise ValueError('FILE_TOO_LARGE')
|
|
92
|
+
try:
|
|
93
|
+
return base64.b64decode(image, validate=True)
|
|
94
|
+
except (binascii.Error, ValueError) as exc:
|
|
95
|
+
raise ValueError('INVALID_BASE64') from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def handler(event, context=None):
|
|
99
|
+
try:
|
|
100
|
+
payload, gateway = _event_payload(event)
|
|
101
|
+
document_type, country = validate_document_hint(payload.get('document_type'), payload.get('country'))
|
|
102
|
+
evidence = payload.get('include_evidence', False)
|
|
103
|
+
if not isinstance(evidence, bool):
|
|
104
|
+
raise ValueError('INVALID_INCLUDE_EVIDENCE')
|
|
105
|
+
data = _read_image(payload)
|
|
106
|
+
if not data:
|
|
107
|
+
raise ValueError('EMPTY_FILE')
|
|
108
|
+
if len(data) > MAX_DECODED_SIZE:
|
|
109
|
+
raise ValueError('FILE_TOO_LARGE')
|
|
110
|
+
options = {k:v for k,v in {'document_type':document_type, 'country':country, 'include_evidence':evidence}.items() if v}
|
|
111
|
+
except ValueError as exc:
|
|
112
|
+
return _error(400, str(exc))
|
|
113
|
+
except Exception:
|
|
114
|
+
logger.error('input_read_failed')
|
|
115
|
+
return _error(500, 'INPUT_READ_FAILED')
|
|
116
|
+
try:
|
|
117
|
+
result = scan(data, **options)
|
|
118
|
+
except ImageQualityError as exc:
|
|
119
|
+
return _error(400, str(exc))
|
|
120
|
+
except OCRModelInitError:
|
|
121
|
+
logger.error('model_init_failed')
|
|
122
|
+
return _error(503, 'MODEL_INIT_FAILED')
|
|
123
|
+
except KycOCRConfigError:
|
|
124
|
+
return _error(503, 'INVALID_KYC_OCR_CONFIG')
|
|
125
|
+
except Exception:
|
|
126
|
+
# Cloud SDK errors may contain bucket/key details; do not log payloads.
|
|
127
|
+
logger.error('scan_failed')
|
|
128
|
+
return _error(500, 'INTERNAL_ERROR')
|
|
129
|
+
output = result.to_dict()
|
|
130
|
+
logger.info('status=%s page_type=%s confidence=%s processing_ms=%s',
|
|
131
|
+
output.get('status'), output.get('pageType'), output.get('confidence'), output.get('processingMs'))
|
|
132
|
+
return _response(422 if output.get('status') == 'failure' else 200, output) if gateway else output
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
AWSTemplateFormatVersion: '2010-09-09'
|
|
2
|
+
Transform: AWS::Serverless-2016-10-31
|
|
3
|
+
Description: Document OCR image Lambda with IAM invocation and optional S3 input
|
|
4
|
+
Parameters:
|
|
5
|
+
KycLanguages:
|
|
6
|
+
Type: String
|
|
7
|
+
Default: en
|
|
8
|
+
ImageUri:
|
|
9
|
+
Type: String
|
|
10
|
+
Default: ''
|
|
11
|
+
InputBucket:
|
|
12
|
+
Type: String
|
|
13
|
+
Default: ''
|
|
14
|
+
InputPrefix:
|
|
15
|
+
Type: String
|
|
16
|
+
Default: uploads/
|
|
17
|
+
AllowedPattern: '[A-Za-z0-9._/-]+'
|
|
18
|
+
ReservedConcurrency:
|
|
19
|
+
Type: Number
|
|
20
|
+
Default: 10
|
|
21
|
+
MinValue: 1
|
|
22
|
+
Conditions:
|
|
23
|
+
HasInputBucket: !Not [!Equals [!Ref InputBucket, '']]
|
|
24
|
+
HasImage: !Not [!Equals [!Ref ImageUri, '']]
|
|
25
|
+
Resources:
|
|
26
|
+
DocumentOcrFunction:
|
|
27
|
+
Type: AWS::Serverless::Function
|
|
28
|
+
Metadata:
|
|
29
|
+
Dockerfile: deploy/lambda/Dockerfile.lambda
|
|
30
|
+
DockerContext: ../..
|
|
31
|
+
DockerTag: document-ocr-lambda
|
|
32
|
+
DockerBuildArgs:
|
|
33
|
+
DOCUMENT_OCR_KYC_LANGS: !Ref KycLanguages
|
|
34
|
+
Properties:
|
|
35
|
+
PackageType: Image
|
|
36
|
+
ImageUri: !If [HasImage, !Ref ImageUri, !Ref 'AWS::NoValue']
|
|
37
|
+
Architectures: [x86_64]
|
|
38
|
+
MemorySize: 2048
|
|
39
|
+
Timeout: 120
|
|
40
|
+
ReservedConcurrentExecutions: !Ref ReservedConcurrency
|
|
41
|
+
Environment:
|
|
42
|
+
Variables:
|
|
43
|
+
DOCUMENT_OCR_KYC_LANGS: !Ref KycLanguages
|
|
44
|
+
DOCUMENT_OCR_S3_BUCKET: !Ref InputBucket
|
|
45
|
+
DOCUMENT_OCR_S3_PREFIX: !Ref InputPrefix
|
|
46
|
+
Policies:
|
|
47
|
+
- AWSLambdaBasicExecutionRole
|
|
48
|
+
- !If
|
|
49
|
+
- HasInputBucket
|
|
50
|
+
- Statement:
|
|
51
|
+
- Effect: Allow
|
|
52
|
+
Action: s3:GetObject
|
|
53
|
+
Resource: !Sub 'arn:${AWS::Partition}:s3:::${InputBucket}/${InputPrefix}*'
|
|
54
|
+
- !Ref 'AWS::NoValue'
|
|
55
|
+
Outputs:
|
|
56
|
+
FunctionName:
|
|
57
|
+
Value: !Ref DocumentOcrFunction
|
|
58
|
+
FunctionArn:
|
|
59
|
+
Value: !GetAtt DocumentOcrFunction.Arn
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
|
+
ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
6
|
+
|
|
7
|
+
cd "$ROOT_DIR"
|
|
8
|
+
|
|
9
|
+
echo "Starting document-ocr dev server on http://localhost:8000"
|
|
10
|
+
echo "Docs: http://localhost:8000/docs"
|
|
11
|
+
echo ""
|
|
12
|
+
|
|
13
|
+
PYTHONPATH="$ROOT_DIR" uv run uvicorn deploy.docker.server:app \
|
|
14
|
+
--host 0.0.0.0 \
|
|
15
|
+
--port 8000 \
|
|
16
|
+
--reload \
|
|
17
|
+
--reload-dir core \
|
|
18
|
+
--reload-dir deploy/docker
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Opt-in only: use with compose.yaml and --profile jobs.
|
|
2
|
+
services:
|
|
3
|
+
ocr:
|
|
4
|
+
environment:
|
|
5
|
+
DOCUMENT_OCR_JOBS_DIR: /var/lib/document-ocr/jobs
|
|
6
|
+
DOCUMENT_OCR_JOB_KEY: ${DOCUMENT_OCR_JOB_KEY:?Set DOCUMENT_OCR_JOB_KEY}
|
|
7
|
+
DOCUMENT_OCR_JOB_RETENTION_SECONDS: ${DOCUMENT_OCR_JOB_RETENTION_SECONDS:-86400}
|
|
8
|
+
DOCUMENT_OCR_WEBHOOK_URL: ${DOCUMENT_OCR_WEBHOOK_URL:-}
|
|
9
|
+
DOCUMENT_OCR_WEBHOOK_SECRET: ${DOCUMENT_OCR_WEBHOOK_SECRET:-}
|
|
10
|
+
volumes:
|
|
11
|
+
- ocr_jobs:/var/lib/document-ocr/jobs
|
|
12
|
+
worker:
|
|
13
|
+
profiles: [jobs]
|
|
14
|
+
image: ${IMAGE_URI:-document-ocr:local}
|
|
15
|
+
pull_policy: never
|
|
16
|
+
command: [python, -m, core.jobs, worker]
|
|
17
|
+
environment:
|
|
18
|
+
DOCUMENT_OCR_KYC_LANGS: ${DOCUMENT_OCR_KYC_LANGS:-en}
|
|
19
|
+
DOCUMENT_OCR_JOBS_DIR: /var/lib/document-ocr/jobs
|
|
20
|
+
DOCUMENT_OCR_JOB_KEY: ${DOCUMENT_OCR_JOB_KEY:?Set DOCUMENT_OCR_JOB_KEY}
|
|
21
|
+
DOCUMENT_OCR_JOB_RETENTION_SECONDS: ${DOCUMENT_OCR_JOB_RETENTION_SECONDS:-86400}
|
|
22
|
+
DOCUMENT_OCR_WEBHOOK_URL: ${DOCUMENT_OCR_WEBHOOK_URL:-}
|
|
23
|
+
DOCUMENT_OCR_WEBHOOK_SECRET: ${DOCUMENT_OCR_WEBHOOK_SECRET:-}
|
|
24
|
+
volumes:
|
|
25
|
+
- ocr_jobs:/var/lib/document-ocr/jobs
|
|
26
|
+
healthcheck:
|
|
27
|
+
disable: true
|
|
28
|
+
depends_on:
|
|
29
|
+
ocr:
|
|
30
|
+
condition: service_healthy
|
|
31
|
+
restart: unless-stopped
|
|
32
|
+
init: true
|
|
33
|
+
cpus: 2.0
|
|
34
|
+
mem_limit: 2g
|
|
35
|
+
volumes:
|
|
36
|
+
ocr_jobs:
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
services:
|
|
2
|
+
ocr:
|
|
3
|
+
image: ${IMAGE_URI:-document-ocr:local}
|
|
4
|
+
build:
|
|
5
|
+
context: ../..
|
|
6
|
+
dockerfile: deploy/docker/Dockerfile
|
|
7
|
+
args:
|
|
8
|
+
DOCUMENT_OCR_KYC_LANGS: ${DOCUMENT_OCR_KYC_LANGS:-en}
|
|
9
|
+
environment:
|
|
10
|
+
DOCUMENT_OCR_KYC_LANGS: ${DOCUMENT_OCR_KYC_LANGS:-en}
|
|
11
|
+
API_TOKEN: ${API_TOKEN:?Set a strong API_TOKEN before starting the server}
|
|
12
|
+
expose: ['8000']
|
|
13
|
+
restart: unless-stopped
|
|
14
|
+
init: true
|
|
15
|
+
cpus: 2.0
|
|
16
|
+
mem_limit: 2g
|
|
17
|
+
healthcheck:
|
|
18
|
+
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready', timeout=2)"]
|
|
19
|
+
interval: 10s
|
|
20
|
+
timeout: 3s
|
|
21
|
+
retries: 15
|
|
22
|
+
start_period: 120s
|
|
23
|
+
caddy:
|
|
24
|
+
image: caddy:2-alpine
|
|
25
|
+
environment:
|
|
26
|
+
DOMAIN: ${DOMAIN:?Set DOMAIN to the DNS name pointing to this server}
|
|
27
|
+
ports: ['80:80', '443:443', '443:443/udp']
|
|
28
|
+
volumes:
|
|
29
|
+
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
30
|
+
- caddy_data:/data
|
|
31
|
+
- caddy_config:/config
|
|
32
|
+
depends_on:
|
|
33
|
+
ocr:
|
|
34
|
+
condition: service_healthy
|
|
35
|
+
restart: unless-stopped
|
|
36
|
+
volumes:
|
|
37
|
+
caddy_data:
|
|
38
|
+
caddy_config:
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Run on the target server (or use Docker's existing remote context).
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
5
|
+
cd "$ROOT_DIR"
|
|
6
|
+
source deploy/common.sh
|
|
7
|
+
hosting_defaults
|
|
8
|
+
require_command docker
|
|
9
|
+
: "${DOMAIN:?Set DOMAIN to a DNS name pointing to this server}"
|
|
10
|
+
: "${API_TOKEN:?Set API_TOKEN}"
|
|
11
|
+
COMPOSE=(docker compose -p "$DOCUMENT_OCR_NAME" -f deploy/server/compose.yaml)
|
|
12
|
+
if [[ "${DOCUMENT_OCR_JOBS_ENABLED:-false}" == true ]]; then
|
|
13
|
+
: "${DOCUMENT_OCR_JOB_KEY:?Set DOCUMENT_OCR_JOB_KEY for encrypted persistent jobs}"
|
|
14
|
+
COMPOSE+=(-f deploy/server/compose.jobs.yaml --profile jobs)
|
|
15
|
+
fi
|
|
16
|
+
"${COMPOSE[@]}" config --quiet
|
|
17
|
+
if [[ -n "${IMAGE_URI:-}" ]]; then
|
|
18
|
+
"${COMPOSE[@]}" pull ocr
|
|
19
|
+
"${COMPOSE[@]}" up --detach --wait --no-build
|
|
20
|
+
else
|
|
21
|
+
"${COMPOSE[@]}" up --detach --wait --build
|
|
22
|
+
fi
|
|
23
|
+
printf 'Document OCR is available at https://%s (Bearer authentication required).\n' "$DOMAIN"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
terraform {
|
|
2
|
+
required_version = ">= 1.5.0"
|
|
3
|
+
required_providers {
|
|
4
|
+
google = { source = "hashicorp/google", version = "~> 7.0" }
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
provider "google" { project = var.project_id }
|
|
8
|
+
locals {
|
|
9
|
+
services = toset(["run.googleapis.com", "artifactregistry.googleapis.com", "iam.googleapis.com", "secretmanager.googleapis.com"])
|
|
10
|
+
}
|
|
11
|
+
resource "google_project_service" "apis" {
|
|
12
|
+
for_each = local.services
|
|
13
|
+
project = var.project_id
|
|
14
|
+
service = each.value
|
|
15
|
+
disable_on_destroy = false
|
|
16
|
+
}
|
|
17
|
+
resource "google_artifact_registry_repository" "images" {
|
|
18
|
+
location = var.region
|
|
19
|
+
repository_id = var.name
|
|
20
|
+
format = "DOCKER"
|
|
21
|
+
depends_on = [google_project_service.apis]
|
|
22
|
+
}
|
|
23
|
+
resource "google_service_account" "runtime" {
|
|
24
|
+
account_id = "${substr(var.name, 0, 23)}-ocr-sa"
|
|
25
|
+
display_name = "Document OCR runtime"
|
|
26
|
+
depends_on = [google_project_service.apis]
|
|
27
|
+
}
|
|
28
|
+
resource "google_secret_manager_secret_iam_member" "token" {
|
|
29
|
+
count = var.api_token_secret == null ? 0 : 1
|
|
30
|
+
project = var.project_id
|
|
31
|
+
secret_id = var.api_token_secret
|
|
32
|
+
role = "roles/secretmanager.secretAccessor"
|
|
33
|
+
member = "serviceAccount:${google_service_account.runtime.email}"
|
|
34
|
+
}
|
|
35
|
+
resource "google_cloud_run_v2_service" "ocr" {
|
|
36
|
+
name = var.name
|
|
37
|
+
location = var.region
|
|
38
|
+
deletion_protection = var.deletion_protection
|
|
39
|
+
ingress = "INGRESS_TRAFFIC_ALL"
|
|
40
|
+
invoker_iam_disabled = false
|
|
41
|
+
template {
|
|
42
|
+
service_account = google_service_account.runtime.email
|
|
43
|
+
max_instance_request_concurrency = 1
|
|
44
|
+
timeout = "300s"
|
|
45
|
+
scaling {
|
|
46
|
+
min_instance_count = var.profile == "warm" ? 1 : 0
|
|
47
|
+
max_instance_count = var.max_instances
|
|
48
|
+
}
|
|
49
|
+
containers {
|
|
50
|
+
image = var.image_uri
|
|
51
|
+
ports { container_port = 8000 }
|
|
52
|
+
resources {
|
|
53
|
+
limits = { cpu = "2", memory = "2Gi" }
|
|
54
|
+
cpu_idle = true
|
|
55
|
+
startup_cpu_boost = true
|
|
56
|
+
}
|
|
57
|
+
env {
|
|
58
|
+
name = "DOCUMENT_OCR_KYC_LANGS"
|
|
59
|
+
value = var.languages
|
|
60
|
+
}
|
|
61
|
+
dynamic "env" {
|
|
62
|
+
for_each = var.api_token_secret == null ? [] : [var.api_token_secret]
|
|
63
|
+
content {
|
|
64
|
+
name = "API_TOKEN"
|
|
65
|
+
value_source {
|
|
66
|
+
secret_key_ref {
|
|
67
|
+
secret = env.value
|
|
68
|
+
version = var.api_token_version
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
startup_probe {
|
|
74
|
+
period_seconds = 5
|
|
75
|
+
failure_threshold = 30
|
|
76
|
+
timeout_seconds = 3
|
|
77
|
+
http_get {
|
|
78
|
+
path = "/ready"
|
|
79
|
+
port = 8000
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
liveness_probe {
|
|
83
|
+
period_seconds = 30
|
|
84
|
+
timeout_seconds = 3
|
|
85
|
+
http_get {
|
|
86
|
+
path = "/health"
|
|
87
|
+
port = 8000
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
depends_on = [google_project_service.apis, google_secret_manager_secret_iam_member.token]
|
|
93
|
+
}
|
|
94
|
+
# Authoritative only for run.invoker: explicitly excludes public invocation.
|
|
95
|
+
resource "google_cloud_run_v2_service_iam_binding" "invokers" {
|
|
96
|
+
project = var.project_id
|
|
97
|
+
location = var.region
|
|
98
|
+
name = google_cloud_run_v2_service.ocr.name
|
|
99
|
+
role = "roles/run.invoker"
|
|
100
|
+
members = var.invoker_members
|
|
101
|
+
}
|
|
102
|
+
output "url" { value = google_cloud_run_v2_service.ocr.uri }
|
|
103
|
+
output "runtime_service_account" { value = google_service_account.runtime.email }
|
|
104
|
+
output "repository" { value = "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.images.repository_id}" }
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
project_id = "your-project"
|
|
2
|
+
region = "asia-south1"
|
|
3
|
+
image_uri = "asia-south1-docker.pkg.dev/your-project/your-existing-repo/document-ocr@sha256:REPLACE"
|
|
4
|
+
invoker_members = ["serviceAccount:caller@your-project.iam.gserviceaccount.com"]
|
|
5
|
+
# api_token_secret = "document-ocr-api-token"
|
|
6
|
+
languages = "en"
|
|
7
|
+
profile = "economy"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
variable "project_id" { type = string }
|
|
2
|
+
variable "region" {
|
|
3
|
+
type = string
|
|
4
|
+
default = "asia-south1"
|
|
5
|
+
}
|
|
6
|
+
variable "name" {
|
|
7
|
+
type = string
|
|
8
|
+
default = "document-ocr"
|
|
9
|
+
}
|
|
10
|
+
variable "image_uri" {
|
|
11
|
+
type = string
|
|
12
|
+
description = "Prebuilt OCR image URI, preferably pinned with @sha256. Build outside Terraform."
|
|
13
|
+
}
|
|
14
|
+
variable "languages" {
|
|
15
|
+
type = string
|
|
16
|
+
default = "en"
|
|
17
|
+
}
|
|
18
|
+
variable "profile" {
|
|
19
|
+
type = string
|
|
20
|
+
default = "economy"
|
|
21
|
+
validation {
|
|
22
|
+
condition = contains(["economy", "warm"], var.profile)
|
|
23
|
+
error_message = "profile must be economy or warm."
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
variable "max_instances" {
|
|
27
|
+
type = number
|
|
28
|
+
default = 10
|
|
29
|
+
validation {
|
|
30
|
+
condition = var.max_instances >= 1 && floor(var.max_instances) == var.max_instances
|
|
31
|
+
error_message = "max_instances must be a positive integer."
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
variable "invoker_members" {
|
|
35
|
+
type = set(string)
|
|
36
|
+
description = "Named IAM principals such as serviceAccount:caller@project.iam.gserviceaccount.com."
|
|
37
|
+
default = []
|
|
38
|
+
validation {
|
|
39
|
+
condition = alltrue([for member in var.invoker_members : !contains(["allUsers", "allAuthenticatedUsers"], member)])
|
|
40
|
+
error_message = "Public IAM principals are not supported."
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
variable "api_token_secret" {
|
|
44
|
+
type = string
|
|
45
|
+
default = null
|
|
46
|
+
description = "Existing Secret Manager secret ID; secret contents never enter Terraform state."
|
|
47
|
+
}
|
|
48
|
+
variable "api_token_version" {
|
|
49
|
+
type = string
|
|
50
|
+
default = "latest"
|
|
51
|
+
}
|
|
52
|
+
variable "deletion_protection" {
|
|
53
|
+
type = bool
|
|
54
|
+
default = true
|
|
55
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
terraform {
|
|
2
|
+
required_version = ">= 1.5.0"
|
|
3
|
+
required_providers {
|
|
4
|
+
aws = { source = "hashicorp/aws", version = "~> 6.0" }
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
provider "aws" { region = var.region }
|
|
8
|
+
data "aws_partition" "current" {}
|
|
9
|
+
resource "aws_ecr_repository" "images" {
|
|
10
|
+
name = var.name
|
|
11
|
+
image_tag_mutability = "IMMUTABLE"
|
|
12
|
+
image_scanning_configuration { scan_on_push = true }
|
|
13
|
+
}
|
|
14
|
+
resource "aws_iam_role" "runtime" {
|
|
15
|
+
name = "${var.name}-runtime"
|
|
16
|
+
assume_role_policy = jsonencode({
|
|
17
|
+
Version = "2012-10-17"
|
|
18
|
+
Statement = [{ Effect = "Allow", Principal = { Service = "lambda.amazonaws.com" }, Action = "sts:AssumeRole" }]
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
resource "aws_iam_role_policy_attachment" "logs" {
|
|
22
|
+
role = aws_iam_role.runtime.name
|
|
23
|
+
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
|
24
|
+
}
|
|
25
|
+
resource "aws_iam_role_policy" "input" {
|
|
26
|
+
count = var.input_bucket == null ? 0 : 1
|
|
27
|
+
name = "read-ocr-input-prefix"
|
|
28
|
+
role = aws_iam_role.runtime.id
|
|
29
|
+
policy = jsonencode({
|
|
30
|
+
Version = "2012-10-17"
|
|
31
|
+
Statement = [{
|
|
32
|
+
Effect = "Allow"
|
|
33
|
+
Action = ["s3:GetObject"]
|
|
34
|
+
Resource = "arn:${data.aws_partition.current.partition}:s3:::${var.input_bucket}/${var.input_prefix}*"
|
|
35
|
+
}]
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
resource "aws_cloudwatch_log_group" "ocr" {
|
|
39
|
+
name = "/aws/lambda/${var.name}"
|
|
40
|
+
retention_in_days = 14
|
|
41
|
+
}
|
|
42
|
+
resource "aws_lambda_function" "ocr" {
|
|
43
|
+
function_name = var.name
|
|
44
|
+
role = aws_iam_role.runtime.arn
|
|
45
|
+
package_type = "Image"
|
|
46
|
+
image_uri = var.image_uri
|
|
47
|
+
architectures = ["x86_64"]
|
|
48
|
+
memory_size = 2048
|
|
49
|
+
timeout = 120
|
|
50
|
+
reserved_concurrent_executions = var.max_concurrency
|
|
51
|
+
environment {
|
|
52
|
+
variables = {
|
|
53
|
+
DOCUMENT_OCR_KYC_LANGS = var.languages
|
|
54
|
+
DOCUMENT_OCR_S3_BUCKET = var.input_bucket == null ? "" : var.input_bucket
|
|
55
|
+
DOCUMENT_OCR_S3_PREFIX = var.input_prefix
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
depends_on = [aws_cloudwatch_log_group.ocr, aws_iam_role_policy_attachment.logs, aws_iam_role_policy.input]
|
|
59
|
+
}
|
|
60
|
+
# No function URL/API Gateway is created; callers use IAM-authorized InvokeFunction.
|
|
61
|
+
output "function_name" { value = aws_lambda_function.ocr.function_name }
|
|
62
|
+
output "function_arn" { value = aws_lambda_function.ocr.arn }
|
|
63
|
+
output "repository_url" { value = aws_ecr_repository.images.repository_url }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
variable "name" {
|
|
2
|
+
type = string
|
|
3
|
+
default = "document-ocr"
|
|
4
|
+
}
|
|
5
|
+
variable "region" {
|
|
6
|
+
type = string
|
|
7
|
+
default = "ap-south-1"
|
|
8
|
+
}
|
|
9
|
+
variable "image_uri" {
|
|
10
|
+
type = string
|
|
11
|
+
description = "Prebuilt Lambda-compatible image in ECR in this region, preferably pinned by digest."
|
|
12
|
+
}
|
|
13
|
+
variable "languages" {
|
|
14
|
+
type = string
|
|
15
|
+
default = "en"
|
|
16
|
+
}
|
|
17
|
+
variable "input_bucket" {
|
|
18
|
+
type = string
|
|
19
|
+
default = null
|
|
20
|
+
description = "Existing input bucket. Only GetObject on input_prefix is granted; no uploads/deletes/listing."
|
|
21
|
+
}
|
|
22
|
+
variable "input_prefix" {
|
|
23
|
+
type = string
|
|
24
|
+
default = "uploads/"
|
|
25
|
+
validation {
|
|
26
|
+
condition = can(regex("^[A-Za-z0-9._/-]+/$", var.input_prefix)) && !strcontains(var.input_prefix, "..")
|
|
27
|
+
error_message = "Use a nonempty relative prefix ending in / without wildcards or .. segments."
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
variable "max_concurrency" {
|
|
31
|
+
type = number
|
|
32
|
+
default = 10
|
|
33
|
+
validation {
|
|
34
|
+
condition = var.max_concurrency >= 1 && floor(var.max_concurrency) == var.max_concurrency
|
|
35
|
+
error_message = "max_concurrency must be a positive integer."
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "india-document-ocr"
|
|
3
|
+
version = "3.1.2"
|
|
4
|
+
description = "Local OCR for passports, Indian KYC and US documents, with evidence, review tools, and portable workers"
|
|
5
|
+
requires-python = ">=3.14"
|
|
6
|
+
license = { file = "LICENSE" }
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Piyush Gambhir" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["ocr", "passport", "aadhaar", "pan", "kyc", "nrega", "npr", "rapidocr"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
"Topic :: Scientific/Engineering :: Image Processing",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"rapidocr>=3.0.0",
|
|
21
|
+
"onnxruntime>=1.17,<2",
|
|
22
|
+
"opencv-python>=4.8,<5",
|
|
23
|
+
"pillow>=12.3,<13",
|
|
24
|
+
"numpy>=1.24,<3",
|
|
25
|
+
"rapidfuzz>=3.0,<4",
|
|
26
|
+
"fastapi>=0.135.1,<1",
|
|
27
|
+
"starlette>=1.3.1,<2",
|
|
28
|
+
"uvicorn[standard]>=0.27,<1",
|
|
29
|
+
"click>=8.3.3,<9",
|
|
30
|
+
"python-multipart>=0.0.31,<1",
|
|
31
|
+
"requests>=2.33,<3",
|
|
32
|
+
"idna>=3.15,<4",
|
|
33
|
+
"urllib3>=2.7,<3",
|
|
34
|
+
"pypdfium2>=5.0,<6",
|
|
35
|
+
"pillow-heif>=1.3,<2",
|
|
36
|
+
"zxing-cpp>=3.1.1,<4",
|
|
37
|
+
"pycountry>=26.2.16,<27",
|
|
38
|
+
"cryptography>=50.0.1,<51",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/piyush-gambhir/document-ocr"
|
|
43
|
+
Repository = "https://github.com/piyush-gambhir/document-ocr"
|
|
44
|
+
Issues = "https://github.com/piyush-gambhir/document-ocr/issues"
|
|
45
|
+
|
|
46
|
+
[project.optional-dependencies]
|
|
47
|
+
dev = [
|
|
48
|
+
"pytest>=9.0.3,<10",
|
|
49
|
+
"pytest-asyncio>=1.0,<2",
|
|
50
|
+
"pytest-cov>=7.0,<8",
|
|
51
|
+
"pygments>=2.20,<3",
|
|
52
|
+
"httpx>=0.27,<1",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[build-system]
|
|
56
|
+
requires = ["hatchling"]
|
|
57
|
+
build-backend = "hatchling.build"
|
|
58
|
+
|
|
59
|
+
[tool.hatch.build.targets.wheel]
|
|
60
|
+
packages = ["core"]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests/python"]
|
|
64
|
+
pythonpath = ["."]
|
|
65
|
+
asyncio_mode = "auto"
|