Spaces:
Sleeping
Sleeping
Sync from GitHub via hub-sync
Browse files- .python-version +1 -0
- LICENSE +21 -0
- README.md +2 -4
- app.py +4 -0
- pyproject.toml +47 -0
- requirements.txt +450 -0
- src/faceverification/__init__.py +0 -0
- src/faceverification/app.py +65 -0
- src/faceverification/config.py +24 -0
- src/faceverification/core/__init__.py +1 -0
- src/faceverification/core/image_processor.py +116 -0
- src/faceverification/core/vectordb.py +123 -0
- src/faceverification/services/__init__.py +0 -0
- src/faceverification/services/face_verification.py +73 -0
- test/images/other_people_negative.jpg +0 -0
- test/images/person_anchor.jpg +0 -0
- test/images/person_positive.jpg +0 -0
- test/test_face_verification.py +150 -0
- test/test_face_verification_e2e.py +59 -0
- test/test_face_verification_integration.py +88 -0
- test/test_face_workflow_integration.py +36 -0
- test/test_image_processor.py +126 -0
- test/test_image_processor_integration.py +72 -0
- test/test_vectordb.py +149 -0
- test/test_vectordb_integration.py +66 -0
- uv.lock +0 -0
.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.
|
| 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
|
|
|