Spaces:
Configuration error
Configuration error
Upload 20 files
Browse files- .env.example +19 -0
- .gitkeep +1 -0
- Dockerfile +32 -0
- README.md +233 -9
- __init__.py +0 -0
- auth.py +32 -0
- cleanup.py +84 -0
- detection_service.py +231 -0
- docker-compose.yml +19 -0
- file_service.py +128 -0
- main.py +97 -0
- masking.py +181 -0
- masking_service.py +101 -0
- ocr_service.py +166 -0
- pytest.ini +5 -0
- requirements.txt +22 -0
- run.sh +28 -0
- schemas.py +37 -0
- test_core.py +131 -0
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
| 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]
|