leandrodevai commited on
Commit
bf2c053
·
verified ·
1 Parent(s): 3bc2e22

Sync from GitHub via hub-sync

Browse files
.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-cu126" }
37
- torchvision = { index = "pytorch-cu126" }
38
 
39
  [[tool.uv.index]]
40
- name = "pytorch-cu126"
41
- url = "https://download.pytorch.org/whl/cu126"
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
- --extra-index-url https://download.pytorch.org/whl/cpu
 
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 == 'nt' or sys_platform == 'win32'
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 gradio
 
 
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==2.3.3 ; python_full_version >= '3.14'
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 pytest
 
 
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.12.5
246
  # via
247
  # chromadb
248
  # fastapi
249
  # gradio
250
  # pydantic-settings
251
- pydantic-core==2.41.5
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
- # pandas
 
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.11.0+cpu
 
 
 
 
337
  # via
338
  # faceverification
339
  # torchvision
340
- torchvision==0.26.0+cpu
 
 
 
 
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 ; python_full_version >= '3.14' or sys_platform == 'emscripten' or sys_platform == 'win32'
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