Spaces:
Sleeping
Sleeping
Sync from GitHub via hub-sync
Browse files- .dockerignore +19 -0
- Dockerfile +30 -0
- README.md +138 -0
- docker-compose.persist.yml +15 -0
- docker-compose.yml +33 -0
- docker/constraints.txt +2 -0
- pyproject.toml +9 -8
- requirements.txt +40 -20
- src/faceverification/config.py +6 -0
- src/faceverification/interfaces/fastapi_app.py +414 -0
- src/faceverification/interfaces/gradio_app.py +1 -1
- test/test_fastapi_app.py +178 -0
- uv.lock +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.github
|
| 3 |
+
.pytest_cache
|
| 4 |
+
.ruff_cache
|
| 5 |
+
.uv-cache
|
| 6 |
+
.venv
|
| 7 |
+
__pycache__
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*.egg-info
|
| 10 |
+
.coverage
|
| 11 |
+
coverage.xml
|
| 12 |
+
htmlcov
|
| 13 |
+
.env
|
| 14 |
+
.envrc
|
| 15 |
+
.vscode
|
| 16 |
+
notebooks
|
| 17 |
+
test
|
| 18 |
+
Dockerfile
|
| 19 |
+
docker-compose*.yml
|
Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
PYTHONPATH=/app/src \
|
| 7 |
+
FACEVERIFICATION_DEVICE=cpu
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 12 |
+
libgl1 \
|
| 13 |
+
libglib2.0-0 \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
COPY requirements.txt .
|
| 17 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
+
|
| 19 |
+
COPY pyproject.toml README.md ./
|
| 20 |
+
COPY src ./src
|
| 21 |
+
|
| 22 |
+
RUN useradd --create-home --shell /usr/sbin/nologin appuser \
|
| 23 |
+
&& mkdir -p /data/chroma \
|
| 24 |
+
&& chown -R appuser:appuser /app /data
|
| 25 |
+
|
| 26 |
+
USER appuser
|
| 27 |
+
|
| 28 |
+
EXPOSE 8000 7860
|
| 29 |
+
|
| 30 |
+
CMD ["uvicorn", "faceverification.interfaces.fastapi_app:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
CHANGED
|
@@ -56,6 +56,144 @@ pip install -r requirements.txt
|
|
| 56 |
python app.py
|
| 57 |
```
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
## Project Structure
|
| 60 |
|
| 61 |
```text
|
|
|
|
| 56 |
python app.py
|
| 57 |
```
|
| 58 |
|
| 59 |
+
## FastAPI Interface
|
| 60 |
+
|
| 61 |
+
The project also includes an HTTP API for the same enroll-and-verify workflow.
|
| 62 |
+
Run it locally with:
|
| 63 |
+
|
| 64 |
+
```bash
|
| 65 |
+
uv run uvicorn faceverification.interfaces.fastapi_app:app --host 0.0.0.0 --port 8000
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
Interactive API documentation is available at:
|
| 69 |
+
|
| 70 |
+
- Swagger UI: http://localhost:8000/docs
|
| 71 |
+
- ReDoc: http://localhost:8000/redoc
|
| 72 |
+
|
| 73 |
+
### Authentication
|
| 74 |
+
|
| 75 |
+
Protected endpoints require a bearer token. The default demo credentials are
|
| 76 |
+
`demo` / `demo123`; override them with `FACEVERIFICATION_DEMO_USERNAME` and
|
| 77 |
+
`FACEVERIFICATION_DEMO_PASSWORD` in `.env`.
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
curl -X POST http://localhost:8000/auth/login \
|
| 81 |
+
-F "username=demo" \
|
| 82 |
+
-F "password=demo123"
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
Use the returned token in the `Authorization` header:
|
| 86 |
+
|
| 87 |
+
```bash
|
| 88 |
+
Authorization: Bearer <access_token>
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
### Endpoints
|
| 92 |
+
|
| 93 |
+
- `GET /health`: returns API status.
|
| 94 |
+
- `POST /auth/login`: returns a JWT access token for the demo user.
|
| 95 |
+
- `POST /persons`: enrolls a known person from an uploaded image and form `name`.
|
| 96 |
+
- `POST /verify`: verifies whether an uploaded face matches a known person.
|
| 97 |
+
|
| 98 |
+
## Deployment Notes
|
| 99 |
+
|
| 100 |
+
For a containerized deployment, the recommended baseline is the FastAPI
|
| 101 |
+
container running Uvicorn:
|
| 102 |
+
|
| 103 |
+
```bash
|
| 104 |
+
uvicorn faceverification.interfaces.fastapi_app:app --host 0.0.0.0 --port 8000
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
This keeps the demo lightweight and avoids loading the FaceNet/MTCNN models in
|
| 108 |
+
multiple worker processes unnecessarily. Because each worker can hold its own
|
| 109 |
+
model instance in memory, increasing worker count should be done only after
|
| 110 |
+
checking available RAM and expected traffic.
|
| 111 |
+
|
| 112 |
+
Gunicorn with Uvicorn workers and an Nginx reverse proxy are valid production
|
| 113 |
+
options, but they are intentionally not required for the baseline deployment:
|
| 114 |
+
|
| 115 |
+
- Use Gunicorn/Uvicorn workers when the service needs a traditional process
|
| 116 |
+
manager or multiple worker processes.
|
| 117 |
+
- Use Nginx when deploying on a self-managed VM that needs TLS termination,
|
| 118 |
+
upload-size limits, reverse proxy routing, compression, or centralized access
|
| 119 |
+
logs.
|
| 120 |
+
- On managed platforms such as Render, Railway, Fly.io, Cloud Run, or similar,
|
| 121 |
+
the platform usually provides the external reverse proxy and TLS layer, so
|
| 122 |
+
running Uvicorn directly inside the application container is sufficient.
|
| 123 |
+
|
| 124 |
+
### Docker
|
| 125 |
+
|
| 126 |
+
Build and run the FastAPI service:
|
| 127 |
+
|
| 128 |
+
```bash
|
| 129 |
+
docker compose up --build api
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
The API will be available at:
|
| 133 |
+
|
| 134 |
+
- http://localhost:8000/health
|
| 135 |
+
- http://localhost:8000/docs
|
| 136 |
+
|
| 137 |
+
Run the optional Gradio interface:
|
| 138 |
+
|
| 139 |
+
```bash
|
| 140 |
+
docker compose --profile gradio up --build
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
The Gradio UI will be available at http://localhost:7860.
|
| 144 |
+
|
| 145 |
+
Both services use the same image. By default, ChromaDB runs in memory, so
|
| 146 |
+
enrolled faces are ephemeral and disappear when the container restarts. This is
|
| 147 |
+
intentional for the demo baseline.
|
| 148 |
+
|
| 149 |
+
To persist embeddings to disk, provide a database name:
|
| 150 |
+
|
| 151 |
+
```bash
|
| 152 |
+
FACEVERIFICATION_VECTOR_DB_NAME=local-demo \
|
| 153 |
+
docker compose -f docker-compose.yml -f docker-compose.persist.yml up --build api
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
The base `docker-compose.yml` does not mount any volume, so the default
|
| 157 |
+
deployment stays ephemeral. The optional `docker-compose.persist.yml` override
|
| 158 |
+
mounts the `faceverification-data` volume at `/data` and translates
|
| 159 |
+
`FACEVERIFICATION_VECTOR_DB_NAME` into
|
| 160 |
+
`FACEVERIFICATION_VECTOR_DB_PERSIST_DIRECTORY=/data/chroma/<name>` before the
|
| 161 |
+
application starts. The application itself still defaults to in-memory ChromaDB
|
| 162 |
+
unless `FACEVERIFICATION_VECTOR_DB_PERSIST_DIRECTORY` is explicitly provided.
|
| 163 |
+
|
| 164 |
+
The default container configuration sets `FACEVERIFICATION_DEVICE=cpu` to keep
|
| 165 |
+
deployment portable.
|
| 166 |
+
|
| 167 |
+
The shared local ChromaDB volume is intended for a small demo deployment when a
|
| 168 |
+
persist name is enabled. For a multi-container production setup with concurrent
|
| 169 |
+
writers or multiple replicas, use an external database/vector-store service or
|
| 170 |
+
make one service the clear owner of writes.
|
| 171 |
+
|
| 172 |
+
For production deployments, override at least:
|
| 173 |
+
|
| 174 |
+
```bash
|
| 175 |
+
FACEVERIFICATION_DEMO_USERNAME
|
| 176 |
+
FACEVERIFICATION_DEMO_PASSWORD
|
| 177 |
+
FACEVERIFICATION_JWT_SECRET_KEY
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
Example enrollment request:
|
| 181 |
+
|
| 182 |
+
```bash
|
| 183 |
+
curl -X POST http://localhost:8000/persons \
|
| 184 |
+
-H "Authorization: Bearer <access_token>" \
|
| 185 |
+
-F "name=Ada Lovelace" \
|
| 186 |
+
-F "image=@test/images/person_anchor.jpg"
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
Example verification request:
|
| 190 |
+
|
| 191 |
+
```bash
|
| 192 |
+
curl -X POST http://localhost:8000/verify \
|
| 193 |
+
-H "Authorization: Bearer <access_token>" \
|
| 194 |
+
-F "image=@test/images/person_positive.jpg"
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
## Project Structure
|
| 198 |
|
| 199 |
```text
|
docker-compose.persist.yml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
api:
|
| 3 |
+
environment:
|
| 4 |
+
FACEVERIFICATION_VECTOR_DB_PERSIST_DIRECTORY: ${FACEVERIFICATION_VECTOR_DB_NAME:+/data/chroma/${FACEVERIFICATION_VECTOR_DB_NAME}}
|
| 5 |
+
volumes:
|
| 6 |
+
- faceverification-data:/data
|
| 7 |
+
|
| 8 |
+
gradio:
|
| 9 |
+
environment:
|
| 10 |
+
FACEVERIFICATION_VECTOR_DB_PERSIST_DIRECTORY: ${FACEVERIFICATION_VECTOR_DB_NAME:+/data/chroma/${FACEVERIFICATION_VECTOR_DB_NAME}}
|
| 11 |
+
volumes:
|
| 12 |
+
- faceverification-data:/data
|
| 13 |
+
|
| 14 |
+
volumes:
|
| 15 |
+
faceverification-data:
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
api:
|
| 3 |
+
build:
|
| 4 |
+
context: .
|
| 5 |
+
image: faceverification
|
| 6 |
+
command: uvicorn faceverification.interfaces.fastapi_app:app --host 0.0.0.0 --port 8000
|
| 7 |
+
ports:
|
| 8 |
+
- "8000:8000"
|
| 9 |
+
environment:
|
| 10 |
+
FACEVERIFICATION_DEVICE: cpu
|
| 11 |
+
FACEVERIFICATION_DEMO_USERNAME: ${FACEVERIFICATION_DEMO_USERNAME:-demo}
|
| 12 |
+
FACEVERIFICATION_DEMO_PASSWORD: ${FACEVERIFICATION_DEMO_PASSWORD:-demo123}
|
| 13 |
+
FACEVERIFICATION_JWT_SECRET_KEY: ${FACEVERIFICATION_JWT_SECRET_KEY:-change-me-in-production-demo-secret-32-bytes-min}
|
| 14 |
+
healthcheck:
|
| 15 |
+
test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5)" ]
|
| 16 |
+
interval: 30s
|
| 17 |
+
timeout: 10s
|
| 18 |
+
retries: 3
|
| 19 |
+
start_period: 60s
|
| 20 |
+
|
| 21 |
+
gradio:
|
| 22 |
+
build:
|
| 23 |
+
context: .
|
| 24 |
+
image: faceverification
|
| 25 |
+
command: python -m faceverification.interfaces.gradio_app
|
| 26 |
+
profiles:
|
| 27 |
+
- gradio
|
| 28 |
+
ports:
|
| 29 |
+
- "7860:7860"
|
| 30 |
+
environment:
|
| 31 |
+
FACEVERIFICATION_DEVICE: cpu
|
| 32 |
+
GRADIO_SERVER_NAME: 0.0.0.0
|
| 33 |
+
GRADIO_SERVER_PORT: 7860
|
docker/constraints.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch==2.11.0+cpu
|
| 2 |
+
torchvision==0.26.0+cpu
|
pyproject.toml
CHANGED
|
@@ -3,7 +3,7 @@ name = "faceverification"
|
|
| 3 |
version = "0.1.0"
|
| 4 |
description = "Face verification demo using FaceNet embeddings, ChromaDB vector search, and CI/CD pipeline."
|
| 5 |
readme = "README.md"
|
| 6 |
-
requires-python = ">=3.11"
|
| 7 |
dependencies = [
|
| 8 |
"numpy>=1.24,<2.0",
|
| 9 |
"datasets>=4.8.5",
|
|
@@ -12,14 +12,16 @@ dependencies = [
|
|
| 12 |
"huggingface-hub>=1.14.0",
|
| 13 |
"python-dotenv>=1.2.2",
|
| 14 |
"pydantic-settings>=2.14.1",
|
| 15 |
-
"torch>=2.11.0",
|
| 16 |
-
"torchvision>=0.26.0",
|
| 17 |
"chromadb>=1.5.9",
|
| 18 |
"pytest>=9.0.3",
|
| 19 |
"pytest-cov>=7.1.0",
|
| 20 |
"fastapi>=0.136.1",
|
|
|
|
|
|
|
| 21 |
"uvicorn[standard]>=0.46.0",
|
| 22 |
"ruff>=0.15.13",
|
|
|
|
|
|
|
| 23 |
]
|
| 24 |
|
| 25 |
[project.scripts]
|
|
@@ -33,12 +35,12 @@ build-backend = "setuptools.build_meta"
|
|
| 33 |
where = ["src"]
|
| 34 |
|
| 35 |
[tool.uv.sources]
|
| 36 |
-
torch = { index = "pytorch-
|
| 37 |
-
torchvision = { index = "pytorch-
|
| 38 |
|
| 39 |
[[tool.uv.index]]
|
| 40 |
-
name = "pytorch-
|
| 41 |
-
url = "https://download.pytorch.org/whl/
|
| 42 |
explicit = true
|
| 43 |
|
| 44 |
[tool.pytest.ini_options]
|
|
@@ -57,4 +59,3 @@ src = ["src", "test"]
|
|
| 57 |
|
| 58 |
[tool.ruff.lint]
|
| 59 |
select = ["E", "F", "I", "B", "UP", "SIM"]
|
| 60 |
-
|
|
|
|
| 3 |
version = "0.1.0"
|
| 4 |
description = "Face verification demo using FaceNet embeddings, ChromaDB vector search, and CI/CD pipeline."
|
| 5 |
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.11,<3.14"
|
| 7 |
dependencies = [
|
| 8 |
"numpy>=1.24,<2.0",
|
| 9 |
"datasets>=4.8.5",
|
|
|
|
| 12 |
"huggingface-hub>=1.14.0",
|
| 13 |
"python-dotenv>=1.2.2",
|
| 14 |
"pydantic-settings>=2.14.1",
|
|
|
|
|
|
|
| 15 |
"chromadb>=1.5.9",
|
| 16 |
"pytest>=9.0.3",
|
| 17 |
"pytest-cov>=7.1.0",
|
| 18 |
"fastapi>=0.136.1",
|
| 19 |
+
"pyjwt>=2.10.1",
|
| 20 |
+
"python-multipart>=0.0.28",
|
| 21 |
"uvicorn[standard]>=0.46.0",
|
| 22 |
"ruff>=0.15.13",
|
| 23 |
+
"torch>=2.12.0",
|
| 24 |
+
"torchvision>=0.27.0",
|
| 25 |
]
|
| 26 |
|
| 27 |
[project.scripts]
|
|
|
|
| 35 |
where = ["src"]
|
| 36 |
|
| 37 |
[tool.uv.sources]
|
| 38 |
+
torch = { index = "pytorch-cpu" }
|
| 39 |
+
torchvision = { index = "pytorch-cpu" }
|
| 40 |
|
| 41 |
[[tool.uv.index]]
|
| 42 |
+
name = "pytorch-cpu"
|
| 43 |
+
url = "https://download.pytorch.org/whl/cpu"
|
| 44 |
explicit = true
|
| 45 |
|
| 46 |
[tool.pytest.ini_options]
|
|
|
|
| 59 |
|
| 60 |
[tool.ruff.lint]
|
| 61 |
select = ["E", "F", "I", "B", "UP", "SIM"]
|
|
|
requirements.txt
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
# This file was autogenerated by uv via the following command:
|
| 2 |
-
# uv export --locked --no-hashes --format requirements.txt --output-file requirements.txt
|
| 3 |
-
--
|
|
|
|
| 4 |
aiohappyeyeballs==2.6.1
|
| 5 |
# via aiohttp
|
| 6 |
aiohttp==3.13.5
|
|
@@ -46,13 +47,15 @@ click==8.3.3
|
|
| 46 |
# via
|
| 47 |
# typer
|
| 48 |
# uvicorn
|
| 49 |
-
colorama==0.4.6 ; os_name =
|
| 50 |
# via
|
| 51 |
# build
|
| 52 |
# click
|
| 53 |
# pytest
|
| 54 |
# tqdm
|
| 55 |
# uvicorn
|
|
|
|
|
|
|
| 56 |
datasets==4.8.5
|
| 57 |
# via faceverification
|
| 58 |
dill==0.4.1
|
|
@@ -64,7 +67,9 @@ durationpy==0.10
|
|
| 64 |
facenet-pytorch==2.5.3
|
| 65 |
# via faceverification
|
| 66 |
fastapi==0.136.1
|
| 67 |
-
# via
|
|
|
|
|
|
|
| 68 |
filelock==3.29.0
|
| 69 |
# via
|
| 70 |
# datasets
|
|
@@ -214,11 +219,7 @@ packaging==26.2
|
|
| 214 |
# huggingface-hub
|
| 215 |
# onnxruntime
|
| 216 |
# pytest
|
| 217 |
-
pandas==
|
| 218 |
-
# via
|
| 219 |
-
# datasets
|
| 220 |
-
# gradio
|
| 221 |
-
pandas==3.0.2 ; python_full_version < '3.14'
|
| 222 |
# via
|
| 223 |
# datasets
|
| 224 |
# gradio
|
|
@@ -228,7 +229,9 @@ pillow==12.2.0
|
|
| 228 |
# gradio
|
| 229 |
# torchvision
|
| 230 |
pluggy==1.6.0
|
| 231 |
-
# via
|
|
|
|
|
|
|
| 232 |
propcache==0.5.2
|
| 233 |
# via
|
| 234 |
# aiohttp
|
|
@@ -242,13 +245,13 @@ pyarrow==24.0.0
|
|
| 242 |
# via datasets
|
| 243 |
pybase64==1.4.3
|
| 244 |
# via chromadb
|
| 245 |
-
pydantic==2.
|
| 246 |
# via
|
| 247 |
# chromadb
|
| 248 |
# fastapi
|
| 249 |
# gradio
|
| 250 |
# pydantic-settings
|
| 251 |
-
pydantic-core==2.
|
| 252 |
# via pydantic
|
| 253 |
pydantic-settings==2.14.1
|
| 254 |
# via
|
|
@@ -260,11 +263,17 @@ pygments==2.20.0
|
|
| 260 |
# via
|
| 261 |
# pytest
|
| 262 |
# rich
|
|
|
|
|
|
|
| 263 |
pypika==0.51.1
|
| 264 |
# via chromadb
|
| 265 |
pyproject-hooks==1.2.0
|
| 266 |
# via build
|
| 267 |
pytest==9.0.3
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
# via faceverification
|
| 269 |
python-dateutil==2.9.0.post0
|
| 270 |
# via
|
|
@@ -276,11 +285,11 @@ python-dotenv==1.2.2
|
|
| 276 |
# pydantic-settings
|
| 277 |
# uvicorn
|
| 278 |
python-multipart==0.0.28
|
| 279 |
-
# via gradio
|
| 280 |
-
pytz==2026.2
|
| 281 |
# via
|
|
|
|
| 282 |
# gradio
|
| 283 |
-
|
|
|
|
| 284 |
pyyaml==6.0.3
|
| 285 |
# via
|
| 286 |
# chromadb
|
|
@@ -309,6 +318,8 @@ rpds-py==0.30.0
|
|
| 309 |
# via
|
| 310 |
# jsonschema
|
| 311 |
# referencing
|
|
|
|
|
|
|
| 312 |
safehttpx==0.1.7
|
| 313 |
# via gradio
|
| 314 |
semantic-version==2.10.0
|
|
@@ -331,13 +342,23 @@ tenacity==9.1.4
|
|
| 331 |
# via chromadb
|
| 332 |
tokenizers==0.23.1
|
| 333 |
# via chromadb
|
|
|
|
|
|
|
| 334 |
tomlkit==0.14.0
|
| 335 |
# via gradio
|
| 336 |
-
torch==2.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
# via
|
| 338 |
# faceverification
|
| 339 |
# torchvision
|
| 340 |
-
torchvision==0.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
# via
|
| 342 |
# facenet-pytorch
|
| 343 |
# faceverification
|
|
@@ -346,8 +367,6 @@ tqdm==4.67.3
|
|
| 346 |
# chromadb
|
| 347 |
# datasets
|
| 348 |
# huggingface-hub
|
| 349 |
-
triton==3.6.0 ; sys_platform == 'linux'
|
| 350 |
-
# via torch
|
| 351 |
typer==0.25.1
|
| 352 |
# via
|
| 353 |
# chromadb
|
|
@@ -379,7 +398,7 @@ typing-inspection==0.4.2
|
|
| 379 |
# fastapi
|
| 380 |
# pydantic
|
| 381 |
# pydantic-settings
|
| 382 |
-
tzdata==2026.2 ;
|
| 383 |
# via pandas
|
| 384 |
urllib3==2.7.0
|
| 385 |
# via
|
|
@@ -388,6 +407,7 @@ urllib3==2.7.0
|
|
| 388 |
uvicorn==0.46.0
|
| 389 |
# via
|
| 390 |
# chromadb
|
|
|
|
| 391 |
# gradio
|
| 392 |
uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
|
| 393 |
# via uvicorn
|
|
|
|
| 1 |
# This file was autogenerated by uv via the following command:
|
| 2 |
+
# uv --cache-dir .uv-cache export --locked --no-hashes --no-emit-project --format requirements.txt --output-file requirements.txt
|
| 3 |
+
--find-links https://download.pytorch.org/whl/cpu/torch/
|
| 4 |
+
--find-links https://download.pytorch.org/whl/cpu/torchvision/
|
| 5 |
aiohappyeyeballs==2.6.1
|
| 6 |
# via aiohttp
|
| 7 |
aiohttp==3.13.5
|
|
|
|
| 47 |
# via
|
| 48 |
# typer
|
| 49 |
# uvicorn
|
| 50 |
+
colorama==0.4.6 ; (os_name != 'nt' and sys_platform == 'win32') or (os_name == 'nt' and sys_platform != 'darwin')
|
| 51 |
# via
|
| 52 |
# build
|
| 53 |
# click
|
| 54 |
# pytest
|
| 55 |
# tqdm
|
| 56 |
# uvicorn
|
| 57 |
+
coverage==7.14.0
|
| 58 |
+
# via pytest-cov
|
| 59 |
datasets==4.8.5
|
| 60 |
# via faceverification
|
| 61 |
dill==0.4.1
|
|
|
|
| 67 |
facenet-pytorch==2.5.3
|
| 68 |
# via faceverification
|
| 69 |
fastapi==0.136.1
|
| 70 |
+
# via
|
| 71 |
+
# faceverification
|
| 72 |
+
# gradio
|
| 73 |
filelock==3.29.0
|
| 74 |
# via
|
| 75 |
# datasets
|
|
|
|
| 219 |
# huggingface-hub
|
| 220 |
# onnxruntime
|
| 221 |
# pytest
|
| 222 |
+
pandas==3.0.2
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
# via
|
| 224 |
# datasets
|
| 225 |
# gradio
|
|
|
|
| 229 |
# gradio
|
| 230 |
# torchvision
|
| 231 |
pluggy==1.6.0
|
| 232 |
+
# via
|
| 233 |
+
# pytest
|
| 234 |
+
# pytest-cov
|
| 235 |
propcache==0.5.2
|
| 236 |
# via
|
| 237 |
# aiohttp
|
|
|
|
| 245 |
# via datasets
|
| 246 |
pybase64==1.4.3
|
| 247 |
# via chromadb
|
| 248 |
+
pydantic==2.13.4
|
| 249 |
# via
|
| 250 |
# chromadb
|
| 251 |
# fastapi
|
| 252 |
# gradio
|
| 253 |
# pydantic-settings
|
| 254 |
+
pydantic-core==2.46.4
|
| 255 |
# via pydantic
|
| 256 |
pydantic-settings==2.14.1
|
| 257 |
# via
|
|
|
|
| 263 |
# via
|
| 264 |
# pytest
|
| 265 |
# rich
|
| 266 |
+
pyjwt==2.12.1
|
| 267 |
+
# via faceverification
|
| 268 |
pypika==0.51.1
|
| 269 |
# via chromadb
|
| 270 |
pyproject-hooks==1.2.0
|
| 271 |
# via build
|
| 272 |
pytest==9.0.3
|
| 273 |
+
# via
|
| 274 |
+
# faceverification
|
| 275 |
+
# pytest-cov
|
| 276 |
+
pytest-cov==7.1.0
|
| 277 |
# via faceverification
|
| 278 |
python-dateutil==2.9.0.post0
|
| 279 |
# via
|
|
|
|
| 285 |
# pydantic-settings
|
| 286 |
# uvicorn
|
| 287 |
python-multipart==0.0.28
|
|
|
|
|
|
|
| 288 |
# via
|
| 289 |
+
# faceverification
|
| 290 |
# gradio
|
| 291 |
+
pytz==2026.2
|
| 292 |
+
# via gradio
|
| 293 |
pyyaml==6.0.3
|
| 294 |
# via
|
| 295 |
# chromadb
|
|
|
|
| 318 |
# via
|
| 319 |
# jsonschema
|
| 320 |
# referencing
|
| 321 |
+
ruff==0.15.13
|
| 322 |
+
# via faceverification
|
| 323 |
safehttpx==0.1.7
|
| 324 |
# via gradio
|
| 325 |
semantic-version==2.10.0
|
|
|
|
| 342 |
# via chromadb
|
| 343 |
tokenizers==0.23.1
|
| 344 |
# via chromadb
|
| 345 |
+
tomli==2.4.1 ; python_full_version <= '3.11'
|
| 346 |
+
# via coverage
|
| 347 |
tomlkit==0.14.0
|
| 348 |
# via gradio
|
| 349 |
+
torch==2.12.0 ; sys_platform == 'darwin'
|
| 350 |
+
# via
|
| 351 |
+
# faceverification
|
| 352 |
+
# torchvision
|
| 353 |
+
torch==2.12.0+cpu ; sys_platform != 'darwin'
|
| 354 |
# via
|
| 355 |
# faceverification
|
| 356 |
# torchvision
|
| 357 |
+
torchvision==0.27.0 ; sys_platform == 'darwin'
|
| 358 |
+
# via
|
| 359 |
+
# facenet-pytorch
|
| 360 |
+
# faceverification
|
| 361 |
+
torchvision==0.27.0+cpu ; sys_platform != 'darwin'
|
| 362 |
# via
|
| 363 |
# facenet-pytorch
|
| 364 |
# faceverification
|
|
|
|
| 367 |
# chromadb
|
| 368 |
# datasets
|
| 369 |
# huggingface-hub
|
|
|
|
|
|
|
| 370 |
typer==0.25.1
|
| 371 |
# via
|
| 372 |
# chromadb
|
|
|
|
| 398 |
# fastapi
|
| 399 |
# pydantic
|
| 400 |
# pydantic-settings
|
| 401 |
+
tzdata==2026.2 ; sys_platform == 'emscripten' or sys_platform == 'win32'
|
| 402 |
# via pandas
|
| 403 |
urllib3==2.7.0
|
| 404 |
# via
|
|
|
|
| 407 |
uvicorn==0.46.0
|
| 408 |
# via
|
| 409 |
# chromadb
|
| 410 |
+
# faceverification
|
| 411 |
# gradio
|
| 412 |
uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
|
| 413 |
# via uvicorn
|
src/faceverification/config.py
CHANGED
|
@@ -14,6 +14,12 @@ class Settings(BaseSettings):
|
|
| 14 |
mtcnn_thresholds: tuple[float, float, float] = (0.6, 0.7, 0.95)
|
| 15 |
facenet_pretrained: str = "vggface2"
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
model_config = SettingsConfigDict(
|
| 18 |
env_file=".env",
|
| 19 |
env_prefix="FACEVERIFICATION_",
|
|
|
|
| 14 |
mtcnn_thresholds: tuple[float, float, float] = (0.6, 0.7, 0.95)
|
| 15 |
facenet_pretrained: str = "vggface2"
|
| 16 |
|
| 17 |
+
demo_username: str = "demo"
|
| 18 |
+
demo_password: str = "demo123"
|
| 19 |
+
jwt_secret_key: str = "change-me-in-production-demo-secret-32-bytes-min"
|
| 20 |
+
jwt_algorithm: str = "HS256"
|
| 21 |
+
jwt_access_token_expire_minutes: int = 60
|
| 22 |
+
|
| 23 |
model_config = SettingsConfigDict(
|
| 24 |
env_file=".env",
|
| 25 |
env_prefix="FACEVERIFICATION_",
|
src/faceverification/interfaces/fastapi_app.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP API for enrolling and verifying faces.
|
| 2 |
+
|
| 3 |
+
The module exposes a small FastAPI application around the service layer:
|
| 4 |
+
|
| 5 |
+
- ``POST /auth/login`` issues a short-lived JWT for the demo user.
|
| 6 |
+
- ``POST /persons`` stores a known person embedding from an uploaded image.
|
| 7 |
+
- ``POST /verify`` checks whether an uploaded face matches the local database.
|
| 8 |
+
|
| 9 |
+
FastAPI uses the route metadata, Pydantic field descriptions, and endpoint
|
| 10 |
+
docstrings below to build the interactive documentation at ``/docs`` and
|
| 11 |
+
``/redoc``.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from base64 import b64encode
|
| 15 |
+
from contextlib import asynccontextmanager
|
| 16 |
+
from datetime import UTC, datetime, timedelta
|
| 17 |
+
from io import BytesIO
|
| 18 |
+
from secrets import compare_digest
|
| 19 |
+
from types import ModuleType
|
| 20 |
+
from typing import Annotated
|
| 21 |
+
|
| 22 |
+
import jwt
|
| 23 |
+
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
|
| 24 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 25 |
+
from jwt import ExpiredSignatureError, InvalidTokenError
|
| 26 |
+
from PIL import Image, ImageOps, UnidentifiedImageError
|
| 27 |
+
from pydantic import BaseModel, Field
|
| 28 |
+
|
| 29 |
+
from faceverification.config import settings
|
| 30 |
+
from faceverification.core.image_processor import FaceNotDetectedError
|
| 31 |
+
|
| 32 |
+
bearer_scheme = HTTPBearer(auto_error=False)
|
| 33 |
+
|
| 34 |
+
DATA_URL_DESCRIPTION = (
|
| 35 |
+
"PNG image encoded as a data URL. The image contains the service annotations "
|
| 36 |
+
"for the detected face."
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
AUTH_RESPONSES = {
|
| 40 |
+
status.HTTP_401_UNAUTHORIZED: {
|
| 41 |
+
"description": "The request is missing a bearer token or the token is invalid.",
|
| 42 |
+
"content": {
|
| 43 |
+
"application/json": {
|
| 44 |
+
"example": {"detail": "Not authenticated"},
|
| 45 |
+
},
|
| 46 |
+
},
|
| 47 |
+
},
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
IMAGE_ERROR_RESPONSES = {
|
| 51 |
+
status.HTTP_400_BAD_REQUEST: {
|
| 52 |
+
"description": "The uploaded file is empty, invalid, or rejected by the service.",
|
| 53 |
+
"content": {
|
| 54 |
+
"application/json": {
|
| 55 |
+
"example": {"detail": "Uploaded file is not a valid image."},
|
| 56 |
+
},
|
| 57 |
+
},
|
| 58 |
+
},
|
| 59 |
+
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE: {
|
| 60 |
+
"description": "The uploaded file content type is not an image.",
|
| 61 |
+
"content": {
|
| 62 |
+
"application/json": {
|
| 63 |
+
"example": {"detail": "Uploaded file must be an image."},
|
| 64 |
+
},
|
| 65 |
+
},
|
| 66 |
+
},
|
| 67 |
+
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
| 68 |
+
"description": "The request is valid, but no usable face or name was found.",
|
| 69 |
+
"content": {
|
| 70 |
+
"application/json": {
|
| 71 |
+
"example": {"detail": "No faces were detected in the image."},
|
| 72 |
+
},
|
| 73 |
+
},
|
| 74 |
+
},
|
| 75 |
+
status.HTTP_500_INTERNAL_SERVER_ERROR: {
|
| 76 |
+
"description": "The face verification pipeline failed unexpectedly.",
|
| 77 |
+
"content": {
|
| 78 |
+
"application/json": {
|
| 79 |
+
"example": {"detail": "Face verification failed."},
|
| 80 |
+
},
|
| 81 |
+
},
|
| 82 |
+
},
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@asynccontextmanager
|
| 87 |
+
async def lifespan(app: FastAPI):
|
| 88 |
+
"""Load the face verification service once when the API starts."""
|
| 89 |
+
from faceverification.services import face_verification
|
| 90 |
+
|
| 91 |
+
app.state.face_service = face_verification
|
| 92 |
+
yield
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
app = FastAPI(
|
| 96 |
+
title="Face Verification API",
|
| 97 |
+
description=(
|
| 98 |
+
"Demo API for enrolling known people and verifying uploaded face images. "
|
| 99 |
+
"Authenticate with `/auth/login`, then send the returned bearer token to "
|
| 100 |
+
"the protected face-verification endpoints."
|
| 101 |
+
),
|
| 102 |
+
version="0.1.0",
|
| 103 |
+
lifespan=lifespan,
|
| 104 |
+
contact={
|
| 105 |
+
"name": "Leandro",
|
| 106 |
+
"url": "https://github.com/leandrodevai/faceverification",
|
| 107 |
+
},
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class HealthResponse(BaseModel):
|
| 112 |
+
"""Health-check payload returned by the system endpoint."""
|
| 113 |
+
|
| 114 |
+
status: str = Field(default="ok", description="Current API status.")
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class EnrollResponse(BaseModel):
|
| 118 |
+
"""Response returned after a person is stored in the embeddings database."""
|
| 119 |
+
|
| 120 |
+
message: str = Field(description="Human-readable result message.")
|
| 121 |
+
name: str = Field(description="Normalized person name stored with the embedding.")
|
| 122 |
+
annotated_image: str = Field(description=DATA_URL_DESCRIPTION)
|
| 123 |
+
|
| 124 |
+
model_config = {
|
| 125 |
+
"json_schema_extra": {
|
| 126 |
+
"example": {
|
| 127 |
+
"message": "Person added to the embeddings database.",
|
| 128 |
+
"name": "Ada Lovelace",
|
| 129 |
+
"annotated_image": "data:image/png;base64,iVBORw0KGgo...",
|
| 130 |
+
},
|
| 131 |
+
},
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class VerifyResponse(BaseModel):
|
| 136 |
+
"""Response returned after comparing an uploaded face against known people."""
|
| 137 |
+
|
| 138 |
+
name: str = Field(
|
| 139 |
+
description=(
|
| 140 |
+
"Matched person name. Returns `Unregistered Person` when the closest "
|
| 141 |
+
"embedding is outside the configured match threshold."
|
| 142 |
+
),
|
| 143 |
+
)
|
| 144 |
+
matched: bool = Field(description="Whether the uploaded face matched a known person.")
|
| 145 |
+
annotated_image: str = Field(description=DATA_URL_DESCRIPTION)
|
| 146 |
+
|
| 147 |
+
model_config = {
|
| 148 |
+
"json_schema_extra": {
|
| 149 |
+
"example": {
|
| 150 |
+
"name": "Ada Lovelace",
|
| 151 |
+
"matched": True,
|
| 152 |
+
"annotated_image": "data:image/png;base64,iVBORw0KGgo...",
|
| 153 |
+
},
|
| 154 |
+
},
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class TokenResponse(BaseModel):
|
| 159 |
+
"""Bearer token returned by the demo authentication endpoint."""
|
| 160 |
+
|
| 161 |
+
access_token: str = Field(description="JWT access token used in the Authorization header.")
|
| 162 |
+
token_type: str = Field(default="bearer", description="OAuth2-compatible token type.")
|
| 163 |
+
|
| 164 |
+
model_config = {
|
| 165 |
+
"json_schema_extra": {
|
| 166 |
+
"example": {
|
| 167 |
+
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
| 168 |
+
"token_type": "bearer",
|
| 169 |
+
},
|
| 170 |
+
},
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def get_face_service(request: Request) -> ModuleType:
|
| 175 |
+
"""Return the service module stored during application startup."""
|
| 176 |
+
return request.app.state.face_service
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _unauthorized_error(detail: str = "Could not validate credentials.") -> HTTPException:
|
| 180 |
+
"""Build a consistent 401 response with the bearer authentication challenge."""
|
| 181 |
+
return HTTPException(
|
| 182 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 183 |
+
detail=detail,
|
| 184 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _create_access_token(username: str) -> str:
|
| 189 |
+
"""Create a signed JWT for the authenticated demo user."""
|
| 190 |
+
expires_at = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes)
|
| 191 |
+
payload = {"sub": username, "exp": expires_at}
|
| 192 |
+
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _authenticate_demo_user(username: str, password: str) -> bool:
|
| 196 |
+
"""Validate demo credentials using constant-time comparisons."""
|
| 197 |
+
valid_username = compare_digest(username, settings.demo_username)
|
| 198 |
+
valid_password = compare_digest(password, settings.demo_password)
|
| 199 |
+
return valid_username and valid_password
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def get_current_username(
|
| 203 |
+
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
|
| 204 |
+
) -> str:
|
| 205 |
+
"""Decode the bearer token and return the authenticated username."""
|
| 206 |
+
if credentials is None:
|
| 207 |
+
raise _unauthorized_error("Not authenticated")
|
| 208 |
+
|
| 209 |
+
try:
|
| 210 |
+
token = credentials.credentials
|
| 211 |
+
payload = jwt.decode(
|
| 212 |
+
token,
|
| 213 |
+
settings.jwt_secret_key,
|
| 214 |
+
algorithms=[settings.jwt_algorithm],
|
| 215 |
+
)
|
| 216 |
+
except ExpiredSignatureError as exc:
|
| 217 |
+
raise _unauthorized_error("Token has expired.") from exc
|
| 218 |
+
except InvalidTokenError as exc:
|
| 219 |
+
raise _unauthorized_error() from exc
|
| 220 |
+
|
| 221 |
+
username = payload.get("sub")
|
| 222 |
+
if not isinstance(username, str) or not username:
|
| 223 |
+
raise _unauthorized_error()
|
| 224 |
+
|
| 225 |
+
return username
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
async def _read_image(upload: UploadFile) -> Image.Image:
|
| 229 |
+
"""Read an uploaded image, apply EXIF orientation, and return it as RGB."""
|
| 230 |
+
if upload.content_type and not upload.content_type.startswith("image/"):
|
| 231 |
+
raise HTTPException(
|
| 232 |
+
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
| 233 |
+
detail="Uploaded file must be an image.",
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
contents = await upload.read()
|
| 237 |
+
if not contents:
|
| 238 |
+
raise HTTPException(
|
| 239 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 240 |
+
detail="Uploaded image is empty.",
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
try:
|
| 244 |
+
image = Image.open(BytesIO(contents))
|
| 245 |
+
image = ImageOps.exif_transpose(image)
|
| 246 |
+
return image.convert("RGB")
|
| 247 |
+
except (UnidentifiedImageError, OSError) as exc:
|
| 248 |
+
raise HTTPException(
|
| 249 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 250 |
+
detail="Uploaded file is not a valid image.",
|
| 251 |
+
) from exc
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _image_to_data_url(image: Image.Image) -> str:
|
| 255 |
+
"""Serialize a PIL image as a PNG data URL for JSON responses."""
|
| 256 |
+
buffer = BytesIO()
|
| 257 |
+
image.save(buffer, format="PNG")
|
| 258 |
+
encoded = b64encode(buffer.getvalue()).decode("ascii")
|
| 259 |
+
return f"data:image/png;base64,{encoded}"
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _service_error(exc: Exception) -> HTTPException:
|
| 263 |
+
"""Map service-layer exceptions to API-friendly HTTP errors."""
|
| 264 |
+
if isinstance(exc, FaceNotDetectedError):
|
| 265 |
+
return HTTPException(
|
| 266 |
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
| 267 |
+
detail=str(exc),
|
| 268 |
+
)
|
| 269 |
+
if isinstance(exc, ValueError):
|
| 270 |
+
return HTTPException(
|
| 271 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 272 |
+
detail=str(exc),
|
| 273 |
+
)
|
| 274 |
+
return HTTPException(
|
| 275 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 276 |
+
detail="Face verification failed.",
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
@app.get(
|
| 281 |
+
"/health",
|
| 282 |
+
response_model=HealthResponse,
|
| 283 |
+
summary="Check API health",
|
| 284 |
+
response_description="The API is running.",
|
| 285 |
+
tags=["system"],
|
| 286 |
+
)
|
| 287 |
+
def health() -> HealthResponse:
|
| 288 |
+
"""Return a lightweight status response for uptime checks."""
|
| 289 |
+
return HealthResponse()
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
@app.post(
|
| 293 |
+
"/auth/login",
|
| 294 |
+
response_model=TokenResponse,
|
| 295 |
+
summary="Issue a demo access token",
|
| 296 |
+
response_description="JWT bearer token for protected endpoints.",
|
| 297 |
+
responses={
|
| 298 |
+
status.HTTP_401_UNAUTHORIZED: {
|
| 299 |
+
"description": "The username or password is incorrect.",
|
| 300 |
+
"content": {
|
| 301 |
+
"application/json": {
|
| 302 |
+
"example": {"detail": "Incorrect username or password."},
|
| 303 |
+
},
|
| 304 |
+
},
|
| 305 |
+
},
|
| 306 |
+
},
|
| 307 |
+
tags=["auth"],
|
| 308 |
+
)
|
| 309 |
+
def login(
|
| 310 |
+
username: Annotated[
|
| 311 |
+
str,
|
| 312 |
+
Form(description="Demo username configured with `FACEVERIFICATION_DEMO_USERNAME`."),
|
| 313 |
+
],
|
| 314 |
+
password: Annotated[
|
| 315 |
+
str,
|
| 316 |
+
Form(description="Demo password configured with `FACEVERIFICATION_DEMO_PASSWORD`."),
|
| 317 |
+
],
|
| 318 |
+
) -> TokenResponse:
|
| 319 |
+
"""Authenticate the demo user and return a signed JWT access token."""
|
| 320 |
+
if not _authenticate_demo_user(username, password):
|
| 321 |
+
raise _unauthorized_error("Incorrect username or password.")
|
| 322 |
+
|
| 323 |
+
return TokenResponse(access_token=_create_access_token(username))
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
@app.post(
|
| 327 |
+
"/persons",
|
| 328 |
+
response_model=EnrollResponse,
|
| 329 |
+
status_code=status.HTTP_201_CREATED,
|
| 330 |
+
summary="Enroll a known person",
|
| 331 |
+
response_description="The person was stored and the annotated upload is returned.",
|
| 332 |
+
responses={**AUTH_RESPONSES, **IMAGE_ERROR_RESPONSES},
|
| 333 |
+
tags=["face verification"],
|
| 334 |
+
)
|
| 335 |
+
async def enroll_person(
|
| 336 |
+
image: Annotated[
|
| 337 |
+
UploadFile,
|
| 338 |
+
File(description="Image containing one clear face to enroll."),
|
| 339 |
+
],
|
| 340 |
+
name: Annotated[
|
| 341 |
+
str,
|
| 342 |
+
Form(description="Person name to associate with the generated face embedding."),
|
| 343 |
+
],
|
| 344 |
+
current_username: Annotated[str, Depends(get_current_username)],
|
| 345 |
+
service: Annotated[ModuleType, Depends(get_face_service)],
|
| 346 |
+
) -> EnrollResponse:
|
| 347 |
+
"""Store a new known person in the embeddings database.
|
| 348 |
+
|
| 349 |
+
The endpoint extracts a face embedding from the uploaded image and stores it
|
| 350 |
+
under the submitted name. It returns the normalized name and a PNG data URL
|
| 351 |
+
with the annotated detection result.
|
| 352 |
+
"""
|
| 353 |
+
cleaned_name = name.strip()
|
| 354 |
+
if not cleaned_name:
|
| 355 |
+
raise HTTPException(
|
| 356 |
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
| 357 |
+
detail="Person name is required.",
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
pil_image = await _read_image(image)
|
| 361 |
+
try:
|
| 362 |
+
annotated_image = service.add_person(pil_image, cleaned_name)
|
| 363 |
+
except Exception as exc:
|
| 364 |
+
raise _service_error(exc) from exc
|
| 365 |
+
|
| 366 |
+
return EnrollResponse(
|
| 367 |
+
message="Person added to the embeddings database.",
|
| 368 |
+
name=cleaned_name,
|
| 369 |
+
annotated_image=_image_to_data_url(annotated_image),
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
@app.post(
|
| 374 |
+
"/verify",
|
| 375 |
+
response_model=VerifyResponse,
|
| 376 |
+
summary="Verify an uploaded face",
|
| 377 |
+
response_description="Best match result and the annotated upload.",
|
| 378 |
+
responses={**AUTH_RESPONSES, **IMAGE_ERROR_RESPONSES},
|
| 379 |
+
tags=["face verification"],
|
| 380 |
+
)
|
| 381 |
+
async def verify_identity(
|
| 382 |
+
image: Annotated[
|
| 383 |
+
UploadFile,
|
| 384 |
+
File(description="Image containing one clear face to compare with known people."),
|
| 385 |
+
],
|
| 386 |
+
current_username: Annotated[str, Depends(get_current_username)],
|
| 387 |
+
service: Annotated[ModuleType, Depends(get_face_service)],
|
| 388 |
+
) -> VerifyResponse:
|
| 389 |
+
"""Compare an uploaded face against the local embeddings database.
|
| 390 |
+
|
| 391 |
+
A successful response always includes the closest label and whether it is
|
| 392 |
+
considered a match according to the configured distance threshold.
|
| 393 |
+
"""
|
| 394 |
+
pil_image = await _read_image(image)
|
| 395 |
+
try:
|
| 396 |
+
name, annotated_image = service.verify_person(pil_image)
|
| 397 |
+
except Exception as exc:
|
| 398 |
+
raise _service_error(exc) from exc
|
| 399 |
+
|
| 400 |
+
return VerifyResponse(
|
| 401 |
+
name=name,
|
| 402 |
+
matched=name != "Unregistered Person",
|
| 403 |
+
annotated_image=_image_to_data_url(annotated_image),
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def main() -> None:
|
| 408 |
+
import uvicorn
|
| 409 |
+
|
| 410 |
+
uvicorn.run("faceverification.interfaces.fastapi_app:app", host="0.0.0.0", port=8000)
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
if __name__ == "__main__":
|
| 414 |
+
main()
|
src/faceverification/interfaces/gradio_app.py
CHANGED
|
@@ -54,7 +54,7 @@ with gr.Blocks() as FV_gr:
|
|
| 54 |
|
| 55 |
|
| 56 |
def main():
|
| 57 |
-
FV_gr.launch()
|
| 58 |
|
| 59 |
|
| 60 |
if __name__ == "__main__":
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
def main():
|
| 57 |
+
FV_gr.launch(server_name="0.0.0.0", server_port=7860)
|
| 58 |
|
| 59 |
|
| 60 |
if __name__ == "__main__":
|
test/test_fastapi_app.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from io import BytesIO
|
| 2 |
+
|
| 3 |
+
from fastapi.testclient import TestClient
|
| 4 |
+
from PIL import Image
|
| 5 |
+
|
| 6 |
+
from faceverification.core.image_processor import FaceNotDetectedError
|
| 7 |
+
from faceverification.interfaces.fastapi_app import app, get_face_service
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _image_bytes() -> bytes:
|
| 11 |
+
buffer = BytesIO()
|
| 12 |
+
Image.new("RGB", (12, 12), "white").save(buffer, format="PNG")
|
| 13 |
+
return buffer.getvalue()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class FakeService:
|
| 17 |
+
def __init__(self):
|
| 18 |
+
self.add_person_calls = []
|
| 19 |
+
self.verify_person_calls = []
|
| 20 |
+
|
| 21 |
+
def add_person(self, image, name):
|
| 22 |
+
self.add_person_calls.append((image, name))
|
| 23 |
+
return Image.new("RGB", image.size, "black")
|
| 24 |
+
|
| 25 |
+
def verify_person(self, image):
|
| 26 |
+
self.verify_person_calls.append(image)
|
| 27 |
+
return "Ada", Image.new("RGB", image.size, "black")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _auth_headers(client: TestClient) -> dict[str, str]:
|
| 31 |
+
response = client.post(
|
| 32 |
+
"/auth/login",
|
| 33 |
+
data={"username": "demo", "password": "demo123"},
|
| 34 |
+
)
|
| 35 |
+
token = response.json()["access_token"]
|
| 36 |
+
return {"Authorization": f"Bearer {token}"}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_health_returns_ok():
|
| 40 |
+
client = TestClient(app)
|
| 41 |
+
|
| 42 |
+
response = client.get("/health")
|
| 43 |
+
|
| 44 |
+
assert response.status_code == 200
|
| 45 |
+
assert response.json() == {"status": "ok"}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_login_returns_access_token():
|
| 49 |
+
client = TestClient(app)
|
| 50 |
+
|
| 51 |
+
response = client.post(
|
| 52 |
+
"/auth/login",
|
| 53 |
+
data={"username": "demo", "password": "demo123"},
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
body = response.json()
|
| 57 |
+
assert response.status_code == 200
|
| 58 |
+
assert body["token_type"] == "bearer"
|
| 59 |
+
assert body["access_token"]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_login_rejects_invalid_credentials():
|
| 63 |
+
client = TestClient(app)
|
| 64 |
+
|
| 65 |
+
response = client.post(
|
| 66 |
+
"/auth/login",
|
| 67 |
+
data={"username": "demo", "password": "wrong"},
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
assert response.status_code == 401
|
| 71 |
+
assert response.json() == {"detail": "Incorrect username or password."}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_verify_identity_requires_token():
|
| 75 |
+
client = TestClient(app)
|
| 76 |
+
|
| 77 |
+
response = client.post(
|
| 78 |
+
"/verify",
|
| 79 |
+
files={"image": ("face.png", _image_bytes(), "image/png")},
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
assert response.status_code == 401
|
| 83 |
+
assert response.json() == {"detail": "Not authenticated"}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_enroll_person_calls_service_and_returns_annotated_image():
|
| 87 |
+
fake_service = FakeService()
|
| 88 |
+
app.dependency_overrides[get_face_service] = lambda: fake_service
|
| 89 |
+
try:
|
| 90 |
+
client = TestClient(app)
|
| 91 |
+
response = client.post(
|
| 92 |
+
"/persons",
|
| 93 |
+
headers=_auth_headers(client),
|
| 94 |
+
data={"name": " Ada "},
|
| 95 |
+
files={"image": ("face.png", _image_bytes(), "image/png")},
|
| 96 |
+
)
|
| 97 |
+
finally:
|
| 98 |
+
app.dependency_overrides.clear()
|
| 99 |
+
|
| 100 |
+
body = response.json()
|
| 101 |
+
assert response.status_code == 201
|
| 102 |
+
assert body["name"] == "Ada"
|
| 103 |
+
assert body["message"] == "Person added to the embeddings database."
|
| 104 |
+
assert body["annotated_image"].startswith("data:image/png;base64,")
|
| 105 |
+
assert fake_service.add_person_calls[0][1] == "Ada"
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_verify_identity_returns_match_result():
|
| 109 |
+
fake_service = FakeService()
|
| 110 |
+
app.dependency_overrides[get_face_service] = lambda: fake_service
|
| 111 |
+
try:
|
| 112 |
+
client = TestClient(app)
|
| 113 |
+
response = client.post(
|
| 114 |
+
"/verify",
|
| 115 |
+
headers=_auth_headers(client),
|
| 116 |
+
files={"image": ("face.png", _image_bytes(), "image/png")},
|
| 117 |
+
)
|
| 118 |
+
finally:
|
| 119 |
+
app.dependency_overrides.clear()
|
| 120 |
+
|
| 121 |
+
body = response.json()
|
| 122 |
+
assert response.status_code == 200
|
| 123 |
+
assert body["name"] == "Ada"
|
| 124 |
+
assert body["matched"] is True
|
| 125 |
+
assert body["annotated_image"].startswith("data:image/png;base64,")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_verify_identity_returns_unprocessable_when_no_face_is_detected():
|
| 129 |
+
class NoFaceService(FakeService):
|
| 130 |
+
def verify_person(self, image):
|
| 131 |
+
raise FaceNotDetectedError("No faces were detected in the image.")
|
| 132 |
+
|
| 133 |
+
app.dependency_overrides[get_face_service] = lambda: NoFaceService()
|
| 134 |
+
try:
|
| 135 |
+
client = TestClient(app)
|
| 136 |
+
response = client.post(
|
| 137 |
+
"/verify",
|
| 138 |
+
headers=_auth_headers(client),
|
| 139 |
+
files={"image": ("face.png", _image_bytes(), "image/png")},
|
| 140 |
+
)
|
| 141 |
+
finally:
|
| 142 |
+
app.dependency_overrides.clear()
|
| 143 |
+
|
| 144 |
+
assert response.status_code == 422
|
| 145 |
+
assert response.json() == {"detail": "No faces were detected in the image."}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_enroll_person_rejects_blank_name():
|
| 149 |
+
app.dependency_overrides[get_face_service] = lambda: FakeService()
|
| 150 |
+
try:
|
| 151 |
+
client = TestClient(app)
|
| 152 |
+
response = client.post(
|
| 153 |
+
"/persons",
|
| 154 |
+
headers=_auth_headers(client),
|
| 155 |
+
data={"name": " "},
|
| 156 |
+
files={"image": ("face.png", _image_bytes(), "image/png")},
|
| 157 |
+
)
|
| 158 |
+
finally:
|
| 159 |
+
app.dependency_overrides.clear()
|
| 160 |
+
|
| 161 |
+
assert response.status_code == 422
|
| 162 |
+
assert response.json() == {"detail": "Person name is required."}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_upload_rejects_non_image_content_type():
|
| 166 |
+
app.dependency_overrides[get_face_service] = lambda: FakeService()
|
| 167 |
+
try:
|
| 168 |
+
client = TestClient(app)
|
| 169 |
+
response = client.post(
|
| 170 |
+
"/verify",
|
| 171 |
+
headers=_auth_headers(client),
|
| 172 |
+
files={"image": ("face.txt", b"hello", "text/plain")},
|
| 173 |
+
)
|
| 174 |
+
finally:
|
| 175 |
+
app.dependency_overrides.clear()
|
| 176 |
+
|
| 177 |
+
assert response.status_code == 415
|
| 178 |
+
assert response.json() == {"detail": "Uploaded file must be an image."}
|
uv.lock
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|