leandrodevai commited on
Commit
c628352
·
verified ·
1 Parent(s): d78d209

Sync from GitHub via hub-sync

Browse files
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.11
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leandro Patrón
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -5,10 +5,8 @@ colorFrom: blue
5
  colorTo: green
6
  sdk: gradio
7
  sdk_version: 6.14.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
- ---
13
-
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
5
  colorTo: green
6
  sdk: gradio
7
  sdk_version: 6.14.0
8
+ python_version: '3.11'
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
+ ---
 
 
app.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from faceverification.app import FV_gr
2
+
3
+ if __name__ == "__main__":
4
+ FV_gr.launch()
pyproject.toml ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "faceverification"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "numpy>=1.24,<2.0",
9
+ "datasets>=4.8.5",
10
+ "facenet-pytorch>=2.5.3",
11
+ "gradio>=6.14.0",
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
+ ]
20
+
21
+ [project.scripts]
22
+ faceverification = "faceverification.app:main"
23
+
24
+ [build-system]
25
+ requires = ["setuptools>=68"]
26
+ build-backend = "setuptools.build_meta"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+
31
+ [tool.uv.sources]
32
+ torch = { index = "pytorch-cu126" }
33
+ torchvision = { index = "pytorch-cu126" }
34
+
35
+ [[tool.uv.index]]
36
+ name = "pytorch-cu126"
37
+ url = "https://download.pytorch.org/whl/cu126"
38
+ explicit = true
39
+
40
+ [tool.pytest.ini_options]
41
+ pythonpath = ["src"]
42
+ testpaths = ["test"]
43
+ python_files = ["test_*.py"]
44
+ markers = [
45
+ "integration: tests that use real external components or models",
46
+ "e2e: end-to-end tests that exercise complete application workflows",
47
+ ]
requirements.txt ADDED
@@ -0,0 +1,450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ -e .
4
+ aiohappyeyeballs==2.6.1
5
+ # via aiohttp
6
+ aiohttp==3.13.5
7
+ # via fsspec
8
+ aiosignal==1.4.0
9
+ # via aiohttp
10
+ annotated-doc==0.0.4
11
+ # via
12
+ # fastapi
13
+ # typer
14
+ annotated-types==0.7.0
15
+ # via pydantic
16
+ anyio==4.13.0
17
+ # via
18
+ # gradio
19
+ # httpx
20
+ # starlette
21
+ # watchfiles
22
+ attrs==26.1.0
23
+ # via
24
+ # aiohttp
25
+ # jsonschema
26
+ # referencing
27
+ audioop-lts==0.2.2 ; python_full_version >= '3.13'
28
+ # via gradio
29
+ bcrypt==5.0.0
30
+ # via chromadb
31
+ brotli==1.2.0
32
+ # via gradio
33
+ build==1.5.0
34
+ # via chromadb
35
+ certifi==2026.4.22
36
+ # via
37
+ # httpcore
38
+ # httpx
39
+ # kubernetes
40
+ # requests
41
+ charset-normalizer==3.4.7
42
+ # via requests
43
+ chromadb==1.5.9
44
+ # via faceverification
45
+ 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
+ cuda-bindings==12.9.6 ; sys_platform == 'linux'
57
+ # via torch
58
+ cuda-pathfinder==1.5.4 ; sys_platform == 'linux'
59
+ # via cuda-bindings
60
+ cuda-toolkit==12.6.3 ; sys_platform == 'linux'
61
+ # via torch
62
+ datasets==4.8.5
63
+ # via faceverification
64
+ dill==0.4.1
65
+ # via
66
+ # datasets
67
+ # multiprocess
68
+ durationpy==0.10
69
+ # via kubernetes
70
+ facenet-pytorch==2.5.3
71
+ # via faceverification
72
+ fastapi==0.136.1
73
+ # via gradio
74
+ filelock==3.29.0
75
+ # via
76
+ # datasets
77
+ # huggingface-hub
78
+ # torch
79
+ flatbuffers==25.12.19
80
+ # via onnxruntime
81
+ frozenlist==1.8.0
82
+ # via
83
+ # aiohttp
84
+ # aiosignal
85
+ fsspec==2026.2.0
86
+ # via
87
+ # datasets
88
+ # gradio-client
89
+ # huggingface-hub
90
+ # torch
91
+ googleapis-common-protos==1.75.0
92
+ # via opentelemetry-exporter-otlp-proto-grpc
93
+ gradio==6.14.0
94
+ # via faceverification
95
+ gradio-client==2.5.0
96
+ # via
97
+ # gradio
98
+ # hf-gradio
99
+ groovy==0.1.2
100
+ # via gradio
101
+ grpcio==1.80.0
102
+ # via
103
+ # chromadb
104
+ # opentelemetry-exporter-otlp-proto-grpc
105
+ h11==0.16.0
106
+ # via
107
+ # httpcore
108
+ # uvicorn
109
+ hf-gradio==0.4.1
110
+ # via gradio
111
+ hf-xet==1.5.0 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
112
+ # via huggingface-hub
113
+ httpcore==1.0.9
114
+ # via httpx
115
+ httptools==0.7.1
116
+ # via uvicorn
117
+ httpx==0.28.1
118
+ # via
119
+ # chromadb
120
+ # datasets
121
+ # gradio
122
+ # gradio-client
123
+ # huggingface-hub
124
+ # safehttpx
125
+ huggingface-hub==1.14.0
126
+ # via
127
+ # datasets
128
+ # faceverification
129
+ # gradio
130
+ # gradio-client
131
+ # tokenizers
132
+ idna==3.14
133
+ # via
134
+ # anyio
135
+ # httpx
136
+ # requests
137
+ # yarl
138
+ importlib-metadata==8.7.1
139
+ # via opentelemetry-api
140
+ importlib-resources==7.1.0
141
+ # via chromadb
142
+ iniconfig==2.3.0
143
+ # via pytest
144
+ jinja2==3.1.6
145
+ # via
146
+ # gradio
147
+ # torch
148
+ jsonschema==4.26.0
149
+ # via chromadb
150
+ jsonschema-specifications==2025.9.1
151
+ # via jsonschema
152
+ kubernetes==35.0.0
153
+ # via chromadb
154
+ markdown-it-py==4.2.0
155
+ # via rich
156
+ markupsafe==3.0.3
157
+ # via
158
+ # gradio
159
+ # jinja2
160
+ mdurl==0.1.2
161
+ # via markdown-it-py
162
+ mmh3==5.2.1
163
+ # via chromadb
164
+ mpmath==1.3.0
165
+ # via sympy
166
+ multidict==6.7.1
167
+ # via
168
+ # aiohttp
169
+ # yarl
170
+ multiprocess==0.70.19
171
+ # via datasets
172
+ networkx==3.6.1
173
+ # via torch
174
+ numpy==1.26.4
175
+ # via
176
+ # chromadb
177
+ # datasets
178
+ # facenet-pytorch
179
+ # faceverification
180
+ # gradio
181
+ # onnxruntime
182
+ # pandas
183
+ # torchvision
184
+ nvidia-cublas-cu12==12.6.4.1 ; sys_platform == 'linux'
185
+ # via
186
+ # cuda-toolkit
187
+ # nvidia-cudnn-cu12
188
+ # nvidia-cusolver-cu12
189
+ nvidia-cuda-cupti-cu12==12.6.80 ; sys_platform == 'linux'
190
+ # via cuda-toolkit
191
+ nvidia-cuda-nvrtc-cu12==12.6.85 ; sys_platform == 'linux'
192
+ # via cuda-toolkit
193
+ nvidia-cuda-runtime-cu12==12.6.77 ; sys_platform == 'linux'
194
+ # via cuda-toolkit
195
+ nvidia-cudnn-cu12==9.10.2.21 ; sys_platform == 'linux'
196
+ # via torch
197
+ nvidia-cufft-cu12==11.3.0.4 ; sys_platform == 'linux'
198
+ # via cuda-toolkit
199
+ nvidia-cufile-cu12==1.11.1.6 ; sys_platform == 'linux'
200
+ # via cuda-toolkit
201
+ nvidia-curand-cu12==10.3.7.77 ; sys_platform == 'linux'
202
+ # via cuda-toolkit
203
+ nvidia-cusolver-cu12==11.7.1.2 ; sys_platform == 'linux'
204
+ # via cuda-toolkit
205
+ nvidia-cusparse-cu12==12.5.4.2 ; sys_platform == 'linux'
206
+ # via
207
+ # cuda-toolkit
208
+ # nvidia-cusolver-cu12
209
+ nvidia-cusparselt-cu12==0.7.1 ; sys_platform == 'linux'
210
+ # via torch
211
+ nvidia-nccl-cu12==2.28.9 ; sys_platform == 'linux'
212
+ # via torch
213
+ nvidia-nvjitlink-cu12==12.6.85 ; sys_platform == 'linux'
214
+ # via
215
+ # cuda-toolkit
216
+ # nvidia-cufft-cu12
217
+ # nvidia-cusolver-cu12
218
+ # nvidia-cusparse-cu12
219
+ nvidia-nvshmem-cu12==3.4.5 ; sys_platform == 'linux'
220
+ # via torch
221
+ nvidia-nvtx-cu12==12.6.77 ; sys_platform == 'linux'
222
+ # via cuda-toolkit
223
+ oauthlib==3.3.1
224
+ # via requests-oauthlib
225
+ onnxruntime==1.26.0
226
+ # via chromadb
227
+ opentelemetry-api==1.41.1
228
+ # via
229
+ # chromadb
230
+ # opentelemetry-exporter-otlp-proto-grpc
231
+ # opentelemetry-sdk
232
+ # opentelemetry-semantic-conventions
233
+ opentelemetry-exporter-otlp-proto-common==1.41.1
234
+ # via opentelemetry-exporter-otlp-proto-grpc
235
+ opentelemetry-exporter-otlp-proto-grpc==1.41.1
236
+ # via chromadb
237
+ opentelemetry-proto==1.41.1
238
+ # via
239
+ # opentelemetry-exporter-otlp-proto-common
240
+ # opentelemetry-exporter-otlp-proto-grpc
241
+ opentelemetry-sdk==1.41.1
242
+ # via
243
+ # chromadb
244
+ # opentelemetry-exporter-otlp-proto-grpc
245
+ opentelemetry-semantic-conventions==0.62b1
246
+ # via opentelemetry-sdk
247
+ orjson==3.11.9
248
+ # via
249
+ # chromadb
250
+ # gradio
251
+ overrides==7.7.0
252
+ # via chromadb
253
+ packaging==26.2
254
+ # via
255
+ # build
256
+ # datasets
257
+ # gradio
258
+ # gradio-client
259
+ # huggingface-hub
260
+ # onnxruntime
261
+ # pytest
262
+ pandas==2.3.3 ; python_full_version >= '3.14'
263
+ # via
264
+ # datasets
265
+ # gradio
266
+ pandas==3.0.2 ; python_full_version < '3.14'
267
+ # via
268
+ # datasets
269
+ # gradio
270
+ pillow==12.2.0
271
+ # via
272
+ # facenet-pytorch
273
+ # gradio
274
+ # torchvision
275
+ pluggy==1.6.0
276
+ # via pytest
277
+ propcache==0.5.2
278
+ # via
279
+ # aiohttp
280
+ # yarl
281
+ protobuf==6.33.6
282
+ # via
283
+ # googleapis-common-protos
284
+ # onnxruntime
285
+ # opentelemetry-proto
286
+ pyarrow==24.0.0
287
+ # via datasets
288
+ pybase64==1.4.3
289
+ # via chromadb
290
+ pydantic==2.13.4
291
+ # via
292
+ # chromadb
293
+ # fastapi
294
+ # gradio
295
+ # pydantic-settings
296
+ pydantic-core==2.46.4
297
+ # via pydantic
298
+ pydantic-settings==2.14.1
299
+ # via
300
+ # chromadb
301
+ # faceverification
302
+ pydub==0.25.1
303
+ # via gradio
304
+ pygments==2.20.0
305
+ # via
306
+ # pytest
307
+ # rich
308
+ pypika==0.51.1
309
+ # via chromadb
310
+ pyproject-hooks==1.2.0
311
+ # via build
312
+ pytest==9.0.3
313
+ # via faceverification
314
+ python-dateutil==2.9.0.post0
315
+ # via
316
+ # kubernetes
317
+ # pandas
318
+ python-dotenv==1.2.2
319
+ # via
320
+ # faceverification
321
+ # pydantic-settings
322
+ # uvicorn
323
+ python-multipart==0.0.28
324
+ # via gradio
325
+ pytz==2026.2
326
+ # via
327
+ # gradio
328
+ # pandas
329
+ pyyaml==6.0.3
330
+ # via
331
+ # chromadb
332
+ # datasets
333
+ # gradio
334
+ # huggingface-hub
335
+ # kubernetes
336
+ # uvicorn
337
+ referencing==0.37.0
338
+ # via
339
+ # jsonschema
340
+ # jsonschema-specifications
341
+ requests==2.33.1
342
+ # via
343
+ # datasets
344
+ # facenet-pytorch
345
+ # kubernetes
346
+ # requests-oauthlib
347
+ requests-oauthlib==2.0.0
348
+ # via kubernetes
349
+ rich==15.0.0
350
+ # via
351
+ # chromadb
352
+ # typer
353
+ rpds-py==0.30.0
354
+ # via
355
+ # jsonschema
356
+ # referencing
357
+ safehttpx==0.1.7
358
+ # via gradio
359
+ semantic-version==2.10.0
360
+ # via gradio
361
+ setuptools==81.0.0
362
+ # via torch
363
+ shellingham==1.5.4
364
+ # via typer
365
+ six==1.17.0
366
+ # via
367
+ # kubernetes
368
+ # python-dateutil
369
+ starlette==1.0.0
370
+ # via
371
+ # fastapi
372
+ # gradio
373
+ sympy==1.14.0
374
+ # via torch
375
+ tenacity==9.1.4
376
+ # via chromadb
377
+ tokenizers==0.23.1
378
+ # via chromadb
379
+ tomlkit==0.14.0
380
+ # via gradio
381
+ torch==2.11.0+cu126
382
+ # via
383
+ # faceverification
384
+ # torchvision
385
+ torchvision==0.26.0+cu126
386
+ # via
387
+ # facenet-pytorch
388
+ # faceverification
389
+ tqdm==4.67.3
390
+ # via
391
+ # chromadb
392
+ # datasets
393
+ # huggingface-hub
394
+ triton==3.6.0 ; sys_platform == 'linux'
395
+ # via torch
396
+ typer==0.25.1
397
+ # via
398
+ # chromadb
399
+ # gradio
400
+ # hf-gradio
401
+ # huggingface-hub
402
+ typing-extensions==4.15.0
403
+ # via
404
+ # aiosignal
405
+ # anyio
406
+ # chromadb
407
+ # fastapi
408
+ # gradio
409
+ # gradio-client
410
+ # grpcio
411
+ # huggingface-hub
412
+ # opentelemetry-api
413
+ # opentelemetry-exporter-otlp-proto-grpc
414
+ # opentelemetry-sdk
415
+ # opentelemetry-semantic-conventions
416
+ # pydantic
417
+ # pydantic-core
418
+ # referencing
419
+ # starlette
420
+ # torch
421
+ # typing-inspection
422
+ typing-inspection==0.4.2
423
+ # via
424
+ # fastapi
425
+ # pydantic
426
+ # pydantic-settings
427
+ tzdata==2026.2 ; python_full_version >= '3.14' or sys_platform == 'emscripten' or sys_platform == 'win32'
428
+ # via pandas
429
+ urllib3==2.7.0
430
+ # via
431
+ # kubernetes
432
+ # requests
433
+ uvicorn==0.46.0
434
+ # via
435
+ # chromadb
436
+ # gradio
437
+ uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
438
+ # via uvicorn
439
+ watchfiles==1.1.1
440
+ # via uvicorn
441
+ websocket-client==1.9.0
442
+ # via kubernetes
443
+ websockets==16.0
444
+ # via uvicorn
445
+ xxhash==3.7.0
446
+ # via datasets
447
+ yarl==1.23.0
448
+ # via aiohttp
449
+ zipp==3.23.1
450
+ # via importlib-metadata
src/faceverification/__init__.py ADDED
File without changes
src/faceverification/app.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from PIL import Image
3
+
4
+ from faceverification.core.image_processor import FaceNotDetectedError
5
+ from faceverification.services.face_verification import add_person, verify_person
6
+
7
+
8
+ def add_person_ui(image: Image.Image, name: str) -> Image.Image:
9
+ try:
10
+ return add_person(image, name)
11
+ except FaceNotDetectedError as exc:
12
+ raise gr.Error(str(exc)) from exc
13
+ except Exception as exc:
14
+ raise gr.Error(str(exc)) from exc
15
+
16
+
17
+ def verify_person_ui(image: Image.Image) -> tuple[str, Image.Image]:
18
+ try:
19
+ return verify_person(image)
20
+ except FaceNotDetectedError as exc:
21
+ raise gr.Error(str(exc)) from exc
22
+ except Exception as exc:
23
+ raise gr.Error(str(exc)) from exc
24
+
25
+
26
+ with gr.Blocks() as FV_gr:
27
+ gr.Markdown(
28
+ "# Face Verification Tool\n"
29
+ "Upload an image and assign a name to add it to the embeddings database."
30
+ )
31
+ with gr.Tabs():
32
+ with gr.TabItem("Add Person"):
33
+ with gr.Row():
34
+ input_image = gr.Image(
35
+ label="Upload an image with a clear face", type="pil"
36
+ )
37
+ input_name = gr.Textbox(
38
+ label="Person name", placeholder="Example: John Doe"
39
+ )
40
+ output_image = gr.Image(label="Detection result")
41
+ submit_btn = gr.Button("Add to database")
42
+ submit_btn.click(
43
+ fn=add_person_ui,
44
+ inputs=[input_image, input_name],
45
+ outputs=output_image,
46
+ )
47
+ with gr.TabItem("Verify Identity"):
48
+ with gr.Row():
49
+ verify_image = gr.Image(label="Upload an image to verify", type="pil")
50
+ verify_btn = gr.Button("Verify identity")
51
+ verify_output_name = gr.Textbox(label="Verification result")
52
+ verify_output_image = gr.Image(label="Detected face")
53
+ verify_btn.click(
54
+ fn=verify_person_ui,
55
+ inputs=verify_image,
56
+ outputs=[verify_output_name, verify_output_image],
57
+ )
58
+
59
+
60
+ def main():
61
+ FV_gr.launch()
62
+
63
+
64
+ if __name__ == "__main__":
65
+ main()
src/faceverification/config.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Literal
2
+
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ vector_db_distance_metric: str = "l2"
8
+ vector_db_collection: str = "face_embeddings"
9
+ vector_db_persist_directory: str | None = None
10
+ vector_db_n_results: int = 5
11
+ face_match_threshold: float = 1.08
12
+
13
+ device: Literal["auto", "cpu", "cuda"] = "auto"
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_",
20
+ extra="ignore",
21
+ )
22
+
23
+
24
+ settings = Settings()
src/faceverification/core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
src/faceverification/core/image_processor.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Face detection and embedding extraction utilities.
2
+
3
+ This module centralizes the computer vision models used by the application:
4
+ MTCNN detects faces and draws bounding boxes, while FaceNet converts a detected
5
+ face into a normalized embedding suitable for vector search.
6
+ """
7
+
8
+ from collections.abc import Sequence
9
+
10
+ import torch
11
+ import torch.nn.functional as F
12
+ from facenet_pytorch import MTCNN, InceptionResnetV1
13
+ from PIL import Image, ImageDraw
14
+
15
+ from faceverification.config import settings
16
+
17
+
18
+ class FaceNotDetectedError(ValueError):
19
+ """Raised when no face can be detected in an image."""
20
+
21
+
22
+ class ImageProcessor:
23
+ """Detect faces and generate FaceNet embeddings.
24
+
25
+ The processor owns the model lifecycle for MTCNN and InceptionResnetV1. It
26
+ accepts explicit configuration for tests or experiments, and falls back to
27
+ application settings when arguments are omitted.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ device: str | None = None,
33
+ mtcnn_thresholds: Sequence[float] | None = None,
34
+ facenet_pretrained: str | None = None,
35
+ ):
36
+ """Initialize face detection and embedding models.
37
+
38
+ Args:
39
+ device: Device used for model inference. Use `"auto"` to select
40
+ CUDA when available, otherwise CPU.
41
+ mtcnn_thresholds: Detection thresholds for the three MTCNN stages.
42
+ facenet_pretrained: Pretrained FaceNet weights identifier.
43
+
44
+ Raises:
45
+ ValueError: If `device` is not `"auto"`, `"cpu"`, or `"cuda"`.
46
+ """
47
+ if device is None:
48
+ device = settings.device
49
+ if mtcnn_thresholds is None:
50
+ mtcnn_thresholds = settings.mtcnn_thresholds
51
+ if facenet_pretrained is None:
52
+ facenet_pretrained = settings.facenet_pretrained
53
+
54
+ if device == "auto":
55
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
56
+ else:
57
+ self.device = device.lower()
58
+ if self.device not in ["cpu", "cuda"]:
59
+ raise ValueError("Device must be 'auto', 'cpu' or 'cuda'.")
60
+ self.mtcnn = MTCNN(
61
+ select_largest=False,
62
+ device=self.device,
63
+ thresholds=list(mtcnn_thresholds),
64
+ )
65
+ self.facenet = (
66
+ InceptionResnetV1(pretrained=facenet_pretrained).eval().to(self.device)
67
+ )
68
+
69
+ def get_embedding(self, image: Image.Image) -> torch.Tensor:
70
+ """Return a normalized embedding for the detected face in an image.
71
+
72
+ Args:
73
+ image: PIL image containing a face.
74
+
75
+ Returns:
76
+ A one-dimensional normalized FaceNet embedding tensor.
77
+
78
+ Raises:
79
+ FaceNotDetectedError: If no face can be detected in the image.
80
+ """
81
+ face_tensor = self.mtcnn(image)
82
+ if face_tensor is None:
83
+ raise FaceNotDetectedError("No face detected in the image.")
84
+
85
+ face_tensor = (
86
+ face_tensor.unsqueeze(0) if face_tensor.ndim == 3 else face_tensor
87
+ ).to(self.device)
88
+
89
+ with torch.no_grad():
90
+ features = self.facenet(face_tensor)
91
+ features = F.normalize(features, p=2, dim=1)
92
+
93
+ return features.squeeze(0)
94
+
95
+ def detect_faces(self, image: Image.Image) -> tuple[Image.Image, bool]:
96
+ """Draw detected face bounding boxes on an image.
97
+
98
+ Args:
99
+ image: PIL image to inspect and annotate.
100
+
101
+ Returns:
102
+ A tuple with the annotated image and a boolean indicating whether at
103
+ least one face was detected.
104
+ """
105
+ boxes, probs = self.mtcnn.detect(image)
106
+
107
+ if boxes is None:
108
+ return image, False
109
+
110
+ draw = ImageDraw.Draw(image)
111
+ for box, prob in zip(boxes, probs):
112
+ x1, y1, x2, y2 = [int(v) for v in box]
113
+ draw.rectangle([x1, y1, x2, y2], outline="red", width=2)
114
+ draw.text((x1, max(0, y1 - 12)), f"{prob:.4f}", fill="red")
115
+
116
+ return image, True
src/faceverification/core/vectordb.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vector database adapter for face embedding storage and lookup.
2
+
3
+ This module wraps ChromaDB behind a small project-specific interface. The rest
4
+ of the application only needs to add face embeddings and query the nearest
5
+ stored embedding, while this class owns the Chroma collection setup and result
6
+ filtering.
7
+ """
8
+
9
+ import uuid
10
+ from typing import Any, Mapping
11
+
12
+ import chromadb
13
+ import numpy as np
14
+ from chromadb.config import Settings as ChromaSettings
15
+
16
+ from faceverification.config import settings
17
+
18
+
19
+ class VectorDB:
20
+ """Store and query face embeddings in a ChromaDB collection.
21
+
22
+ The collection is configured with the selected HNSW distance metric and can
23
+ run either in memory or against a persistent directory when one is provided.
24
+ Query results are post-processed with NumPy so the service layer receives a
25
+ simple `(metadata, distance)` pair.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ distance_metric: str | None = None,
31
+ name_collection: str | None = None,
32
+ persist_directory: str | None = None,
33
+ ):
34
+ """Initialize the ChromaDB client and face embeddings collection.
35
+
36
+ Args:
37
+ distance_metric: HNSW distance metric used by ChromaDB. Common
38
+ values are `"l2"`, `"cosine"`, and `"ip"`.
39
+ name_collection: Name of the collection that stores face
40
+ embeddings.
41
+ persist_directory: Optional directory where ChromaDB should persist
42
+ data. When omitted, the database runs in memory.
43
+ """
44
+ if distance_metric is None:
45
+ distance_metric = settings.vector_db_distance_metric
46
+ if name_collection is None:
47
+ name_collection = settings.vector_db_collection
48
+ if persist_directory is None:
49
+ persist_directory = settings.vector_db_persist_directory
50
+
51
+ chroma_settings = ChromaSettings(
52
+ is_persistent=bool(persist_directory),
53
+ persist_directory=persist_directory or "",
54
+ )
55
+ self.client = chromadb.Client(chroma_settings)
56
+
57
+ self.collection = self.client.get_or_create_collection(
58
+ name=name_collection, metadata={"hnsw:space": distance_metric}
59
+ )
60
+
61
+ def add_embedding(self, embedding: np.ndarray, metadata: Mapping[str, Any]) -> None:
62
+ """Add one face embedding and its metadata to the collection.
63
+
64
+ Args:
65
+ embedding: Face embedding vector produced by the face recognition
66
+ model.
67
+ metadata: Metadata associated with the embedding, such as the
68
+ person's name.
69
+ """
70
+ self.collection.add(
71
+ embeddings=[embedding],
72
+ metadatas=[metadata],
73
+ ids=[str(uuid.uuid4())],
74
+ )
75
+
76
+ def query_embedding(
77
+ self,
78
+ embedding: np.ndarray,
79
+ threshold: float | None = None,
80
+ n_results: int | None = None,
81
+ ) -> tuple[Mapping[str, Any] | None, float]:
82
+ """Find the closest stored embedding within the configured threshold.
83
+
84
+ Args:
85
+ embedding: Query embedding vector to compare against stored
86
+ embeddings.
87
+ threshold: Maximum Euclidean distance accepted as a match.
88
+ n_results: Number of nearest ChromaDB candidates to inspect.
89
+
90
+ Returns:
91
+ A tuple containing the matched metadata and its distance. If no
92
+ candidate is within the threshold, metadata is `None` and the best
93
+ distance is still returned.
94
+ """
95
+ if threshold is None:
96
+ threshold = settings.face_match_threshold
97
+ if n_results is None:
98
+ n_results = settings.vector_db_n_results
99
+
100
+ result = self.collection.query(
101
+ query_embeddings=[embedding],
102
+ include=["metadatas", "distances", "embeddings"],
103
+ n_results=n_results,
104
+ )
105
+ embeddings = result.get("embeddings")
106
+ if not embeddings or embeddings[0] is None or len(embeddings[0]) == 0:
107
+ raise ValueError(
108
+ "No record found in the vector database. "
109
+ "Add a person before verifying faces."
110
+ )
111
+
112
+ best_dist = 100000
113
+ best_idx = 0
114
+ for i, emb_res in enumerate(result["embeddings"][0]):
115
+ dist = np.linalg.norm(embedding - emb_res)
116
+ if dist < best_dist:
117
+ best_dist = dist
118
+ best_idx = i
119
+
120
+ if best_dist <= threshold:
121
+ return result["metadatas"][0][best_idx], best_dist
122
+ else:
123
+ return None, best_dist
src/faceverification/services/__init__.py ADDED
File without changes
src/faceverification/services/face_verification.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Application service functions for face enrollment and verification.
2
+
3
+ This module coordinates image preprocessing, embedding extraction, and vector
4
+ database operations for the Gradio interface.
5
+ """
6
+
7
+ from PIL import Image
8
+
9
+ from faceverification.core.image_processor import FaceNotDetectedError, ImageProcessor
10
+ from faceverification.core.vectordb import VectorDB
11
+
12
+ image_processor = ImageProcessor()
13
+
14
+ vector_db = VectorDB()
15
+
16
+
17
+ def add_person(image: Image.Image, name: str) -> Image.Image:
18
+ """Enroll a person by extracting and storing their face embedding.
19
+
20
+ Args:
21
+ image: Input image provided by the UI as a PIL image.
22
+ name: Person name to store as embedding metadata.
23
+
24
+ Returns:
25
+ The input image annotated with detected face bounding boxes.
26
+
27
+ Raises:
28
+ FaceNotDetectedError: If no face is detected in the input image.
29
+ TypeError: If embedding extraction does not return the expected tensor.
30
+ """
31
+
32
+ img, presence = image_processor.detect_faces(image)
33
+
34
+ if not presence:
35
+ raise FaceNotDetectedError("No faces were detected in the image.")
36
+
37
+ faces_pt = image_processor.get_embedding(img)
38
+ if faces_pt is not None:
39
+ vector_db.add_embedding(faces_pt.cpu().numpy(), {"name": name})
40
+ else:
41
+ raise TypeError("The extracted face embedding is not a torch.Tensor.")
42
+
43
+ return img
44
+
45
+
46
+ def verify_person(image: Image.Image) -> tuple[str, Image.Image]:
47
+ """Verify whether the input face matches a stored person.
48
+
49
+ Args:
50
+ image: Input image provided by the UI as a PIL image.
51
+
52
+ Returns:
53
+ A tuple with the matched person name, or `"Unregistered Person"` when no match is
54
+ found, and the image annotated with detected face bounding boxes.
55
+
56
+ Raises:
57
+ FaceNotDetectedError: If no face is detected in the input image.
58
+ """
59
+ detected_faces, presence = image_processor.detect_faces(image.copy())
60
+
61
+ if not presence:
62
+ raise FaceNotDetectedError("No faces were detected in the image.")
63
+
64
+ faces_pt = image_processor.get_embedding(image)
65
+ if faces_pt is not None:
66
+ metadata, _ = vector_db.query_embedding(faces_pt.cpu().numpy())
67
+
68
+ if metadata:
69
+ return metadata["name"], detected_faces
70
+
71
+ return "Unregistered Person", detected_faces
72
+
73
+ raise FaceNotDetectedError("No faces were detected in the image.")
test/images/other_people_negative.jpg ADDED
test/images/person_anchor.jpg ADDED
test/images/person_positive.jpg ADDED
test/test_face_verification.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib
2
+ import sys
3
+ from unittest.mock import Mock
4
+
5
+ import numpy as np
6
+ import pytest
7
+ from PIL import Image
8
+
9
+ from faceverification.core import image_processor as image_processor_module
10
+ from faceverification.core import vectordb as vectordb_module
11
+ from faceverification.core.image_processor import FaceNotDetectedError
12
+
13
+
14
+ class FakeEmbedding:
15
+ def __init__(self, value):
16
+ self.value = value
17
+
18
+ def cpu(self):
19
+ return self
20
+
21
+ def numpy(self):
22
+ return self.value
23
+
24
+
25
+ @pytest.fixture
26
+ def service_module(monkeypatch):
27
+ fake_image_processor = Mock()
28
+ fake_vector_db = Mock()
29
+
30
+ monkeypatch.setattr(
31
+ image_processor_module,
32
+ "ImageProcessor",
33
+ lambda: fake_image_processor,
34
+ )
35
+ monkeypatch.setattr(vectordb_module, "VectorDB", lambda: fake_vector_db)
36
+
37
+ sys.modules.pop("faceverification.services.face_verification", None)
38
+ module = importlib.import_module("faceverification.services.face_verification")
39
+
40
+ return module, fake_image_processor, fake_vector_db
41
+
42
+
43
+ def test_add_person_stores_embedding_and_returns_annotated_image(service_module):
44
+ service, image_processor, vector_db = service_module
45
+ original_image = Image.new("RGB", (10, 10), "white")
46
+ annotated_image = Image.new("RGB", (10, 10), "black")
47
+ embedding = np.array([0.1, 0.2, 0.3])
48
+ image_processor.detect_faces.return_value = (annotated_image, True)
49
+ image_processor.get_embedding.return_value = FakeEmbedding(embedding)
50
+
51
+ result = service.add_person(original_image, "Ada")
52
+
53
+ assert result is annotated_image
54
+ image_processor.detect_faces.assert_called_once_with(original_image)
55
+ image_processor.get_embedding.assert_called_once_with(annotated_image)
56
+ vector_db.add_embedding.assert_called_once()
57
+ args = vector_db.add_embedding.call_args.args
58
+ np.testing.assert_array_equal(args[0], embedding)
59
+ assert args[1] == {"name": "Ada"}
60
+
61
+
62
+ def test_add_person_raises_when_no_face_is_detected(service_module):
63
+ service, image_processor, vector_db = service_module
64
+ image_processor.detect_faces.return_value = (Image.new("RGB", (10, 10)), False)
65
+
66
+ with pytest.raises(FaceNotDetectedError, match="No faces were detected"):
67
+ service.add_person(Image.new("RGB", (10, 10)), "Ada")
68
+
69
+ image_processor.get_embedding.assert_not_called()
70
+ vector_db.add_embedding.assert_not_called()
71
+
72
+
73
+ def test_add_person_raises_type_error_when_embedding_is_none(service_module):
74
+ service, image_processor, vector_db = service_module
75
+ image = Image.new("RGB", (10, 10))
76
+ image_processor.detect_faces.return_value = (image, True)
77
+ image_processor.get_embedding.return_value = None
78
+
79
+ with pytest.raises(TypeError, match="extracted face embedding"):
80
+ service.add_person(image, "Ada")
81
+
82
+ vector_db.add_embedding.assert_not_called()
83
+
84
+
85
+ def test_verify_person_returns_matched_name_and_annotated_image(service_module):
86
+ service, image_processor, vector_db = service_module
87
+ original_image = Image.new("RGB", (10, 10), "white")
88
+ detected_faces = Image.new("RGB", (10, 10), "black")
89
+ embedding = np.array([0.4, 0.5, 0.6])
90
+ image_processor.detect_faces.return_value = (detected_faces, True)
91
+ image_processor.get_embedding.return_value = FakeEmbedding(embedding)
92
+ vector_db.query_embedding.return_value = ({"name": "Grace"}, 0.2)
93
+
94
+ name, image = service.verify_person(original_image)
95
+
96
+ assert name == "Grace"
97
+ assert image is detected_faces
98
+ detected_input = image_processor.detect_faces.call_args.args[0]
99
+ assert detected_input is not original_image
100
+ image_processor.get_embedding.assert_called_once_with(original_image)
101
+ vector_db.query_embedding.assert_called_once()
102
+ np.testing.assert_array_equal(vector_db.query_embedding.call_args.args[0], embedding)
103
+
104
+
105
+ def test_verify_person_returns_unregistered_when_no_metadata_matches(service_module):
106
+ service, image_processor, vector_db = service_module
107
+ detected_faces = Image.new("RGB", (10, 10))
108
+ image_processor.detect_faces.return_value = (detected_faces, True)
109
+ image_processor.get_embedding.return_value = FakeEmbedding(np.array([0.1, 0.2]))
110
+ vector_db.query_embedding.return_value = (None, 1.5)
111
+
112
+ name, image = service.verify_person(Image.new("RGB", (10, 10)))
113
+
114
+ assert name == "Unregistered Person"
115
+ assert image is detected_faces
116
+
117
+
118
+ def test_verify_person_raises_when_vector_db_has_no_records(service_module):
119
+ service, image_processor, vector_db = service_module
120
+ detected_faces = Image.new("RGB", (10, 10))
121
+ image_processor.detect_faces.return_value = (detected_faces, True)
122
+ image_processor.get_embedding.return_value = FakeEmbedding(np.array([0.1, 0.2]))
123
+ vector_db.query_embedding.side_effect = ValueError(
124
+ "No record found in the vector database. Add a person before verifying faces."
125
+ )
126
+
127
+ with pytest.raises(ValueError, match="Add a person before verifying faces"):
128
+ service.verify_person(Image.new("RGB", (10, 10)))
129
+
130
+
131
+ def test_verify_person_raises_when_no_face_is_detected(service_module):
132
+ service, image_processor, vector_db = service_module
133
+ image_processor.detect_faces.return_value = (Image.new("RGB", (10, 10)), False)
134
+
135
+ with pytest.raises(FaceNotDetectedError, match="No faces were detected"):
136
+ service.verify_person(Image.new("RGB", (10, 10)))
137
+
138
+ image_processor.get_embedding.assert_not_called()
139
+ vector_db.query_embedding.assert_not_called()
140
+
141
+
142
+ def test_verify_person_raises_when_embedding_is_none(service_module):
143
+ service, image_processor, vector_db = service_module
144
+ image_processor.detect_faces.return_value = (Image.new("RGB", (10, 10)), True)
145
+ image_processor.get_embedding.return_value = None
146
+
147
+ with pytest.raises(FaceNotDetectedError, match="No faces were detected"):
148
+ service.verify_person(Image.new("RGB", (10, 10)))
149
+
150
+ vector_db.query_embedding.assert_not_called()
test/test_face_verification_e2e.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib
2
+ import sys
3
+ import uuid
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ from PIL import Image
8
+
9
+ from faceverification.core import vectordb as vectordb_module
10
+ from faceverification.core.vectordb import VectorDB
11
+
12
+ pytestmark = [pytest.mark.integration, pytest.mark.e2e]
13
+
14
+ IMAGES_DIR = Path(__file__).parent / "images"
15
+
16
+
17
+ @pytest.fixture
18
+ def service_with_test_vectordb(monkeypatch):
19
+ vector_db = VectorDB(
20
+ distance_metric="l2",
21
+ name_collection=f"test_service_e2e_faces_{uuid.uuid4().hex}",
22
+ )
23
+
24
+ monkeypatch.setattr(vectordb_module, "VectorDB", lambda: vector_db)
25
+ sys.modules.pop("faceverification.services.face_verification", None)
26
+ service = importlib.import_module("faceverification.services.face_verification")
27
+
28
+ yield service
29
+
30
+ sys.modules.pop("faceverification.services.face_verification", None)
31
+
32
+
33
+ def test_service_enrolls_and_verifies_person_with_real_models(service_with_test_vectordb):
34
+ service = service_with_test_vectordb
35
+ anchor_image = Image.open(IMAGES_DIR / "person_anchor.jpg").convert("RGB")
36
+ positive_image = Image.open(IMAGES_DIR / "person_positive.jpg").convert("RGB")
37
+
38
+ service.add_person(anchor_image, "Ada")
39
+ name, annotated_image = service.verify_person(positive_image)
40
+
41
+ assert name == "Ada"
42
+ assert annotated_image is not positive_image
43
+
44
+
45
+ def test_service_raises_before_enrollment(monkeypatch):
46
+ vector_db = VectorDB(
47
+ distance_metric="l2",
48
+ name_collection=f"test_empty_service_e2e_faces_{uuid.uuid4().hex}",
49
+ )
50
+
51
+ monkeypatch.setattr(vectordb_module, "VectorDB", lambda: vector_db)
52
+ sys.modules.pop("faceverification.services.face_verification", None)
53
+ service = importlib.import_module("faceverification.services.face_verification")
54
+ image = Image.open(IMAGES_DIR / "person_anchor.jpg").convert("RGB")
55
+
56
+ with pytest.raises(ValueError, match="Add a person before verifying faces"):
57
+ service.verify_person(image)
58
+
59
+ sys.modules.pop("faceverification.services.face_verification", None)
test/test_face_verification_integration.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib
2
+ import sys
3
+ import uuid
4
+
5
+ import numpy as np
6
+ import pytest
7
+ from PIL import Image
8
+
9
+ from faceverification.core import image_processor as image_processor_module
10
+ from faceverification.core import vectordb as vectordb_module
11
+ from faceverification.core.vectordb import VectorDB
12
+
13
+ pytestmark = pytest.mark.integration
14
+
15
+
16
+ class FakeEmbedding:
17
+ def __init__(self, value):
18
+ self.value = value
19
+
20
+ def cpu(self):
21
+ return self
22
+
23
+ def numpy(self):
24
+ return self.value
25
+
26
+
27
+ class FakeImageProcessor:
28
+ def __init__(self):
29
+ self.embeddings = []
30
+
31
+ def detect_faces(self, image):
32
+ return image, True
33
+
34
+ def get_embedding(self, _image):
35
+ return FakeEmbedding(self.embeddings.pop(0))
36
+
37
+
38
+ @pytest.fixture
39
+ def service_with_real_vectordb(monkeypatch):
40
+ fake_image_processor = FakeImageProcessor()
41
+ vector_db = VectorDB(
42
+ distance_metric="l2",
43
+ name_collection=f"test_service_faces_{uuid.uuid4().hex}",
44
+ )
45
+
46
+ monkeypatch.setattr(
47
+ image_processor_module,
48
+ "ImageProcessor",
49
+ lambda: fake_image_processor,
50
+ )
51
+ monkeypatch.setattr(vectordb_module, "VectorDB", lambda: vector_db)
52
+
53
+ sys.modules.pop("faceverification.services.face_verification", None)
54
+ service = importlib.import_module("faceverification.services.face_verification")
55
+
56
+ return service, fake_image_processor
57
+
58
+
59
+ def test_add_then_verify_person_with_real_vectordb(service_with_real_vectordb):
60
+ service, image_processor = service_with_real_vectordb
61
+ image_processor.embeddings = [
62
+ np.array([0.1, 0.2, 0.3]),
63
+ np.array([0.11, 0.19, 0.31]),
64
+ ]
65
+ image = Image.new("RGB", (10, 10), "white")
66
+
67
+ service.add_person(image, "Ada")
68
+ name, annotated_image = service.verify_person(image)
69
+
70
+ assert name == "Ada"
71
+ assert annotated_image is not None
72
+
73
+
74
+ def test_verify_person_returns_unregistered_when_real_vectordb_match_is_too_far(
75
+ service_with_real_vectordb,
76
+ ):
77
+ service, image_processor = service_with_real_vectordb
78
+ image_processor.embeddings = [
79
+ np.array([0.0, 0.0, 0.0]),
80
+ np.array([10.0, 10.0, 10.0]),
81
+ ]
82
+ image = Image.new("RGB", (10, 10), "white")
83
+
84
+ service.add_person(image, "Ada")
85
+ name, annotated_image = service.verify_person(image)
86
+
87
+ assert name == "Unregistered Person"
88
+ assert annotated_image is not None
test/test_face_workflow_integration.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+ import pytest
6
+ from PIL import Image
7
+
8
+ from faceverification.core.image_processor import ImageProcessor
9
+ from faceverification.core.vectordb import VectorDB
10
+
11
+ pytestmark = [pytest.mark.integration, pytest.mark.e2e]
12
+
13
+ IMAGES_DIR = Path(__file__).parent / "images"
14
+
15
+
16
+ def test_real_image_embedding_can_be_enrolled_and_matched_in_vectordb():
17
+ processor = ImageProcessor(device="cpu")
18
+ vector_db = VectorDB(
19
+ distance_metric="l2",
20
+ name_collection=f"test_real_image_faces_{uuid.uuid4().hex}",
21
+ )
22
+ anchor_image = Image.open(IMAGES_DIR / "person_anchor.jpg").convert("RGB")
23
+ positive_image = Image.open(IMAGES_DIR / "person_positive.jpg").convert("RGB")
24
+
25
+ anchor_embedding = processor.get_embedding(anchor_image).cpu().numpy()
26
+ positive_embedding = processor.get_embedding(positive_image).cpu().numpy()
27
+ vector_db.add_embedding(anchor_embedding, {"name": "Ada"})
28
+
29
+ metadata, distance = vector_db.query_embedding(
30
+ positive_embedding,
31
+ threshold=1.08,
32
+ n_results=1,
33
+ )
34
+
35
+ assert metadata == {"name": "Ada"}
36
+ assert distance == pytest.approx(np.linalg.norm(positive_embedding - anchor_embedding))
test/test_image_processor.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from unittest.mock import Mock
2
+
3
+ import pytest
4
+ import torch
5
+ from PIL import Image
6
+
7
+ from faceverification.core import image_processor
8
+ from faceverification.core.image_processor import FaceNotDetectedError, ImageProcessor
9
+
10
+
11
+ class FakeMTCNN:
12
+ instances = []
13
+
14
+ def __init__(self, **kwargs):
15
+ self.kwargs = kwargs
16
+ FakeMTCNN.instances.append(self)
17
+
18
+
19
+ class FakeFacenet:
20
+ instances = []
21
+
22
+ def __init__(self, pretrained):
23
+ self.pretrained = pretrained
24
+ self.eval_called = False
25
+ self.device = None
26
+ FakeFacenet.instances.append(self)
27
+
28
+ def eval(self):
29
+ self.eval_called = True
30
+ return self
31
+
32
+ def to(self, device):
33
+ self.device = device
34
+ return self
35
+
36
+
37
+ def test_init_uses_cpu_when_auto_and_cuda_is_unavailable(monkeypatch):
38
+ FakeMTCNN.instances = []
39
+ FakeFacenet.instances = []
40
+ monkeypatch.setattr(image_processor.torch.cuda, "is_available", lambda: False)
41
+ monkeypatch.setattr(image_processor, "MTCNN", FakeMTCNN)
42
+ monkeypatch.setattr(image_processor, "InceptionResnetV1", FakeFacenet)
43
+
44
+ processor = ImageProcessor(
45
+ device="auto",
46
+ mtcnn_thresholds=(0.1, 0.2, 0.3),
47
+ facenet_pretrained="test-weights",
48
+ )
49
+
50
+ assert processor.device == "cpu"
51
+ assert FakeMTCNN.instances[0].kwargs == {
52
+ "select_largest": False,
53
+ "device": "cpu",
54
+ "thresholds": [0.1, 0.2, 0.3],
55
+ }
56
+ assert FakeFacenet.instances[0].pretrained == "test-weights"
57
+ assert FakeFacenet.instances[0].eval_called is True
58
+ assert FakeFacenet.instances[0].device == "cpu"
59
+
60
+
61
+ def test_init_rejects_invalid_device():
62
+ with pytest.raises(ValueError, match="Device must be"):
63
+ ImageProcessor(device="gpu")
64
+
65
+
66
+ def test_get_embedding_returns_normalized_embedding():
67
+ processor = ImageProcessor.__new__(ImageProcessor)
68
+ processor.device = "cpu"
69
+ processor.mtcnn = Mock(return_value=torch.ones((3, 2, 2)))
70
+ processor.facenet = Mock(return_value=torch.tensor([[3.0, 4.0]]))
71
+ image = Image.new("RGB", (10, 10))
72
+
73
+ embedding = processor.get_embedding(image)
74
+
75
+ torch.testing.assert_close(embedding, torch.tensor([0.6, 0.8]))
76
+ processor.mtcnn.assert_called_once_with(image)
77
+ assert processor.facenet.call_args.args[0].shape == torch.Size([1, 3, 2, 2])
78
+
79
+
80
+ def test_get_embedding_keeps_batched_face_tensor_shape():
81
+ processor = ImageProcessor.__new__(ImageProcessor)
82
+ processor.device = "cpu"
83
+ processor.mtcnn = Mock(return_value=torch.ones((1, 3, 2, 2)))
84
+ processor.facenet = Mock(return_value=torch.tensor([[3.0, 4.0]]))
85
+ image = Image.new("RGB", (10, 10))
86
+
87
+ embedding = processor.get_embedding(image)
88
+
89
+ torch.testing.assert_close(embedding, torch.tensor([0.6, 0.8]))
90
+ assert processor.facenet.call_args.args[0].shape == torch.Size([1, 3, 2, 2])
91
+
92
+
93
+ def test_get_embedding_raises_when_no_face_is_detected():
94
+ processor = ImageProcessor.__new__(ImageProcessor)
95
+ processor.mtcnn = Mock(return_value=None)
96
+
97
+ with pytest.raises(FaceNotDetectedError, match="No face detected"):
98
+ processor.get_embedding(Image.new("RGB", (10, 10)))
99
+
100
+
101
+ def test_detect_faces_draws_boxes_and_returns_true():
102
+ processor = ImageProcessor.__new__(ImageProcessor)
103
+ processor.mtcnn = Mock()
104
+ processor.mtcnn.detect.return_value = (
105
+ [[1.0, 1.0, 8.0, 8.0]],
106
+ [0.98765],
107
+ )
108
+ image = Image.new("RGB", (10, 10), "white")
109
+
110
+ annotated_image, presence = processor.detect_faces(image)
111
+
112
+ assert annotated_image is image
113
+ assert presence is True
114
+ assert image.getpixel((1, 1)) == (255, 0, 0)
115
+
116
+
117
+ def test_detect_faces_returns_false_when_no_boxes_are_detected():
118
+ processor = ImageProcessor.__new__(ImageProcessor)
119
+ processor.mtcnn = Mock()
120
+ processor.mtcnn.detect.return_value = (None, None)
121
+ image = Image.new("RGB", (10, 10), "white")
122
+
123
+ annotated_image, presence = processor.detect_faces(image)
124
+
125
+ assert annotated_image is image
126
+ assert presence is False
test/test_image_processor_integration.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+ import torch
5
+ from PIL import Image
6
+
7
+ from faceverification.core.image_processor import ImageProcessor
8
+
9
+ pytestmark = pytest.mark.integration
10
+
11
+ IMAGES_DIR = Path(__file__).parent / "images"
12
+
13
+
14
+ @pytest.fixture(scope="module")
15
+ def processor():
16
+ return ImageProcessor(device="cpu")
17
+
18
+
19
+ @pytest.fixture(scope="module")
20
+ def anchor_image():
21
+ return Image.open(IMAGES_DIR / "person_anchor.jpg").convert("RGB")
22
+
23
+
24
+ @pytest.fixture(scope="module")
25
+ def positive_image():
26
+ return Image.open(IMAGES_DIR / "person_positive.jpg").convert("RGB")
27
+
28
+
29
+ @pytest.fixture(scope="module")
30
+ def negative_image():
31
+ return Image.open(IMAGES_DIR / "other_people_negative.jpg").convert("RGB")
32
+
33
+
34
+ @pytest.mark.parametrize(
35
+ "image_name",
36
+ [
37
+ "person_anchor.jpg",
38
+ "person_positive.jpg",
39
+ "other_people_negative.jpg",
40
+ ],
41
+ )
42
+ def test_detect_faces_finds_faces_in_fixture_images(processor, image_name):
43
+ image = Image.open(IMAGES_DIR / image_name).convert("RGB")
44
+
45
+ annotated_image, presence = processor.detect_faces(image)
46
+
47
+ assert annotated_image is image
48
+ assert presence is True
49
+
50
+
51
+ def test_get_embedding_returns_normalized_facenet_vector(processor, anchor_image):
52
+ embedding = processor.get_embedding(anchor_image)
53
+
54
+ assert embedding.ndim == 1
55
+ assert embedding.shape[0] == 512
56
+ torch.testing.assert_close(torch.linalg.norm(embedding), torch.tensor(1.0))
57
+
58
+
59
+ def test_positive_image_embedding_is_closer_than_negative_image(
60
+ processor,
61
+ anchor_image,
62
+ positive_image,
63
+ negative_image,
64
+ ):
65
+ anchor_embedding = processor.get_embedding(anchor_image)
66
+ positive_embedding = processor.get_embedding(positive_image)
67
+ negative_embedding = processor.get_embedding(negative_image)
68
+
69
+ positive_distance = torch.linalg.norm(anchor_embedding - positive_embedding)
70
+ negative_distance = torch.linalg.norm(anchor_embedding - negative_embedding)
71
+
72
+ assert positive_distance < negative_distance
test/test_vectordb.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from unittest.mock import Mock
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from faceverification.core import vectordb
7
+ from faceverification.core.vectordb import VectorDB
8
+
9
+
10
+ class FakeClient:
11
+ def __init__(self, settings):
12
+ self.settings = settings
13
+ self.collection = Mock()
14
+
15
+ def get_or_create_collection(self, **kwargs):
16
+ self.collection_kwargs = kwargs
17
+ return self.collection
18
+
19
+
20
+ def test_init_creates_in_memory_collection_with_configured_metric(monkeypatch):
21
+ clients = []
22
+
23
+ def fake_client(settings):
24
+ client = FakeClient(settings)
25
+ clients.append(client)
26
+ return client
27
+
28
+ monkeypatch.setattr(vectordb.chromadb, "Client", fake_client)
29
+
30
+ db = VectorDB(distance_metric="cosine", name_collection="faces")
31
+
32
+ assert db.client is clients[0]
33
+ assert clients[0].settings.is_persistent is False
34
+ assert clients[0].settings.persist_directory == ""
35
+ assert clients[0].collection_kwargs == {
36
+ "name": "faces",
37
+ "metadata": {"hnsw:space": "cosine"},
38
+ }
39
+ assert db.collection is clients[0].collection
40
+
41
+
42
+ def test_init_passes_persist_directory_when_configured(monkeypatch):
43
+ clients = []
44
+
45
+ def fake_client(settings):
46
+ client = FakeClient(settings)
47
+ clients.append(client)
48
+ return client
49
+
50
+ monkeypatch.setattr(vectordb.chromadb, "Client", fake_client)
51
+
52
+ VectorDB(
53
+ distance_metric="l2",
54
+ name_collection="faces",
55
+ persist_directory="tmp/chroma",
56
+ )
57
+
58
+ assert clients[0].settings.is_persistent is True
59
+ assert clients[0].settings.persist_directory == "tmp/chroma"
60
+
61
+
62
+ def test_add_embedding_stores_embedding_metadata_and_generated_id(monkeypatch):
63
+ collection = Mock()
64
+ db = VectorDB.__new__(VectorDB)
65
+ db.collection = collection
66
+ embedding = np.array([0.1, 0.2, 0.3])
67
+ metadata = {"name": "Ada"}
68
+
69
+ monkeypatch.setattr(vectordb.uuid, "uuid4", lambda: "fixed-id")
70
+
71
+ db.add_embedding(embedding, metadata)
72
+
73
+ collection.add.assert_called_once()
74
+ kwargs = collection.add.call_args.kwargs
75
+ np.testing.assert_array_equal(kwargs["embeddings"][0], embedding)
76
+ assert kwargs["metadatas"] == [metadata]
77
+ assert kwargs["ids"] == ["fixed-id"]
78
+
79
+
80
+ def test_query_embedding_returns_closest_metadata_within_threshold():
81
+ collection = Mock()
82
+ collection.query.return_value = {
83
+ "embeddings": [
84
+ [
85
+ np.array([3.0, 4.0]),
86
+ np.array([0.2, 0.1]),
87
+ np.array([1.0, 1.0]),
88
+ ]
89
+ ],
90
+ "metadatas": [[{"name": "Far"}, {"name": "Near"}, {"name": "Middle"}]],
91
+ "distances": [[5.0, 0.22, 1.41]],
92
+ }
93
+ db = VectorDB.__new__(VectorDB)
94
+ db.collection = collection
95
+ query_embedding = np.array([0.0, 0.0])
96
+
97
+ metadata, distance = db.query_embedding(
98
+ query_embedding,
99
+ threshold=0.5,
100
+ n_results=3,
101
+ )
102
+
103
+ assert metadata == {"name": "Near"}
104
+ assert distance == np.linalg.norm(query_embedding - np.array([0.2, 0.1]))
105
+ collection.query.assert_called_once()
106
+ assert collection.query.call_args.kwargs["include"] == [
107
+ "metadatas",
108
+ "distances",
109
+ "embeddings",
110
+ ]
111
+ assert collection.query.call_args.kwargs["n_results"] == 3
112
+ np.testing.assert_array_equal(
113
+ collection.query.call_args.kwargs["query_embeddings"][0],
114
+ query_embedding,
115
+ )
116
+
117
+
118
+ def test_query_embedding_returns_none_when_closest_distance_exceeds_threshold():
119
+ collection = Mock()
120
+ collection.query.return_value = {
121
+ "embeddings": [[np.array([2.0, 0.0]), np.array([0.0, 3.0])]],
122
+ "metadatas": [[{"name": "Ada"}, {"name": "Grace"}]],
123
+ "distances": [[2.0, 3.0]],
124
+ }
125
+ db = VectorDB.__new__(VectorDB)
126
+ db.collection = collection
127
+
128
+ metadata, distance = db.query_embedding(
129
+ np.array([0.0, 0.0]),
130
+ threshold=1.0,
131
+ n_results=2,
132
+ )
133
+
134
+ assert metadata is None
135
+ assert distance == 2.0
136
+
137
+
138
+ def test_query_embedding_raises_when_database_has_no_embeddings():
139
+ collection = Mock()
140
+ collection.query.return_value = {
141
+ "embeddings": [np.array([], dtype=float)],
142
+ "metadatas": [[]],
143
+ "distances": [[]],
144
+ }
145
+ db = VectorDB.__new__(VectorDB)
146
+ db.collection = collection
147
+
148
+ with pytest.raises(ValueError, match="Add a person before verifying faces"):
149
+ db.query_embedding(np.array([0.0, 0.0]), threshold=1.0, n_results=2)
test/test_vectordb_integration.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from faceverification.core.vectordb import VectorDB
7
+
8
+ pytestmark = pytest.mark.integration
9
+
10
+
11
+ def test_vectordb_adds_and_queries_embedding_with_real_chromadb():
12
+ db = VectorDB(
13
+ distance_metric="l2",
14
+ name_collection=f"test_faces_{uuid.uuid4().hex}",
15
+ )
16
+ stored_embedding = np.array([0.1, 0.2, 0.3])
17
+ query_embedding = np.array([0.11, 0.19, 0.31])
18
+
19
+ db.add_embedding(stored_embedding, {"name": "Ada"})
20
+
21
+ metadata, distance = db.query_embedding(
22
+ query_embedding,
23
+ threshold=0.1,
24
+ n_results=1,
25
+ )
26
+
27
+ assert metadata == {"name": "Ada"}
28
+ assert distance == pytest.approx(np.linalg.norm(query_embedding - stored_embedding))
29
+
30
+
31
+ def test_vectordb_raises_when_real_chromadb_collection_is_empty():
32
+ db = VectorDB(
33
+ distance_metric="l2",
34
+ name_collection=f"test_empty_faces_{uuid.uuid4().hex}",
35
+ )
36
+
37
+ with pytest.raises(ValueError, match="Add a person before verifying faces"):
38
+ db.query_embedding(np.array([0.1, 0.2, 0.3]), threshold=0.1, n_results=1)
39
+
40
+
41
+ def test_vectordb_persists_embeddings_between_instances(tmp_path):
42
+ persist_directory = tmp_path / "chroma"
43
+ collection_name = f"test_persistent_faces_{uuid.uuid4().hex}"
44
+ stored_embedding = np.array([0.1, 0.2, 0.3])
45
+ query_embedding = np.array([0.11, 0.19, 0.31])
46
+
47
+ first_db = VectorDB(
48
+ distance_metric="l2",
49
+ name_collection=collection_name,
50
+ persist_directory=str(persist_directory),
51
+ )
52
+ first_db.add_embedding(stored_embedding, {"name": "Ada"})
53
+
54
+ second_db = VectorDB(
55
+ distance_metric="l2",
56
+ name_collection=collection_name,
57
+ persist_directory=str(persist_directory),
58
+ )
59
+ metadata, distance = second_db.query_embedding(
60
+ query_embedding,
61
+ threshold=0.1,
62
+ n_results=1,
63
+ )
64
+
65
+ assert metadata == {"name": "Ada"}
66
+ assert distance == pytest.approx(np.linalg.norm(query_embedding - stored_embedding))
uv.lock ADDED
The diff for this file is too large to render. See raw diff