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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -2
  3. package/dist/index.cjs +659 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +293 -0
  6. package/dist/index.d.ts +293 -0
  7. package/dist/index.js +616 -0
  8. package/dist/index.js.map +1 -0
  9. package/hosting/.dockerignore +17 -0
  10. package/hosting/.gcloudignore +14 -0
  11. package/hosting/FEATURES.md +172 -0
  12. package/hosting/HOSTING.md +268 -0
  13. package/hosting/LICENSE +21 -0
  14. package/hosting/README.md +369 -0
  15. package/hosting/core/__init__.py +0 -0
  16. package/hosting/core/aadhaar_extractor.py +298 -0
  17. package/hosting/core/back_page_extractor.py +492 -0
  18. package/hosting/core/barcodes.py +125 -0
  19. package/hosting/core/card_recovery.py +244 -0
  20. package/hosting/core/document_bundle.py +82 -0
  21. package/hosting/core/document_classifier.py +233 -0
  22. package/hosting/core/document_input.py +135 -0
  23. package/hosting/core/document_registry.py +143 -0
  24. package/hosting/core/driving_licence_extractor.py +510 -0
  25. package/hosting/core/ead_recovery.py +54 -0
  26. package/hosting/core/evidence.py +54 -0
  27. package/hosting/core/form_recovery.py +61 -0
  28. package/hosting/core/jobs.py +232 -0
  29. package/hosting/core/kyc_ocr.py +132 -0
  30. package/hosting/core/kyc_validation.py +411 -0
  31. package/hosting/core/model_setup.py +13 -0
  32. package/hosting/core/mrz_parser.py +336 -0
  33. package/hosting/core/mrz_recovery.py +193 -0
  34. package/hosting/core/npr_extractor.py +410 -0
  35. package/hosting/core/nrega_extractor.py +827 -0
  36. package/hosting/core/ocr_engine.py +207 -0
  37. package/hosting/core/page_classifier.py +137 -0
  38. package/hosting/core/pan_extractor.py +183 -0
  39. package/hosting/core/pipeline.py +712 -0
  40. package/hosting/core/preprocessor.py +297 -0
  41. package/hosting/core/recovery_budget.py +67 -0
  42. package/hosting/core/redaction.py +48 -0
  43. package/hosting/core/review.html +244 -0
  44. package/hosting/core/structured_documents.py +422 -0
  45. package/hosting/core/travel_mrz.py +164 -0
  46. package/hosting/core/travel_recovery.py +187 -0
  47. package/hosting/core/validator.py +396 -0
  48. package/hosting/core/validators.py +179 -0
  49. package/hosting/core/voter_id_extractor.py +363 -0
  50. package/hosting/deploy/cloudflare/deploy.sh +44 -0
  51. package/hosting/deploy/cloudflare/package-lock.json +3088 -0
  52. package/hosting/deploy/cloudflare/package.json +29 -0
  53. package/hosting/deploy/cloudflare/src/gateway.ts +42 -0
  54. package/hosting/deploy/cloudflare/src/index.ts +21 -0
  55. package/hosting/deploy/cloudflare/test/gateway.test.ts +59 -0
  56. package/hosting/deploy/cloudflare/tsconfig.json +12 -0
  57. package/hosting/deploy/cloudflare/worker-configuration.d.ts +16253 -0
  58. package/hosting/deploy/cloudflare/wrangler.jsonc +26 -0
  59. package/hosting/deploy/cloudrun/cloudbuild.yaml +18 -0
  60. package/hosting/deploy/cloudrun/deploy.sh +71 -0
  61. package/hosting/deploy/cloudrun/render_service.py +47 -0
  62. package/hosting/deploy/common.sh +14 -0
  63. package/hosting/deploy/docker/Dockerfile +16 -0
  64. package/hosting/deploy/docker/server.py +313 -0
  65. package/hosting/deploy/lambda/Dockerfile.lambda +11 -0
  66. package/hosting/deploy/lambda/deploy.sh +22 -0
  67. package/hosting/deploy/lambda/handler.py +132 -0
  68. package/hosting/deploy/lambda/template.yaml +59 -0
  69. package/hosting/deploy/local/dev.sh +18 -0
  70. package/hosting/deploy/server/Caddyfile +5 -0
  71. package/hosting/deploy/server/compose.jobs.yaml +36 -0
  72. package/hosting/deploy/server/compose.yaml +38 -0
  73. package/hosting/deploy/server/deploy.sh +23 -0
  74. package/hosting/infra/cloudrun/main.tf +104 -0
  75. package/hosting/infra/cloudrun/terraform.tfvars.example +7 -0
  76. package/hosting/infra/cloudrun/variables.tf +55 -0
  77. package/hosting/infra/lambda/main.tf +63 -0
  78. package/hosting/infra/lambda/terraform.tfvars.example +6 -0
  79. package/hosting/infra/lambda/variables.tf +37 -0
  80. package/hosting/pyproject.toml +65 -0
  81. package/hosting/requirements.lock +134 -0
  82. package/package.json +76 -4
  83. package/python/core/__init__.py +0 -0
  84. package/python/core/aadhaar_extractor.py +298 -0
  85. package/python/core/back_page_extractor.py +492 -0
  86. package/python/core/barcodes.py +125 -0
  87. package/python/core/card_recovery.py +244 -0
  88. package/python/core/document_bundle.py +82 -0
  89. package/python/core/document_classifier.py +233 -0
  90. package/python/core/document_input.py +135 -0
  91. package/python/core/document_registry.py +143 -0
  92. package/python/core/driving_licence_extractor.py +510 -0
  93. package/python/core/ead_recovery.py +54 -0
  94. package/python/core/evidence.py +54 -0
  95. package/python/core/form_recovery.py +61 -0
  96. package/python/core/jobs.py +232 -0
  97. package/python/core/kyc_ocr.py +132 -0
  98. package/python/core/kyc_validation.py +411 -0
  99. package/python/core/model_setup.py +13 -0
  100. package/python/core/mrz_parser.py +336 -0
  101. package/python/core/mrz_recovery.py +193 -0
  102. package/python/core/npr_extractor.py +410 -0
  103. package/python/core/nrega_extractor.py +827 -0
  104. package/python/core/ocr_engine.py +207 -0
  105. package/python/core/page_classifier.py +137 -0
  106. package/python/core/pan_extractor.py +183 -0
  107. package/python/core/pipeline.py +712 -0
  108. package/python/core/preprocessor.py +297 -0
  109. package/python/core/recovery_budget.py +67 -0
  110. package/python/core/redaction.py +48 -0
  111. package/python/core/review.html +244 -0
  112. package/python/core/structured_documents.py +422 -0
  113. package/python/core/travel_mrz.py +164 -0
  114. package/python/core/travel_recovery.py +187 -0
  115. package/python/core/validator.py +396 -0
  116. package/python/core/validators.py +179 -0
  117. package/python/core/voter_id_extractor.py +363 -0
  118. package/python/pyproject.toml +34 -0
  119. package/python/requirements.lock +134 -0
  120. package/python/server.py +313 -0
  121. package/scripts/dococr.mjs +163 -0
  122. package/scripts/postinstall.mjs +173 -0
  123. package/scripts/sync-hosting.mjs +35 -0
  124. 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,5 @@
1
+ {$DOMAIN} {
2
+ # Caddy obtains and renews HTTPS certificates automatically.
3
+ reverse_proxy ocr:8000
4
+ header Cache-Control "no-store"
5
+ }
@@ -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,6 @@
1
+ region = "ap-south-1"
2
+ image_uri = "123456789012.dkr.ecr.ap-south-1.amazonaws.com/existing-repo@sha256:REPLACE"
3
+ languages = "en"
4
+ # input_bucket = "your-private-documents"
5
+ input_prefix = "uploads/"
6
+ max_concurrency = 10
@@ -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"