zombee11 commited on
Commit
4de67a0
Β·
verified Β·
1 Parent(s): 3f058c8

Upload 20 files

Browse files
Files changed (20) hide show
  1. .env.example +19 -0
  2. .gitkeep +1 -0
  3. Dockerfile +32 -0
  4. README.md +233 -9
  5. __init__.py +0 -0
  6. auth.py +32 -0
  7. cleanup.py +84 -0
  8. detection_service.py +231 -0
  9. docker-compose.yml +19 -0
  10. file_service.py +128 -0
  11. main.py +97 -0
  12. masking.py +181 -0
  13. masking_service.py +101 -0
  14. ocr_service.py +166 -0
  15. pytest.ini +5 -0
  16. requirements.txt +22 -0
  17. run.sh +28 -0
  18. schemas.py +37 -0
  19. test_core.py +131 -0
  20. verhoeff.py +54 -0
.env.example ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Aadhaar Masking API β€” Environment Configuration
2
+ # Copy to .env and fill in values before running.
3
+
4
+ # Comma-separated list of valid bearer tokens
5
+ API_KEYS=demo-key-12345,prod-key-replace-me
6
+
7
+ # Temporary storage directory for masked files
8
+ MASK_STORE_DIR=/tmp/aadhaar_masked
9
+
10
+ # How long (seconds) to keep masked files before auto-delete (default: 5 min)
11
+ MASK_FILE_TTL=300
12
+
13
+ # Confidence threshold below which requests get flagged for manual review (0.0–1.0)
14
+ CONFIDENCE_THRESHOLD=0.5
15
+
16
+ # Uvicorn bind settings (used by run.sh)
17
+ HOST=0.0.0.0
18
+ PORT=8000
19
+ WORKERS=2
.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ /* Static assets placeholder */
Dockerfile ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # System deps: Tesseract, OpenCV headless libs, poppler
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ tesseract-ocr \
6
+ tesseract-ocr-hin \
7
+ libglib2.0-0 \
8
+ libsm6 \
9
+ libxrender1 \
10
+ libxext6 \
11
+ libgl1 \
12
+ zbar-tools \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ WORKDIR /app
16
+
17
+ COPY requirements.txt .
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ COPY . .
21
+
22
+ # Non-root user
23
+ RUN useradd -m appuser && chown -R appuser /app
24
+ USER appuser
25
+
26
+ EXPOSE 8000
27
+
28
+ ENV PYTHONDONTWRITEBYTECODE=1 \
29
+ PYTHONUNBUFFERED=1 \
30
+ API_KEYS=demo-key-12345
31
+
32
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
README.md CHANGED
@@ -1,12 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Maskingmasking
3
- emoji: 😻
4
- colorFrom: green
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- short_description: aadhaar masking
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
+ # πŸ›‘οΈ Aadhaar Masking API
2
+
3
+ Production-grade REST API that detects and masks sensitive information from Aadhaar documents β€” Aadhaar numbers, VIDs, and QR codes β€” from JPG, PNG, and PDF files.
4
+
5
+ ---
6
+
7
+ ## Features
8
+
9
+ | Feature | Detail |
10
+ |---|---|
11
+ | **Aadhaar Number Detection** | 12-digit regex + Verhoeff checksum validation |
12
+ | **VID Detection** | 16-digit pattern (first digit 9), configurable masking |
13
+ | **QR Code Detection** | OpenCV-native + contour fallback, solid black-out |
14
+ | **Hybrid OCR** | EasyOCR (primary) + Tesseract (fallback), multi-pass |
15
+ | **Multi-language** | English + Hindi OCR support |
16
+ | **Image Preprocessing** | Grayscale, denoise, Otsu + adaptive thresholding, upscaling |
17
+ | **Confidence Scoring** | Per-item and overall score, low-confidence flagging |
18
+ | **Output Formats** | Same format as input or convert to PDF |
19
+ | **Security** | Bearer auth, ephemeral storage (auto-delete, TTL=5min), no document logging |
20
+ | **Deployment** | Docker + docker-compose ready |
21
+
22
+ ---
23
+
24
+ ## Project Structure
25
+
26
+ ```
27
+ aadhaar-masking/
28
+ β”œβ”€β”€ app/
29
+ β”‚ β”œβ”€β”€ main.py # FastAPI app, middleware, lifespan
30
+ β”‚ β”œβ”€β”€ routers/
31
+ β”‚ β”‚ └── masking.py # POST /aadhaar/mask, GET /aadhaar/download/{id}
32
+ β”‚ β”œβ”€β”€ services/
33
+ β”‚ β”‚ β”œβ”€β”€ ocr_service.py # Hybrid EasyOCR + Tesseract, preprocessing
34
+ β”‚ β”‚ β”œβ”€β”€ detection_service.py # Aadhaar / VID / QR detection pipeline
35
+ β”‚ β”‚ β”œβ”€β”€ masking_service.py # Applies pixel-level masks
36
+ β”‚ β”‚ └── file_service.py # PDF/image load & rebuild
37
+ β”‚ β”œβ”€β”€ models/
38
+ β”‚ β”‚ └── schemas.py # Pydantic request/response models
39
+ β”‚ └── utils/
40
+ β”‚ β”œβ”€β”€ verhoeff.py # Verhoeff checksum algorithm
41
+ β”‚ β”œβ”€β”€ auth.py # Bearer API-key middleware
42
+ β”‚ └── cleanup.py # Ephemeral file store + scheduler
43
+ β”œβ”€β”€ tests/
44
+ β”‚ └── test_core.py # Unit tests (verhoeff, regex, masking, OCR)
45
+ β”œβ”€β”€ templates/
46
+ β”‚ └── index.html # Developer console frontend
47
+ β”œβ”€β”€ static/ # Static assets
48
+ β”œβ”€β”€ Dockerfile
49
+ β”œβ”€β”€ docker-compose.yml
50
+ β”œβ”€β”€ requirements.txt
51
+ β”œβ”€β”€ .env.example
52
+ β”œβ”€β”€ pytest.ini
53
+ └── run.sh
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Quick Start
59
+
60
+ ### 1. Install dependencies
61
+
62
+ ```bash
63
+ # System packages (Ubuntu/Debian)
64
+ sudo apt-get install tesseract-ocr tesseract-ocr-hin
65
+
66
+ # Python packages
67
+ pip install -r requirements.txt
68
+ ```
69
+
70
+ ### 2. Configure environment
71
+
72
+ ```bash
73
+ cp .env.example .env
74
+ # Edit .env β€” set API_KEYS, TTL, etc.
75
+ ```
76
+
77
+ ### 3. Run the server
78
+
79
+ ```bash
80
+ bash run.sh
81
+ # or directly:
82
+ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
83
+ ```
84
+
85
+ ### 4. Open the developer console
86
+
87
+ Visit **http://localhost:8000** for the interactive test UI.
88
+ Swagger docs at **http://localhost:8000/docs**.
89
+
90
+ ---
91
+
92
+ ## Docker
93
+
94
+ ```bash
95
+ # Build and run
96
+ docker-compose up --build
97
+
98
+ # Or manually
99
+ docker build -t aadhaar-masking .
100
+ docker run -p 8000:8000 -e API_KEYS=my-key aadhaar-masking
101
+ ```
102
+
103
+ ---
104
+
105
+ ## API Reference
106
+
107
+ ### `POST /aadhaar/mask`
108
+
109
+ Masks an Aadhaar document.
110
+
111
+ **Headers**
112
+
113
+ ```
114
+ Authorization: Bearer <API_KEY>
115
+ Content-Type: multipart/form-data
116
+ ```
117
+
118
+ **Form fields**
119
+
120
+ | Field | Type | Required | Default | Description |
121
+ |---|---|---|---|---|
122
+ | `file` | file | βœ… | β€” | JPG, PNG, or PDF (max 20MB) |
123
+ | `mask_type` | string | ❌ | `partial` | `partial` or `full` |
124
+ | `output_format` | string | ❌ | `image` | `image` or `pdf` |
125
+
126
+ **Success response (200)**
127
+
128
+ ```json
129
+ {
130
+ "status": "success",
131
+ "masked_file_url": "http://localhost:8000/aadhaar/download/abc123",
132
+ "detected": {
133
+ "aadhaar": true,
134
+ "vid": false,
135
+ "qr": true
136
+ },
137
+ "confidence_score": 0.87,
138
+ "flagged_for_review": false,
139
+ "processing_time_ms": 312.4,
140
+ "request_id": "a1b2c3d4"
141
+ }
142
+ ```
143
+
144
+ **Error response (400)**
145
+
146
+ ```json
147
+ {
148
+ "status": "error",
149
+ "message": "No Aadhaar number, VID, or QR code detected in the document.",
150
+ "request_id": "a1b2c3d4"
151
+ }
152
+ ```
153
+
154
+ ### `GET /aadhaar/download/{file_id}`
155
+
156
+ Download the masked file. File is automatically deleted after TTL (default 5 minutes).
157
+
158
+ ### `GET /health`
159
+
160
+ ```json
161
+ { "status": "ok", "version": "1.0.0" }
162
+ ```
163
+
164
+ ---
165
+
166
+ ## Masking Rules
167
+
168
+ | Type | Partial (default) | Full |
169
+ |---|---|---|
170
+ | Aadhaar (12-digit) | `XXXX XXXX 1234` | `XXXX XXXX XXXX` |
171
+ | VID (16-digit) | `XXXX XXXX XXXX 5678` | `XXXX XXXX XXXX XXXX` |
172
+ | QR Code | Solid black box | Solid black box |
173
+
174
  ---
175
+
176
+ ## Detection Pipeline
177
+
178
+ ```
179
+ Upload
180
+ β”‚
181
+ β”œβ”€ File validation (type, size)
182
+ β”‚
183
+ β”œβ”€ Load frames (PDF β†’ per-page, image β†’ single frame)
184
+ β”‚
185
+ └─ Per frame:
186
+ β”œβ”€ QR Detection (OpenCV QRCodeDetector β†’ contour fallback)
187
+ β”‚
188
+ β”œβ”€ Image Preprocessing
189
+ β”‚ β”œβ”€ Grayscale
190
+ β”‚ β”œβ”€ Denoise (fastNlMeansDenoising)
191
+ β”‚ β”œβ”€ Otsu threshold
192
+ β”‚ β”œβ”€ Adaptive threshold
193
+ β”‚ └─ Upscale (if low-res)
194
+ β”‚
195
+ β”œβ”€ OCR (multi-pass over each variant)
196
+ β”‚ β”œβ”€ EasyOCR (en + hi) β€” primary
197
+ β”‚ └─ Tesseract (eng+hin, PSM 11) β€” fallback
198
+ β”‚
199
+ └─ Pattern Matching
200
+ β”œβ”€ Aadhaar regex β†’ Verhoeff checksum β†’ phone-number filter
201
+ └─ VID regex β†’ 16-digit + first digit 9
202
+ ```
203
+
204
+ ---
205
+
206
+ ## Security Notes
207
+
208
+ - Documents are **never stored permanently**. Processed files live in `/tmp/aadhaar_masked/` and are auto-deleted after TTL.
209
+ - Only **metadata** is logged (request ID, detection flags, timing). No Aadhaar numbers or document content is written to logs.
210
+ - API key validation on every request via `Authorization: Bearer` header.
211
+ - In production, serve behind HTTPS (nginx/Caddy reverse proxy).
212
+
213
+ ---
214
+
215
+ ## Running Tests
216
+
217
+ ```bash
218
+ pytest tests/ -v
219
+ ```
220
+
221
+ ---
222
+
223
+ ## Environment Variables
224
+
225
+ | Variable | Default | Description |
226
+ |---|---|---|
227
+ | `API_KEYS` | `demo-key-12345` | Comma-separated valid API keys |
228
+ | `MASK_STORE_DIR` | `/tmp/aadhaar_masked` | Temp storage directory |
229
+ | `MASK_FILE_TTL` | `300` | Seconds before auto-delete |
230
+ | `CONFIDENCE_THRESHOLD` | `0.5` | Below this β†’ flagged for review |
231
+
232
  ---
233
 
234
+ ## License
235
+
236
+ MIT
__init__.py ADDED
File without changes
auth.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Simple API-key bearer authentication."""
2
+
3
+ import os
4
+ import logging
5
+ from fastapi import HTTPException, Security, status
6
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ _security = HTTPBearer(auto_error=False)
11
+
12
+ # In production, load from vault / env. Multiple keys supported (comma-separated).
13
+ _RAW = os.environ.get("API_KEYS", "demo-key-12345,test-key-67890")
14
+ VALID_KEYS: set[str] = {k.strip() for k in _RAW.split(",") if k.strip()}
15
+
16
+
17
+ def verify_api_key(
18
+ credentials: HTTPAuthorizationCredentials = Security(_security),
19
+ ) -> str:
20
+ if credentials is None:
21
+ raise HTTPException(
22
+ status_code=status.HTTP_401_UNAUTHORIZED,
23
+ detail="Missing Authorization header. Use: Bearer <API_KEY>",
24
+ )
25
+ token = credentials.credentials
26
+ if token not in VALID_KEYS:
27
+ logger.warning("Rejected invalid API key (last4=%s)", token[-4:] if len(token) >= 4 else "??")
28
+ raise HTTPException(
29
+ status_code=status.HTTP_403_FORBIDDEN,
30
+ detail="Invalid API key.",
31
+ )
32
+ return token
cleanup.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ephemeral file store: saves processed files to /tmp with auto-deletion.
3
+ Files are removed after TTL_SECONDS (default 5 minutes) or on next cleanup pass.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from pathlib import Path
14
+ from typing import Dict, Tuple
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _STORE_DIR = Path(os.environ.get("MASK_STORE_DIR", "/tmp/aadhaar_masked"))
19
+ _TTL_SECONDS = int(os.environ.get("MASK_FILE_TTL", 300)) # 5 minutes default
20
+
21
+ _registry: Dict[str, float] = {} # file_id -> created_at
22
+ _lock = threading.Lock()
23
+
24
+
25
+ def _ensure_dir():
26
+ _STORE_DIR.mkdir(parents=True, exist_ok=True)
27
+
28
+
29
+ def save_file(data: bytes, ext: str) -> str:
30
+ """
31
+ Save masked file bytes; return a file_id used to build the download URL.
32
+ """
33
+ _ensure_dir()
34
+ file_id = uuid.uuid4().hex
35
+ path = _STORE_DIR / f"{file_id}.{ext.lstrip('.')}"
36
+ path.write_bytes(data)
37
+ with _lock:
38
+ _registry[file_id] = time.time()
39
+ logger.info("Saved masked file: %s (TTL %ds)", path.name, _TTL_SECONDS)
40
+ return file_id
41
+
42
+
43
+ def get_file_path(file_id: str) -> Path | None:
44
+ """Return the Path for a file_id, or None if not found / expired."""
45
+ with _lock:
46
+ created = _registry.get(file_id)
47
+ if created is None:
48
+ return None
49
+ for p in _STORE_DIR.glob(f"{file_id}.*"):
50
+ if p.exists():
51
+ return p
52
+ return None
53
+
54
+
55
+ def delete_file(file_id: str) -> None:
56
+ with _lock:
57
+ _registry.pop(file_id, None)
58
+ for p in _STORE_DIR.glob(f"{file_id}.*"):
59
+ try:
60
+ p.unlink()
61
+ logger.debug("Deleted: %s", p.name)
62
+ except OSError:
63
+ pass
64
+
65
+
66
+ def _cleanup_loop():
67
+ while True:
68
+ time.sleep(60)
69
+ now = time.time()
70
+ expired = []
71
+ with _lock:
72
+ for fid, created in list(_registry.items()):
73
+ if now - created > _TTL_SECONDS:
74
+ expired.append(fid)
75
+ for fid in expired:
76
+ delete_file(fid)
77
+ if expired:
78
+ logger.info("Cleanup: removed %d expired files.", len(expired))
79
+
80
+
81
+ def start_cleanup_scheduler():
82
+ t = threading.Thread(target=_cleanup_loop, daemon=True, name="file-cleanup")
83
+ t.start()
84
+ logger.info("File cleanup scheduler started (TTL=%ds).", _TTL_SECONDS)
detection_service.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Detection service: finds Aadhaar numbers, VIDs, and QR codes in images.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import re
9
+ from dataclasses import dataclass, field
10
+ from typing import List, Optional, Tuple
11
+
12
+ import cv2
13
+ import numpy as np
14
+
15
+ from app.services.ocr_service import OCRResult, run_ocr
16
+ from app.utils.verhoeff import validate as verhoeff_validate
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # ── Regex patterns ─────────────────────────────────────────────────────────────
21
+
22
+ # Aadhaar: 12 digits, may be grouped as XXXX XXXX XXXX or solid
23
+ _AADHAAR_RE = re.compile(
24
+ r"(?<!\d)" # no leading digit
25
+ r"([2-9]\d{3})" # first group (starts 2-9, avoids phone-like starts)
26
+ r"[\s\-]?"
27
+ r"(\d{4})"
28
+ r"[\s\-]?"
29
+ r"(\d{4})"
30
+ r"(?!\d)" # no trailing digit
31
+ )
32
+
33
+ # VID: 16-digit number (first digit 9 per UIDAI spec)
34
+ _VID_RE = re.compile(
35
+ r"(?<!\d)"
36
+ r"(9\d{3})"
37
+ r"[\s\-]?"
38
+ r"(\d{4})"
39
+ r"[\s\-]?"
40
+ r"(\d{4})"
41
+ r"[\s\-]?"
42
+ r"(\d{4})"
43
+ r"(?!\d)"
44
+ )
45
+
46
+ # Phone numbers to avoid false-positives (Indian mobiles start with 6-9, 10 digits)
47
+ _PHONE_RE = re.compile(r"(?<!\d)[6-9]\d{9}(?!\d)")
48
+
49
+
50
+ @dataclass
51
+ class DetectedItem:
52
+ kind: str # "aadhaar" | "vid" | "qr"
53
+ value: Optional[str]
54
+ bbox: Optional[Tuple[int, int, int, int]] # x, y, w, h in image coords
55
+ confidence: float # 0-1
56
+ raw_text: Optional[str] = None
57
+
58
+
59
+ @dataclass
60
+ class DetectionOutput:
61
+ items: List[DetectedItem] = field(default_factory=list)
62
+
63
+ @property
64
+ def has_aadhaar(self) -> bool:
65
+ return any(i.kind == "aadhaar" for i in self.items)
66
+
67
+ @property
68
+ def has_vid(self) -> bool:
69
+ return any(i.kind == "vid" for i in self.items)
70
+
71
+ @property
72
+ def has_qr(self) -> bool:
73
+ return any(i.kind == "qr" for i in self.items)
74
+
75
+ @property
76
+ def overall_confidence(self) -> float:
77
+ if not self.items:
78
+ return 0.0
79
+ return round(sum(i.confidence for i in self.items) / len(self.items), 3)
80
+
81
+
82
+ # ── QR Detection ───────────────────────────────────────────────────────────────
83
+
84
+ def _detect_qr_regions(img: np.ndarray) -> List[Tuple[int, int, int, int]]:
85
+ """
86
+ Detect QR code bounding boxes without decoding content.
87
+ Uses OpenCV QRCodeDetector; falls back to contour heuristics.
88
+ """
89
+ regions = []
90
+
91
+ # Method 1: OpenCV native QR detector
92
+ try:
93
+ qr_detector = cv2.QRCodeDetector()
94
+ retval, points = qr_detector.detect(img)
95
+ if retval and points is not None:
96
+ pts = points[0].astype(int)
97
+ x, y, w, h = cv2.boundingRect(pts)
98
+ # Add 10% padding
99
+ pad_x = max(10, int(w * 0.1))
100
+ pad_y = max(10, int(h * 0.1))
101
+ regions.append((
102
+ max(0, x - pad_x), max(0, y - pad_y),
103
+ w + 2 * pad_x, h + 2 * pad_y,
104
+ ))
105
+ return regions
106
+ except Exception as e:
107
+ logger.debug("QR detector (native) failed: %s", e)
108
+
109
+ # Method 2: Contour-based heuristic (square-ish dark blobs)
110
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img
111
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
112
+ _, thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
113
+ contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
114
+
115
+ img_area = img.shape[0] * img.shape[1]
116
+ for cnt in contours:
117
+ area = cv2.contourArea(cnt)
118
+ if area < 2000 or area > img_area * 0.25:
119
+ continue
120
+ x, y, w, h = cv2.boundingRect(cnt)
121
+ aspect = w / h if h else 0
122
+ if 0.7 < aspect < 1.4: # roughly square
123
+ # Check for finder-pattern-like density
124
+ roi = thresh[y:y+h, x:x+w]
125
+ density = np.sum(roi == 255) / (w * h)
126
+ if 0.25 < density < 0.65:
127
+ regions.append((x, y, w, h))
128
+
129
+ return regions
130
+
131
+
132
+ # ── Number Detection ───────────────────────────────────────────────────────────
133
+
134
+ def _ocr_results_to_full_text(results: List[OCRResult]) -> str:
135
+ return " ".join(r.text for r in results)
136
+
137
+
138
+ def _find_aadhaar_in_text(
139
+ text: str, ocr_results: List[OCRResult]
140
+ ) -> List[DetectedItem]:
141
+ detected = []
142
+ for m in _AADHAAR_RE.finditer(text):
143
+ raw = m.group(0).replace(" ", "").replace("-", "")
144
+ if len(raw) != 12:
145
+ continue
146
+ # Reject if overlaps with phone number pattern
147
+ surrounding = text[max(0, m.start()-2):m.end()+2]
148
+ if _PHONE_RE.search(surrounding):
149
+ logger.debug("Skipping potential phone number match: %s", raw)
150
+ continue
151
+ if not verhoeff_validate(raw):
152
+ logger.debug("Failed Verhoeff checksum: %s", raw)
153
+ continue
154
+
155
+ # Find the OCR result that contains this match
156
+ conf = 0.7
157
+ bbox = None
158
+ for r in ocr_results:
159
+ cleaned = r.text.replace(" ", "").replace("-", "")
160
+ if raw in cleaned:
161
+ conf = r.confidence
162
+ bbox = r.bbox
163
+ break
164
+
165
+ formatted = f"{raw[:4]} {raw[4:8]} {raw[8:]}"
166
+ detected.append(DetectedItem(
167
+ kind="aadhaar", value=formatted, bbox=bbox,
168
+ confidence=min(1.0, conf * 1.1), # small boost for checksum pass
169
+ raw_text=m.group(0),
170
+ ))
171
+ return detected
172
+
173
+
174
+ def _find_vid_in_text(
175
+ text: str, ocr_results: List[OCRResult]
176
+ ) -> List[DetectedItem]:
177
+ detected = []
178
+ for m in _VID_RE.finditer(text):
179
+ raw = m.group(0).replace(" ", "").replace("-", "")
180
+ if len(raw) != 16:
181
+ continue
182
+
183
+ conf = 0.65
184
+ bbox = None
185
+ for r in ocr_results:
186
+ cleaned = r.text.replace(" ", "").replace("-", "")
187
+ if raw in cleaned:
188
+ conf = r.confidence
189
+ bbox = r.bbox
190
+ break
191
+
192
+ formatted = f"{raw[:4]} {raw[4:8]} {raw[8:12]} {raw[12:]}"
193
+ detected.append(DetectedItem(
194
+ kind="vid", value=formatted, bbox=bbox,
195
+ confidence=conf, raw_text=m.group(0),
196
+ ))
197
+ return detected
198
+
199
+
200
+ # ── Main entrypoint ────────────────────────────────────────────────────────────
201
+
202
+ def detect_all(img: np.ndarray) -> DetectionOutput:
203
+ """
204
+ Run full detection pipeline on a single image frame.
205
+ Returns a DetectionOutput with all found items.
206
+ """
207
+ output = DetectionOutput()
208
+
209
+ # 1. QR detection (no OCR needed)
210
+ qr_regions = _detect_qr_regions(img)
211
+ for bbox in qr_regions:
212
+ output.items.append(DetectedItem(
213
+ kind="qr", value=None, bbox=bbox, confidence=0.9,
214
+ ))
215
+
216
+ # 2. OCR-based detection
217
+ ocr_results = run_ocr(img)
218
+ full_text = _ocr_results_to_full_text(ocr_results)
219
+ logger.debug("OCR full text: %s", full_text)
220
+
221
+ aadhaar_items = _find_aadhaar_in_text(full_text, ocr_results)
222
+ vid_items = _find_vid_in_text(full_text, ocr_results)
223
+
224
+ output.items.extend(aadhaar_items)
225
+ output.items.extend(vid_items)
226
+
227
+ logger.info(
228
+ "Detection complete β€” aadhaar=%d vid=%d qr=%d",
229
+ len(aadhaar_items), len(vid_items), len(qr_regions),
230
+ )
231
+ return output
docker-compose.yml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3.9"
2
+
3
+ services:
4
+ api:
5
+ build: .
6
+ ports:
7
+ - "8000:8000"
8
+ environment:
9
+ - API_KEYS=demo-key-12345,prod-key-replace-me
10
+ - MASK_FILE_TTL=300
11
+ - MASK_STORE_DIR=/tmp/aadhaar_masked
12
+ volumes:
13
+ - /tmp/aadhaar_masked:/tmp/aadhaar_masked
14
+ restart: unless-stopped
15
+ healthcheck:
16
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
17
+ interval: 30s
18
+ timeout: 5s
19
+ retries: 3
file_service.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ File processing service.
3
+ - Converts uploaded files to OpenCV image frames.
4
+ - Rebuilds PDF or image output after masking.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import logging
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import List, Tuple
14
+
15
+ import cv2
16
+ import numpy as np
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _PDF_AVAILABLE = False
21
+ try:
22
+ import fitz # PyMuPDF
23
+ _PDF_AVAILABLE = True
24
+ except ImportError:
25
+ logger.warning("PyMuPDF not installed; PDF support disabled.")
26
+
27
+ _PIL_AVAILABLE = False
28
+ try:
29
+ from PIL import Image as PILImage
30
+ _PIL_AVAILABLE = True
31
+ except ImportError:
32
+ pass
33
+
34
+
35
+ def _np_to_bytes_jpg(img: np.ndarray) -> bytes:
36
+ _, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 95])
37
+ return bytes(buf)
38
+
39
+
40
+ def _np_to_bytes_png(img: np.ndarray) -> bytes:
41
+ _, buf = cv2.imencode(".png", img)
42
+ return bytes(buf)
43
+
44
+
45
+ def decode_image(raw: bytes, suffix: str) -> np.ndarray:
46
+ """Decode raw file bytes to an OpenCV BGR image."""
47
+ arr = np.frombuffer(raw, dtype=np.uint8)
48
+ img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
49
+ if img is None:
50
+ raise ValueError(f"Could not decode image (suffix={suffix})")
51
+ return img
52
+
53
+
54
+ def load_file(raw: bytes, suffix: str) -> List[np.ndarray]:
55
+ """
56
+ Load a file and return a list of OpenCV BGR frames.
57
+ One frame per page for PDFs; one frame for images.
58
+ """
59
+ suffix = suffix.lower().lstrip(".")
60
+
61
+ if suffix == "pdf":
62
+ return _load_pdf_pages(raw)
63
+ elif suffix in ("jpg", "jpeg", "png"):
64
+ return [decode_image(raw, suffix)]
65
+ else:
66
+ raise ValueError(f"Unsupported file type: {suffix}")
67
+
68
+
69
+ def _load_pdf_pages(raw: bytes) -> List[np.ndarray]:
70
+ if not _PDF_AVAILABLE:
71
+ raise RuntimeError("PyMuPDF is not installed; cannot process PDFs.")
72
+
73
+ doc = fitz.open(stream=raw, filetype="pdf")
74
+ frames = []
75
+ for page in doc:
76
+ mat = fitz.Matrix(2.0, 2.0) # 2Γ— zoom for better OCR
77
+ pix = page.get_pixmap(matrix=mat, alpha=False)
78
+ img_bytes = pix.tobytes("png")
79
+ arr = np.frombuffer(img_bytes, dtype=np.uint8)
80
+ img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
81
+ if img is not None:
82
+ frames.append(img)
83
+ doc.close()
84
+ return frames
85
+
86
+
87
+ def rebuild_output(
88
+ original_raw: bytes,
89
+ original_suffix: str,
90
+ masked_frames: List[np.ndarray],
91
+ output_format: str, # "image" | "pdf"
92
+ ) -> Tuple[bytes, str]:
93
+ """
94
+ Rebuild the output file from masked frames.
95
+ Returns (file_bytes, media_type).
96
+ """
97
+ original_suffix = original_suffix.lower().lstrip(".")
98
+
99
+ if output_format == "pdf" or original_suffix == "pdf":
100
+ data = _frames_to_pdf(masked_frames)
101
+ return data, "application/pdf"
102
+ else:
103
+ # Single-frame image
104
+ if original_suffix in ("jpg", "jpeg"):
105
+ data = _np_to_bytes_jpg(masked_frames[0])
106
+ return data, "image/jpeg"
107
+ else:
108
+ data = _np_to_bytes_png(masked_frames[0])
109
+ return data, "image/png"
110
+
111
+
112
+ def _frames_to_pdf(frames: List[np.ndarray]) -> bytes:
113
+ if not _PDF_AVAILABLE:
114
+ raise RuntimeError("PyMuPDF is not installed; cannot produce PDF output.")
115
+
116
+ doc = fitz.open()
117
+ for frame in frames:
118
+ img_bytes = _np_to_bytes_png(frame)
119
+ img_doc = fitz.open(stream=img_bytes, filetype="png")
120
+ pdf_bytes = img_doc.convert_to_pdf()
121
+ img_pdf = fitz.open("pdf", pdf_bytes)
122
+ doc.insert_pdf(img_pdf)
123
+ img_doc.close()
124
+ img_pdf.close()
125
+ buf = io.BytesIO()
126
+ doc.save(buf)
127
+ doc.close()
128
+ return buf.getvalue()
main.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aadhaar Masking API - Production Grade
3
+ Masks Aadhaar numbers, VIDs, and QR codes from identity documents.
4
+ """
5
+
6
+ import logging
7
+ import time
8
+ import uuid
9
+ from contextlib import asynccontextmanager
10
+
11
+ from fastapi import FastAPI, Request
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.middleware.trustedhost import TrustedHostMiddleware
14
+ from fastapi.staticfiles import StaticFiles
15
+ from fastapi.responses import HTMLResponse
16
+ from fastapi.templating import Jinja2Templates
17
+
18
+ from app.routers import masking
19
+ from app.utils.cleanup import start_cleanup_scheduler
20
+
21
+ # ── Logging ───────────────────────────────────────────────────────────────────
22
+ logging.basicConfig(
23
+ level=logging.INFO,
24
+ format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
25
+ )
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @asynccontextmanager
30
+ async def lifespan(app: FastAPI):
31
+ logger.info("Starting Aadhaar Masking API...")
32
+ start_cleanup_scheduler()
33
+ yield
34
+ logger.info("Shutting down Aadhaar Masking API.")
35
+
36
+
37
+ # ── App ────────────────────────────────────────────────────────────────────────
38
+ app = FastAPI(
39
+ title="Aadhaar Masking API",
40
+ description=(
41
+ "Production-grade API to detect and mask sensitive information "
42
+ "(Aadhaar number, VID, QR code) from Aadhaar documents. "
43
+ "Supports JPG, PNG, and PDF inputs."
44
+ ),
45
+ version="1.0.0",
46
+ contact={"name": "API Support", "email": "support@example.com"},
47
+ license_info={"name": "MIT"},
48
+ docs_url="/docs",
49
+ redoc_url="/redoc",
50
+ lifespan=lifespan,
51
+ )
52
+
53
+ # ── Middleware ─────────────────────────────────────────────────────────────────
54
+ app.add_middleware(
55
+ CORSMiddleware,
56
+ allow_origins=["*"],
57
+ allow_credentials=True,
58
+ allow_methods=["*"],
59
+ allow_headers=["*"],
60
+ )
61
+
62
+
63
+ @app.middleware("http")
64
+ async def add_request_id(request: Request, call_next):
65
+ request_id = str(uuid.uuid4())[:8]
66
+ request.state.request_id = request_id
67
+ start = time.time()
68
+ response = await call_next(request)
69
+ duration = round((time.time() - start) * 1000, 1)
70
+ response.headers["X-Request-ID"] = request_id
71
+ response.headers["X-Process-Time-ms"] = str(duration)
72
+ logger.info(
73
+ "method=%s path=%s status=%d duration_ms=%s rid=%s",
74
+ request.method, request.url.path,
75
+ response.status_code, duration, request_id,
76
+ )
77
+ return response
78
+
79
+
80
+ # ── Static / Templates ─────────────────────────────────────────────────────────
81
+ app.mount("/static", StaticFiles(directory="static"), name="static")
82
+ templates = Jinja2Templates(directory="templates")
83
+
84
+
85
+ # ── Routers ────────────────────────────────────────────────────────────────────
86
+ app.include_router(masking.router)
87
+
88
+
89
+ # ── Health ─────────────────────────────────────────────────────────────────────
90
+ @app.get("/health", tags=["Health"], summary="Health check")
91
+ async def health():
92
+ return {"status": "ok", "version": "1.0.0"}
93
+
94
+
95
+ @app.get("/", response_class=HTMLResponse, include_in_schema=False)
96
+ async def frontend(request: Request):
97
+ return templates.TemplateResponse("index.html", {"request": request})
masking.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ POST /aadhaar/mask β€” core masking endpoint.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from fastapi import (
12
+ APIRouter, Depends, File, Form, HTTPException,
13
+ Request, UploadFile, status,
14
+ )
15
+ from fastapi.responses import FileResponse, JSONResponse
16
+
17
+ from app.models.schemas import (
18
+ DetectionResult, ErrorResponse, MaskType, MaskingResponse, OutputFormat,
19
+ )
20
+ from app.services.detection_service import detect_all
21
+ from app.services.file_service import load_file, rebuild_output
22
+ from app.services.masking_service import apply_masks
23
+ from app.utils.auth import verify_api_key
24
+ from app.utils.cleanup import delete_file, get_file_path, save_file
25
+
26
+ logger = logging.getLogger(__name__)
27
+ router = APIRouter(prefix="/aadhaar", tags=["Aadhaar Masking"])
28
+
29
+ _ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "pdf"}
30
+ _MAX_FILE_SIZE = 20 * 1024 * 1024 # 20 MB
31
+ _CONFIDENCE_THRESHOLD = 0.5
32
+
33
+
34
+ def _ext(filename: str) -> str:
35
+ return Path(filename).suffix.lstrip(".").lower()
36
+
37
+
38
+ @router.post(
39
+ "/mask",
40
+ response_model=MaskingResponse,
41
+ summary="Mask Aadhaar document",
42
+ description=(
43
+ "Upload a JPG, PNG, or PDF Aadhaar document. "
44
+ "The API detects and masks Aadhaar numbers, VIDs, and QR codes. "
45
+ "Returns JSON metadata + a download URL for the masked file."
46
+ ),
47
+ responses={
48
+ 200: {"model": MaskingResponse},
49
+ 400: {"model": ErrorResponse},
50
+ 401: {"model": ErrorResponse},
51
+ 403: {"model": ErrorResponse},
52
+ 422: {"description": "Validation error"},
53
+ },
54
+ )
55
+ async def mask_aadhaar(
56
+ request: Request,
57
+ file: UploadFile = File(..., description="JPG, PNG, or PDF document"),
58
+ mask_type: MaskType = Form(MaskType.partial, description="full or partial masking"),
59
+ output_format: OutputFormat = Form(OutputFormat.image, description="image or pdf"),
60
+ _api_key: str = Depends(verify_api_key),
61
+ ):
62
+ t0 = time.perf_counter()
63
+ rid = getattr(request.state, "request_id", "?")
64
+
65
+ # ── Validate file type ────────────────────────────────────────────────────
66
+ ext = _ext(file.filename or "unknown")
67
+ if ext not in _ALLOWED_EXTENSIONS:
68
+ raise HTTPException(
69
+ status_code=status.HTTP_400_BAD_REQUEST,
70
+ detail=f"Unsupported file type '{ext}'. Allowed: {_ALLOWED_EXTENSIONS}",
71
+ )
72
+
73
+ raw = await file.read()
74
+ if len(raw) > _MAX_FILE_SIZE:
75
+ raise HTTPException(
76
+ status_code=status.HTTP_400_BAD_REQUEST,
77
+ detail=f"File too large ({len(raw)//1024}KB). Max 20MB.",
78
+ )
79
+
80
+ # ── Load frames ───────────────────────────────────────────────────────────
81
+ try:
82
+ frames = load_file(raw, ext)
83
+ except Exception as exc:
84
+ logger.exception("File load failed rid=%s", rid)
85
+ raise HTTPException(
86
+ status_code=status.HTTP_400_BAD_REQUEST,
87
+ detail=f"Could not read file: {exc}",
88
+ )
89
+
90
+ if not frames:
91
+ raise HTTPException(
92
+ status_code=status.HTTP_400_BAD_REQUEST,
93
+ detail="No image frames found in the uploaded file.",
94
+ )
95
+
96
+ # ── Detect & mask each frame ──────────────────────────────────────────────
97
+ masked_frames = []
98
+ all_items = []
99
+ for frame in frames:
100
+ detection = detect_all(frame)
101
+ all_items.extend(detection.items)
102
+ masked = apply_masks(frame, detection.items, mask_type)
103
+ masked_frames.append(masked)
104
+
105
+ # ── Aggregate detection results ───────────────────────────────────────────
106
+ has_aadhaar = any(i.kind == "aadhaar" for i in all_items)
107
+ has_vid = any(i.kind == "vid" for i in all_items)
108
+ has_qr = any(i.kind == "qr" for i in all_items)
109
+
110
+ if not (has_aadhaar or has_vid or has_qr):
111
+ logger.warning("No sensitive data detected rid=%s", rid)
112
+ raise HTTPException(
113
+ status_code=status.HTTP_400_BAD_REQUEST,
114
+ detail="No Aadhaar number, VID, or QR code detected in the document.",
115
+ )
116
+
117
+ overall_conf = (
118
+ sum(i.confidence for i in all_items) / len(all_items) if all_items else 0.0
119
+ )
120
+ flagged = overall_conf < _CONFIDENCE_THRESHOLD
121
+
122
+ # ── Rebuild output ────────────────────────────────────────────────────────
123
+ out_ext = "pdf" if output_format == OutputFormat.pdf else (
124
+ "jpg" if ext in ("jpg", "jpeg") else "png"
125
+ )
126
+ try:
127
+ out_bytes, media_type = rebuild_output(raw, ext, masked_frames, output_format.value)
128
+ except Exception as exc:
129
+ logger.exception("Output rebuild failed rid=%s", rid)
130
+ raise HTTPException(
131
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
132
+ detail=f"Failed to create output file: {exc}",
133
+ )
134
+
135
+ # ── Save and return URL ───────────────────────────────────────────────────
136
+ file_id = save_file(out_bytes, out_ext)
137
+ base_url = str(request.base_url).rstrip("/")
138
+ masked_url = f"{base_url}/aadhaar/download/{file_id}"
139
+
140
+ duration_ms = round((time.perf_counter() - t0) * 1000, 1)
141
+ logger.info(
142
+ "Masking complete rid=%s aadhaar=%s vid=%s qr=%s conf=%.2f dur_ms=%s",
143
+ rid, has_aadhaar, has_vid, has_qr, overall_conf, duration_ms,
144
+ )
145
+
146
+ return MaskingResponse(
147
+ status="success",
148
+ masked_file_url=masked_url,
149
+ detected=DetectionResult(aadhaar=has_aadhaar, vid=has_vid, qr=has_qr),
150
+ confidence_score=round(overall_conf, 3),
151
+ flagged_for_review=flagged,
152
+ processing_time_ms=duration_ms,
153
+ request_id=rid,
154
+ )
155
+
156
+
157
+ @router.get(
158
+ "/download/{file_id}",
159
+ summary="Download masked file",
160
+ description="One-time-use download link; file is deleted after TTL.",
161
+ include_in_schema=True,
162
+ tags=["Aadhaar Masking"],
163
+ )
164
+ async def download_masked(file_id: str):
165
+ path = get_file_path(file_id)
166
+ if path is None:
167
+ raise HTTPException(
168
+ status_code=status.HTTP_404_NOT_FOUND,
169
+ detail="File not found or has expired.",
170
+ )
171
+ ext = path.suffix.lstrip(".")
172
+ media_type_map = {
173
+ "jpg": "image/jpeg", "jpeg": "image/jpeg",
174
+ "png": "image/png", "pdf": "application/pdf",
175
+ }
176
+ return FileResponse(
177
+ path=str(path),
178
+ media_type=media_type_map.get(ext, "application/octet-stream"),
179
+ filename=f"masked_aadhaar.{ext}",
180
+ background=None, # don't delete yet; cleanup handles TTL
181
+ )
masking_service.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Masking service: applies redaction to Aadhaar numbers, VIDs, and QR regions.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import re
9
+ from typing import List
10
+
11
+ import cv2
12
+ import numpy as np
13
+
14
+ from app.models.schemas import MaskType
15
+ from app.services.detection_service import DetectedItem
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # ── Colour constants (BGR) ─────────────────────────────────────────────────────
20
+ _BLACK = (0, 0, 0)
21
+ _WHITE = (255, 255, 255)
22
+ _FONT = cv2.FONT_HERSHEY_SIMPLEX
23
+
24
+
25
+ def _mask_aadhaar_text(value: str, mask_type: MaskType) -> str:
26
+ """
27
+ Return the display string after masking.
28
+ - partial (default): XXXX XXXX 1234
29
+ - full: XXXX XXXX XXXX
30
+ """
31
+ digits = value.replace(" ", "")
32
+ if mask_type == MaskType.full:
33
+ return "XXXX XXXX XXXX"
34
+ else:
35
+ # Keep last 4 digits
36
+ return f"XXXX XXXX {digits[8:]}"
37
+
38
+
39
+ def _mask_vid_text(value: str, mask_type: MaskType) -> str:
40
+ digits = value.replace(" ", "")
41
+ if mask_type == MaskType.full:
42
+ return "XXXX XXXX XXXX XXXX"
43
+ else:
44
+ return f"XXXX XXXX XXXX {digits[12:]}"
45
+
46
+
47
+ def _draw_masked_text(img: np.ndarray, bbox, masked_text: str) -> None:
48
+ """Overwrite a text region with a solid black box + white masked text."""
49
+ if bbox is None:
50
+ return
51
+ x, y, w, h = bbox
52
+ # Solid black fill
53
+ cv2.rectangle(img, (x, y), (x + w, y + h), _BLACK, -1)
54
+ # White label
55
+ font_scale = max(0.3, h / 30.0)
56
+ thickness = max(1, int(h / 15))
57
+ text_size, _ = cv2.getTextSize(masked_text, _FONT, font_scale, thickness)
58
+ tx = x + max(0, (w - text_size[0]) // 2)
59
+ ty = y + h // 2 + text_size[1] // 2
60
+ cv2.putText(img, masked_text, (tx, ty), _FONT, font_scale, _WHITE, thickness, cv2.LINE_AA)
61
+
62
+
63
+ def _blur_qr(img: np.ndarray, bbox) -> None:
64
+ """Heavily blur (or black-out) the QR region."""
65
+ if bbox is None:
66
+ return
67
+ x, y, w, h = bbox
68
+ x2, y2 = min(img.shape[1], x + w), min(img.shape[0], y + h)
69
+ roi = img[y:y2, x:x2]
70
+ # Apply solid black box (most secure; set to blur for softer look)
71
+ img[y:y2, x:x2] = 0 # solid black
72
+
73
+
74
+ def apply_masks(
75
+ img: np.ndarray,
76
+ items: List[DetectedItem],
77
+ mask_type: MaskType,
78
+ ) -> np.ndarray:
79
+ """
80
+ Apply all detected masking to a copy of the image.
81
+ Returns the masked image.
82
+ """
83
+ masked = img.copy()
84
+
85
+ for item in items:
86
+ if item.kind == "aadhaar":
87
+ if item.value:
88
+ label = _mask_aadhaar_text(item.value, mask_type)
89
+ if item.bbox:
90
+ _draw_masked_text(masked, item.bbox, label)
91
+ else:
92
+ logger.warning("Aadhaar detected but no bbox; skipping visual mask.")
93
+ elif item.kind == "vid":
94
+ if item.value:
95
+ label = _mask_vid_text(item.value, mask_type)
96
+ if item.bbox:
97
+ _draw_masked_text(masked, item.bbox, label)
98
+ elif item.kind == "qr":
99
+ _blur_qr(masked, item.bbox)
100
+
101
+ return masked
ocr_service.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hybrid OCR service: EasyOCR primary, Tesseract fallback.
3
+ Returns a list of (text, bbox, confidence) tuples.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from dataclasses import dataclass
10
+ from typing import List, Tuple
11
+
12
+ import cv2
13
+ import numpy as np
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ BBox = Tuple[int, int, int, int] # x, y, w, h
18
+
19
+
20
+ @dataclass
21
+ class OCRResult:
22
+ text: str
23
+ bbox: BBox # (x, y, w, h)
24
+ confidence: float # 0–1
25
+
26
+
27
+ def _try_import_easyocr():
28
+ try:
29
+ import easyocr # noqa: F401
30
+ return True
31
+ except ImportError:
32
+ return False
33
+
34
+
35
+ def _try_import_pytesseract():
36
+ try:
37
+ import pytesseract # noqa: F401
38
+ return True
39
+ except ImportError:
40
+ return False
41
+
42
+
43
+ _EASYOCR_READER = None
44
+ _EASYOCR_AVAILABLE = _try_import_easyocr()
45
+ _TESSERACT_AVAILABLE = _try_import_pytesseract()
46
+
47
+
48
+ def _get_easyocr_reader():
49
+ global _EASYOCR_READER
50
+ if _EASYOCR_READER is None and _EASYOCR_AVAILABLE:
51
+ import easyocr
52
+ logger.info("Initialising EasyOCR reader (en, hi)…")
53
+ _EASYOCR_READER = easyocr.Reader(["en", "hi"], gpu=False, verbose=False)
54
+ logger.info("EasyOCR reader ready.")
55
+ return _EASYOCR_READER
56
+
57
+
58
+ # ── Preprocessing ──────────────────────────────────────────────────────────────
59
+
60
+ def preprocess(img: np.ndarray) -> List[np.ndarray]:
61
+ """Return multiple preprocessed variants for multi-pass OCR."""
62
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img.copy()
63
+
64
+ # Variant 1: denoised + Otsu threshold
65
+ denoised = cv2.fastNlMeansDenoising(gray, h=10)
66
+ _, thresh_otsu = cv2.threshold(denoised, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
67
+
68
+ # Variant 2: adaptive threshold (handles uneven lighting)
69
+ thresh_adaptive = cv2.adaptiveThreshold(
70
+ denoised, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2
71
+ )
72
+
73
+ # Variant 3: upscaled for low-res images
74
+ h, w = gray.shape[:2]
75
+ scale = max(1, 2000 // max(h, w))
76
+ if scale > 1:
77
+ upscaled = cv2.resize(thresh_otsu, (w * scale, h * scale), interpolation=cv2.INTER_CUBIC)
78
+ else:
79
+ upscaled = thresh_otsu
80
+
81
+ return [thresh_otsu, thresh_adaptive, upscaled]
82
+
83
+
84
+ def _easyocr_pass(img: np.ndarray) -> List[OCRResult]:
85
+ reader = _get_easyocr_reader()
86
+ if reader is None:
87
+ return []
88
+ try:
89
+ raw = reader.readtext(img, detail=1, paragraph=False)
90
+ except Exception as exc:
91
+ logger.warning("EasyOCR failed: %s", exc)
92
+ return []
93
+
94
+ results = []
95
+ for item in raw:
96
+ pts, text, conf = item
97
+ xs = [p[0] for p in pts]
98
+ ys = [p[1] for p in pts]
99
+ x, y = int(min(xs)), int(min(ys))
100
+ w = int(max(xs) - x)
101
+ h = int(max(ys) - y)
102
+ results.append(OCRResult(text=text, bbox=(x, y, w, h), confidence=float(conf)))
103
+ return results
104
+
105
+
106
+ def _tesseract_pass(img: np.ndarray) -> List[OCRResult]:
107
+ if not _TESSERACT_AVAILABLE:
108
+ return []
109
+ try:
110
+ import pytesseract
111
+ data = pytesseract.image_to_data(
112
+ img,
113
+ lang="eng+hin",
114
+ config="--psm 11 --oem 3",
115
+ output_type=pytesseract.Output.DICT,
116
+ )
117
+ except Exception as exc:
118
+ logger.warning("Tesseract failed: %s", exc)
119
+ return []
120
+
121
+ results = []
122
+ n = len(data["text"])
123
+ for i in range(n):
124
+ text = data["text"][i].strip()
125
+ conf = int(data["conf"][i])
126
+ if text and conf > 0:
127
+ x = data["left"][i]
128
+ y = data["top"][i]
129
+ w = data["width"][i]
130
+ h = data["height"][i]
131
+ results.append(
132
+ OCRResult(text=text, bbox=(x, y, w, h), confidence=conf / 100.0)
133
+ )
134
+ return results
135
+
136
+
137
+ def run_ocr(img: np.ndarray) -> List[OCRResult]:
138
+ """
139
+ Run multi-pass hybrid OCR on the image.
140
+ Returns deduplicated results sorted by confidence descending.
141
+ """
142
+ all_results: List[OCRResult] = []
143
+ variants = preprocess(img)
144
+
145
+ for variant in variants:
146
+ # Try EasyOCR first
147
+ if _EASYOCR_AVAILABLE:
148
+ all_results.extend(_easyocr_pass(variant))
149
+ # Tesseract fallback / supplement
150
+ if _TESSERACT_AVAILABLE:
151
+ all_results.extend(_tesseract_pass(variant))
152
+
153
+ if not all_results:
154
+ logger.warning("No OCR engine available; returning empty results.")
155
+ return []
156
+
157
+ # Deduplicate by text similarity
158
+ seen: set[str] = set()
159
+ deduped: List[OCRResult] = []
160
+ for r in sorted(all_results, key=lambda x: -x.confidence):
161
+ key = r.text.replace(" ", "").lower()
162
+ if key not in seen:
163
+ seen.add(key)
164
+ deduped.append(r)
165
+
166
+ return deduped
pytest.ini ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ [pytest]
2
+ asyncio_mode = auto
3
+ testpaths = tests
4
+ log_cli = true
5
+ log_cli_level = WARNING
requirements.txt ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn[standard]==0.29.0
3
+ python-multipart==0.0.9
4
+ pydantic==2.7.1
5
+
6
+ # OCR
7
+ easyocr==1.7.1
8
+ pytesseract==0.3.10
9
+ Pillow==10.3.0
10
+
11
+ # Image processing
12
+ opencv-python-headless==4.9.0.80
13
+ numpy==1.26.4
14
+
15
+ # PDF
16
+ PyMuPDF==1.24.3
17
+
18
+ # Utilities
19
+ python-dotenv==1.0.1
20
+ httpx==0.27.0 # for tests
21
+ pytest==8.2.0
22
+ pytest-asyncio==0.23.6
run.sh ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Load .env if it exists
5
+ if [ -f .env ]; then
6
+ export $(grep -v '^#' .env | xargs)
7
+ fi
8
+
9
+ HOST="${HOST:-0.0.0.0}"
10
+ PORT="${PORT:-8000}"
11
+ WORKERS="${WORKERS:-2}"
12
+
13
+ echo ""
14
+ echo "╔══════════════════════════════════════════════╗"
15
+ echo "β•‘ Aadhaar Masking API v1.0.0 β•‘"
16
+ echo "╠══════════════════════════════════════════════╣"
17
+ echo "β•‘ http://${HOST}:${PORT} β•‘"
18
+ echo "β•‘ Swagger: http://${HOST}:${PORT}/docs β•‘"
19
+ echo "β•‘ Workers: ${WORKERS} β•‘"
20
+ echo "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•"
21
+ echo ""
22
+
23
+ uvicorn app.main:app \
24
+ --host "$HOST" \
25
+ --port "$PORT" \
26
+ --workers "$WORKERS" \
27
+ --reload \
28
+ --log-level info
schemas.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Request / response models."""
2
+
3
+ from enum import Enum
4
+ from typing import Optional
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class MaskType(str, Enum):
9
+ full = "full"
10
+ partial = "partial"
11
+
12
+
13
+ class OutputFormat(str, Enum):
14
+ image = "image"
15
+ pdf = "pdf"
16
+
17
+
18
+ class DetectionResult(BaseModel):
19
+ aadhaar: bool = Field(False, description="Aadhaar number detected")
20
+ vid: bool = Field(False, description="VID detected")
21
+ qr: bool = Field(False, description="QR code detected")
22
+
23
+
24
+ class MaskingResponse(BaseModel):
25
+ status: str = Field(..., examples=["success"])
26
+ masked_file_url: Optional[str] = Field(None, description="URL to download the masked file")
27
+ detected: DetectionResult
28
+ confidence_score: float = Field(..., ge=0.0, le=1.0, description="Overall detection confidence (0–1)")
29
+ flagged_for_review: bool = Field(False, description="True when confidence < threshold")
30
+ processing_time_ms: float = Field(..., description="Server-side processing time in ms")
31
+ request_id: Optional[str] = None
32
+
33
+
34
+ class ErrorResponse(BaseModel):
35
+ status: str = "error"
36
+ message: str
37
+ request_id: Optional[str] = None
test_core.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for Aadhaar Masking API.
3
+ Run: pytest tests/ -v
4
+ """
5
+
6
+ import io
7
+ import re
8
+
9
+ import numpy as np
10
+ import pytest
11
+
12
+ # ── Verhoeff tests ─────────────────────────────────────────────────────────────
13
+
14
+ from app.utils.verhoeff import validate
15
+
16
+
17
+ def test_verhoeff_valid_known():
18
+ # Known valid Aadhaar-format numbers (public test vectors)
19
+ assert validate("234123412346") is True # example that passes Verhoeff
20
+
21
+
22
+ def test_verhoeff_invalid():
23
+ assert validate("123456789012") is False
24
+
25
+
26
+ def test_verhoeff_length_guard():
27
+ # Verhoeff works on any digit string; 12-digit gate is at detection layer
28
+ assert isinstance(validate("0"), bool)
29
+
30
+
31
+ # ── Regex pattern tests ────────────────────────────────────────────────────────
32
+
33
+ import re
34
+
35
+ AADHAAR_RE = re.compile(
36
+ r"(?<!\d)([2-9]\d{3})[\s\-]?(\d{4})[\s\-]?(\d{4})(?!\d)"
37
+ )
38
+
39
+
40
+ def test_aadhaar_regex_matches_spaced():
41
+ text = "Your Aadhaar: 2341 2341 2346"
42
+ m = AADHAAR_RE.search(text)
43
+ assert m is not None
44
+
45
+
46
+ def test_aadhaar_regex_no_match_phone():
47
+ # 10-digit phone starting with 9 β€” should NOT match 12-digit pattern
48
+ text = "Call us at 9876543210"
49
+ raw = text.replace(" ", "")
50
+ # Phone is 10 digits; our regex needs 12, so no full match expected
51
+ matches = [
52
+ m.group(0).replace(" ", "").replace("-", "")
53
+ for m in AADHAAR_RE.finditer(text)
54
+ if len(m.group(0).replace(" ", "").replace("-", "")) == 12
55
+ ]
56
+ assert matches == []
57
+
58
+
59
+ VID_RE = re.compile(
60
+ r"(?<!\d)(9\d{3})[\s\-]?(\d{4})[\s\-]?(\d{4})[\s\-]?(\d{4})(?!\d)"
61
+ )
62
+
63
+
64
+ def test_vid_regex_matches():
65
+ text = "VID: 9876 5432 1098 7654"
66
+ assert VID_RE.search(text) is not None
67
+
68
+
69
+ def test_vid_regex_no_match_short():
70
+ text = "9876 5432 1098" # only 12 digits
71
+ assert VID_RE.search(text) is None
72
+
73
+
74
+ # ── Masking text tests ─────────────────────────────────────────────────────────
75
+
76
+ from app.models.schemas import MaskType
77
+ from app.services.masking_service import _mask_aadhaar_text, _mask_vid_text
78
+
79
+
80
+ def test_mask_aadhaar_partial():
81
+ assert _mask_aadhaar_text("2341 2341 2346", MaskType.partial) == "XXXX XXXX 2346"
82
+
83
+
84
+ def test_mask_aadhaar_full():
85
+ assert _mask_aadhaar_text("2341 2341 2346", MaskType.full) == "XXXX XXXX XXXX"
86
+
87
+
88
+ def test_mask_vid_partial():
89
+ result = _mask_vid_text("9876 5432 1098 7654", MaskType.partial)
90
+ assert result == "XXXX XXXX XXXX 7654"
91
+
92
+
93
+ def test_mask_vid_full():
94
+ result = _mask_vid_text("9876 5432 1098 7654", MaskType.full)
95
+ assert result == "XXXX XXXX XXXX XXXX"
96
+
97
+
98
+ # ── File service tests ─────────────────────────────────────────────────────────
99
+
100
+ from app.services.file_service import decode_image
101
+ import cv2
102
+
103
+
104
+ def test_decode_png_bytes():
105
+ img = np.zeros((100, 100, 3), dtype=np.uint8)
106
+ _, buf = cv2.imencode(".png", img)
107
+ decoded = decode_image(bytes(buf), "png")
108
+ assert decoded.shape == (100, 100, 3)
109
+
110
+
111
+ def test_decode_bad_bytes():
112
+ with pytest.raises(ValueError):
113
+ decode_image(b"not an image", "jpg")
114
+
115
+
116
+ # ── Cleanup / storage tests ────────────────────────────────────────────────────
117
+
118
+ from app.utils.cleanup import save_file, get_file_path, delete_file
119
+
120
+
121
+ def test_save_and_retrieve():
122
+ fid = save_file(b"hello", "txt")
123
+ path = get_file_path(fid)
124
+ assert path is not None
125
+ assert path.read_bytes() == b"hello"
126
+
127
+
128
+ def test_delete_file():
129
+ fid = save_file(b"bye", "txt")
130
+ delete_file(fid)
131
+ assert get_file_path(fid) is None
verhoeff.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Verhoeff algorithm checksum validation for Aadhaar numbers.
3
+ The Aadhaar number uses the Verhoeff check-digit scheme.
4
+ """
5
+
6
+ # Multiplication table
7
+ _D = [
8
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
9
+ [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
10
+ [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
11
+ [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
12
+ [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
13
+ [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
14
+ [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
15
+ [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
16
+ [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
17
+ [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
18
+ ]
19
+
20
+ # Permutation table
21
+ _P = [
22
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
23
+ [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
24
+ [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
25
+ [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
26
+ [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
27
+ [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
28
+ [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
29
+ [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
30
+ ]
31
+
32
+ # Inverse table
33
+ _INV = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9]
34
+
35
+
36
+ def validate(number: str) -> bool:
37
+ """
38
+ Returns True if the given digit string passes Verhoeff validation.
39
+ The number is read right-to-left (last digit is position 0).
40
+ """
41
+ digits = [int(d) for d in reversed(number)]
42
+ c = 0
43
+ for i, d in enumerate(digits):
44
+ c = _D[c][_P[i % 8][d]]
45
+ return c == 0
46
+
47
+
48
+ def _generate_check_digit(number: str) -> int:
49
+ """Generate the Verhoeff check digit for a partial number."""
50
+ digits = [int(d) for d in reversed(number + "0")]
51
+ c = 0
52
+ for i, d in enumerate(digits):
53
+ c = _D[c][_P[i % 8][d]]
54
+ return _INV[c]