Spaces:
Sleeping
Sleeping
Sync from GitHub via hub-sync
Browse files- Dockerfile +2 -0
- README.md +16 -4
- docker-compose.yml +4 -2
- infra/bicep/main.bicep +8 -0
- src/faceverification/config.py +3 -0
- src/faceverification/core/image_processor.py +45 -0
- src/faceverification/core/vectordb.py +54 -1
- src/faceverification/interfaces/fastapi_app.py +125 -6
- src/faceverification/interfaces/gradio_app.py +17 -2
- src/faceverification/logging_config.py +66 -0
- src/faceverification/services/face_verification.py +44 -1
Dockerfile
CHANGED
|
@@ -32,4 +32,6 @@ FROM app AS fastapi
|
|
| 32 |
CMD ["uvicorn", "faceverification.interfaces.fastapi_app:app", "--host", "0.0.0.0", "--port", "8000"]
|
| 33 |
|
| 34 |
FROM app AS gradio
|
|
|
|
|
|
|
| 35 |
CMD ["python", "-m", "faceverification.interfaces.gradio_app"]
|
|
|
|
| 32 |
CMD ["uvicorn", "faceverification.interfaces.fastapi_app:app", "--host", "0.0.0.0", "--port", "8000"]
|
| 33 |
|
| 34 |
FROM app AS gradio
|
| 35 |
+
ENV GRADIO_SERVER_NAME=0.0.0.0 \
|
| 36 |
+
GRADIO_SERVER_PORT=7860
|
| 37 |
CMD ["python", "-m", "faceverification.interfaces.gradio_app"]
|
README.md
CHANGED
|
@@ -49,14 +49,14 @@ Using uv:
|
|
| 49 |
|
| 50 |
```bash
|
| 51 |
uv sync
|
| 52 |
-
uv run
|
| 53 |
```
|
| 54 |
|
| 55 |
Using pip:
|
| 56 |
|
| 57 |
```bash
|
| 58 |
-
pip install -r requirements.txt
|
| 59 |
-
python
|
| 60 |
```
|
| 61 |
|
| 62 |
## FastAPI Interface
|
|
@@ -65,7 +65,7 @@ The project includes an HTTP API for the same enroll-and-verify workflow.
|
|
| 65 |
Run it locally with:
|
| 66 |
|
| 67 |
```bash
|
| 68 |
-
uv run uvicorn faceverification.interfaces.fastapi_app:app --
|
| 69 |
```
|
| 70 |
|
| 71 |
Interactive API documentation is available at:
|
|
@@ -86,8 +86,15 @@ FACEVERIFICATION_DEMO_PASSWORD=demo123
|
|
| 86 |
FACEVERIFICATION_JWT_SECRET_KEY=replace-this-with-a-long-random-secret
|
| 87 |
FACEVERIFICATION_JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
|
| 88 |
FACEVERIFICATION_MAX_UPLOAD_BYTES=5242880
|
|
|
|
|
|
|
| 89 |
```
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
```bash
|
| 92 |
curl -X POST http://localhost:8000/auth/login \
|
| 93 |
-F "username=demo" \
|
|
@@ -195,6 +202,11 @@ unless `FACEVERIFICATION_VECTOR_DB_PERSIST_DIRECTORY` is explicitly provided.
|
|
| 195 |
The default container configuration sets `FACEVERIFICATION_DEVICE=cpu` to keep
|
| 196 |
deployment portable.
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
The shared local ChromaDB volume is intended for a small demo deployment when a
|
| 199 |
persist name is enabled. For a multi-container production setup with concurrent
|
| 200 |
writers or multiple replicas, use an external database/vector-store service or
|
|
|
|
| 49 |
|
| 50 |
```bash
|
| 51 |
uv sync
|
| 52 |
+
uv run faceverification
|
| 53 |
```
|
| 54 |
|
| 55 |
Using pip:
|
| 56 |
|
| 57 |
```bash
|
| 58 |
+
pip install -r requirements.txt -e .
|
| 59 |
+
python -m faceverification.interfaces.gradio_app
|
| 60 |
```
|
| 61 |
|
| 62 |
## FastAPI Interface
|
|
|
|
| 65 |
Run it locally with:
|
| 66 |
|
| 67 |
```bash
|
| 68 |
+
uv run uvicorn faceverification.interfaces.fastapi_app:app --port 8000
|
| 69 |
```
|
| 70 |
|
| 71 |
Interactive API documentation is available at:
|
|
|
|
| 86 |
FACEVERIFICATION_JWT_SECRET_KEY=replace-this-with-a-long-random-secret
|
| 87 |
FACEVERIFICATION_JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
|
| 88 |
FACEVERIFICATION_MAX_UPLOAD_BYTES=5242880
|
| 89 |
+
FACEVERIFICATION_DEBUG=false
|
| 90 |
+
FACEVERIFICATION_LOG_FORMAT=json
|
| 91 |
```
|
| 92 |
|
| 93 |
+
`FACEVERIFICATION_DEBUG=true` raises application logging to debug level and uses
|
| 94 |
+
a readable text formatter by default, which is useful for local troubleshooting.
|
| 95 |
+
Keep it `false` in normal deployments; Container Apps sends stdout logs to Log
|
| 96 |
+
Analytics, where the default JSON format is easier to query.
|
| 97 |
+
|
| 98 |
```bash
|
| 99 |
curl -X POST http://localhost:8000/auth/login \
|
| 100 |
-F "username=demo" \
|
|
|
|
| 202 |
The default container configuration sets `FACEVERIFICATION_DEVICE=cpu` to keep
|
| 203 |
deployment portable.
|
| 204 |
|
| 205 |
+
Local Docker Compose defaults `FACEVERIFICATION_DEBUG=true` and
|
| 206 |
+
`FACEVERIFICATION_LOG_FORMAT=text` for developer ergonomics. Azure Container
|
| 207 |
+
Apps sets `FACEVERIFICATION_DEBUG=false` and `FACEVERIFICATION_LOG_FORMAT=json`
|
| 208 |
+
for lower-volume structured logs in Log Analytics.
|
| 209 |
+
|
| 210 |
The shared local ChromaDB volume is intended for a small demo deployment when a
|
| 211 |
persist name is enabled. For a multi-container production setup with concurrent
|
| 212 |
writers or multiple replicas, use an external database/vector-store service or
|
docker-compose.yml
CHANGED
|
@@ -11,6 +11,8 @@ services:
|
|
| 11 |
FACEVERIFICATION_DEMO_USERNAME: ${FACEVERIFICATION_DEMO_USERNAME:-demo}
|
| 12 |
FACEVERIFICATION_DEMO_PASSWORD: ${FACEVERIFICATION_DEMO_PASSWORD:-demo123}
|
| 13 |
FACEVERIFICATION_JWT_SECRET_KEY: ${FACEVERIFICATION_JWT_SECRET_KEY:-change-me-in-production-demo-secret-32-bytes-min}
|
|
|
|
|
|
|
| 14 |
healthcheck:
|
| 15 |
test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5)" ]
|
| 16 |
interval: 30s
|
|
@@ -29,5 +31,5 @@ services:
|
|
| 29 |
- "7860:7860"
|
| 30 |
environment:
|
| 31 |
FACEVERIFICATION_DEVICE: cpu
|
| 32 |
-
|
| 33 |
-
|
|
|
|
| 11 |
FACEVERIFICATION_DEMO_USERNAME: ${FACEVERIFICATION_DEMO_USERNAME:-demo}
|
| 12 |
FACEVERIFICATION_DEMO_PASSWORD: ${FACEVERIFICATION_DEMO_PASSWORD:-demo123}
|
| 13 |
FACEVERIFICATION_JWT_SECRET_KEY: ${FACEVERIFICATION_JWT_SECRET_KEY:-change-me-in-production-demo-secret-32-bytes-min}
|
| 14 |
+
FACEVERIFICATION_DEBUG: ${FACEVERIFICATION_DEBUG:-true}
|
| 15 |
+
FACEVERIFICATION_LOG_FORMAT: ${FACEVERIFICATION_LOG_FORMAT:-text}
|
| 16 |
healthcheck:
|
| 17 |
test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5)" ]
|
| 18 |
interval: 30s
|
|
|
|
| 31 |
- "7860:7860"
|
| 32 |
environment:
|
| 33 |
FACEVERIFICATION_DEVICE: cpu
|
| 34 |
+
FACEVERIFICATION_DEBUG: ${FACEVERIFICATION_DEBUG:-true}
|
| 35 |
+
FACEVERIFICATION_LOG_FORMAT: ${FACEVERIFICATION_LOG_FORMAT:-text}
|
infra/bicep/main.bicep
CHANGED
|
@@ -133,6 +133,14 @@ resource app 'Microsoft.App/containerApps@2024-03-01' = {
|
|
| 133 |
name: 'FACEVERIFICATION_DEVICE'
|
| 134 |
value: 'cpu'
|
| 135 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
{
|
| 137 |
name: 'FACEVERIFICATION_DEMO_USERNAME'
|
| 138 |
secretRef: 'demo-username'
|
|
|
|
| 133 |
name: 'FACEVERIFICATION_DEVICE'
|
| 134 |
value: 'cpu'
|
| 135 |
}
|
| 136 |
+
{
|
| 137 |
+
name: 'FACEVERIFICATION_DEBUG'
|
| 138 |
+
value: 'false'
|
| 139 |
+
}
|
| 140 |
+
{
|
| 141 |
+
name: 'FACEVERIFICATION_LOG_FORMAT'
|
| 142 |
+
value: 'json'
|
| 143 |
+
}
|
| 144 |
{
|
| 145 |
name: 'FACEVERIFICATION_DEMO_USERNAME'
|
| 146 |
secretRef: 'demo-username'
|
src/faceverification/config.py
CHANGED
|
@@ -4,6 +4,9 @@ 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
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
class Settings(BaseSettings):
|
| 7 |
+
debug: bool = False
|
| 8 |
+
log_format: Literal["json", "text"] = "json"
|
| 9 |
+
|
| 10 |
vector_db_distance_metric: str = "l2"
|
| 11 |
vector_db_collection: str = "face_embeddings"
|
| 12 |
vector_db_persist_directory: str | None = None
|
src/faceverification/core/image_processor.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Face detection and FaceNet embedding utilities."""
|
| 2 |
|
|
|
|
| 3 |
from collections.abc import Sequence
|
| 4 |
|
| 5 |
import torch
|
|
@@ -9,6 +10,8 @@ from PIL import Image, ImageDraw
|
|
| 9 |
|
| 10 |
from faceverification.config import settings
|
| 11 |
|
|
|
|
|
|
|
| 12 |
|
| 13 |
class FaceNotDetectedError(ValueError):
|
| 14 |
"""Raised when no face can be detected in an image."""
|
|
@@ -49,6 +52,17 @@ class ImageProcessor:
|
|
| 49 |
thresholds=list(mtcnn_thresholds),
|
| 50 |
)
|
| 51 |
self.facenet = InceptionResnetV1(pretrained=facenet_pretrained).eval().to(self.device)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
def get_embedding(self, image: Image.Image) -> torch.Tensor:
|
| 54 |
"""Return a normalized FaceNet embedding for the detected face.
|
|
@@ -64,6 +78,10 @@ class ImageProcessor:
|
|
| 64 |
"""
|
| 65 |
face_tensor = self.mtcnn(image)
|
| 66 |
if face_tensor is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
raise FaceNotDetectedError("No face detected in the image.")
|
| 68 |
|
| 69 |
face_tensor = (face_tensor.unsqueeze(0) if face_tensor.ndim == 3 else face_tensor).to(
|
|
@@ -74,6 +92,16 @@ class ImageProcessor:
|
|
| 74 |
features = self.facenet(face_tensor)
|
| 75 |
features = F.normalize(features, p=2, dim=1)
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
return features.squeeze(0)
|
| 78 |
|
| 79 |
def detect_faces(self, image: Image.Image) -> tuple[Image.Image, bool]:
|
|
@@ -88,8 +116,25 @@ class ImageProcessor:
|
|
| 88 |
boxes, probs = self.mtcnn.detect(image)
|
| 89 |
|
| 90 |
if boxes is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
return image, False
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
draw = ImageDraw.Draw(image)
|
| 94 |
for box, prob in zip(boxes, probs, strict=True):
|
| 95 |
x1, y1, x2, y2 = [int(v) for v in box]
|
|
|
|
| 1 |
"""Face detection and FaceNet embedding utilities."""
|
| 2 |
|
| 3 |
+
import logging
|
| 4 |
from collections.abc import Sequence
|
| 5 |
|
| 6 |
import torch
|
|
|
|
| 10 |
|
| 11 |
from faceverification.config import settings
|
| 12 |
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
|
| 16 |
class FaceNotDetectedError(ValueError):
|
| 17 |
"""Raised when no face can be detected in an image."""
|
|
|
|
| 52 |
thresholds=list(mtcnn_thresholds),
|
| 53 |
)
|
| 54 |
self.facenet = InceptionResnetV1(pretrained=facenet_pretrained).eval().to(self.device)
|
| 55 |
+
logger.info(
|
| 56 |
+
"image_processor_initialized",
|
| 57 |
+
extra={
|
| 58 |
+
"extra_fields": {
|
| 59 |
+
"event": "image_processor_initialized",
|
| 60 |
+
"device": self.device,
|
| 61 |
+
"mtcnn_thresholds": list(mtcnn_thresholds),
|
| 62 |
+
"facenet_pretrained": facenet_pretrained,
|
| 63 |
+
}
|
| 64 |
+
},
|
| 65 |
+
)
|
| 66 |
|
| 67 |
def get_embedding(self, image: Image.Image) -> torch.Tensor:
|
| 68 |
"""Return a normalized FaceNet embedding for the detected face.
|
|
|
|
| 78 |
"""
|
| 79 |
face_tensor = self.mtcnn(image)
|
| 80 |
if face_tensor is None:
|
| 81 |
+
logger.debug(
|
| 82 |
+
"face_embedding_no_face",
|
| 83 |
+
extra={"extra_fields": {"event": "face_embedding_no_face"}},
|
| 84 |
+
)
|
| 85 |
raise FaceNotDetectedError("No face detected in the image.")
|
| 86 |
|
| 87 |
face_tensor = (face_tensor.unsqueeze(0) if face_tensor.ndim == 3 else face_tensor).to(
|
|
|
|
| 92 |
features = self.facenet(face_tensor)
|
| 93 |
features = F.normalize(features, p=2, dim=1)
|
| 94 |
|
| 95 |
+
logger.debug(
|
| 96 |
+
"face_embedding_created",
|
| 97 |
+
extra={
|
| 98 |
+
"extra_fields": {
|
| 99 |
+
"event": "face_embedding_created",
|
| 100 |
+
"shape": list(features.shape),
|
| 101 |
+
"device": self.device,
|
| 102 |
+
}
|
| 103 |
+
},
|
| 104 |
+
)
|
| 105 |
return features.squeeze(0)
|
| 106 |
|
| 107 |
def detect_faces(self, image: Image.Image) -> tuple[Image.Image, bool]:
|
|
|
|
| 116 |
boxes, probs = self.mtcnn.detect(image)
|
| 117 |
|
| 118 |
if boxes is None:
|
| 119 |
+
logger.debug(
|
| 120 |
+
"face_detection_completed",
|
| 121 |
+
extra={"extra_fields": {"event": "face_detection_completed", "face_count": 0}},
|
| 122 |
+
)
|
| 123 |
return image, False
|
| 124 |
|
| 125 |
+
probabilities = [float(prob) for prob in probs]
|
| 126 |
+
logger.debug(
|
| 127 |
+
"face_detection_completed",
|
| 128 |
+
extra={
|
| 129 |
+
"extra_fields": {
|
| 130 |
+
"event": "face_detection_completed",
|
| 131 |
+
"face_count": len(boxes),
|
| 132 |
+
"min_probability": min(probabilities),
|
| 133 |
+
"max_probability": max(probabilities),
|
| 134 |
+
}
|
| 135 |
+
},
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
draw = ImageDraw.Draw(image)
|
| 139 |
for box, prob in zip(boxes, probs, strict=True):
|
| 140 |
x1, y1, x2, y2 = [int(v) for v in box]
|
src/faceverification/core/vectordb.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Small ChromaDB adapter for face embedding storage and lookup."""
|
| 2 |
|
|
|
|
| 3 |
import uuid
|
| 4 |
from collections.abc import Mapping
|
| 5 |
from typing import Any
|
|
@@ -10,6 +11,8 @@ from chromadb.config import Settings as ChromaSettings
|
|
| 10 |
|
| 11 |
from faceverification.config import settings
|
| 12 |
|
|
|
|
|
|
|
| 13 |
|
| 14 |
class VectorDB:
|
| 15 |
"""Store face embeddings and query the nearest known identity."""
|
|
@@ -43,6 +46,17 @@ class VectorDB:
|
|
| 43 |
self.collection = self.client.get_or_create_collection(
|
| 44 |
name=name_collection, metadata={"hnsw:space": distance_metric}
|
| 45 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
def add_embedding(self, embedding: np.ndarray, metadata: Mapping[str, Any]) -> None:
|
| 48 |
"""Store one embedding with its metadata.
|
|
@@ -56,6 +70,16 @@ class VectorDB:
|
|
| 56 |
metadatas=[metadata],
|
| 57 |
ids=[str(uuid.uuid4())],
|
| 58 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
def query_embedding(
|
| 61 |
self,
|
|
@@ -79,6 +103,17 @@ class VectorDB:
|
|
| 79 |
if n_results is None:
|
| 80 |
n_results = settings.vector_db_n_results
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
result = self.collection.query(
|
| 83 |
query_embeddings=[embedding],
|
| 84 |
include=["metadatas", "distances", "embeddings"],
|
|
@@ -86,6 +121,10 @@ class VectorDB:
|
|
| 86 |
)
|
| 87 |
embeddings = result.get("embeddings")
|
| 88 |
if not embeddings or embeddings[0] is None or len(embeddings[0]) == 0:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
raise ValueError(
|
| 90 |
"No record found in the vector database. Add a person before verifying faces."
|
| 91 |
)
|
|
@@ -98,7 +137,21 @@ class VectorDB:
|
|
| 98 |
best_dist = dist
|
| 99 |
best_idx = i
|
| 100 |
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
return result["metadatas"][0][best_idx], best_dist
|
| 103 |
else:
|
| 104 |
return None, best_dist
|
|
|
|
| 1 |
"""Small ChromaDB adapter for face embedding storage and lookup."""
|
| 2 |
|
| 3 |
+
import logging
|
| 4 |
import uuid
|
| 5 |
from collections.abc import Mapping
|
| 6 |
from typing import Any
|
|
|
|
| 11 |
|
| 12 |
from faceverification.config import settings
|
| 13 |
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
|
| 17 |
class VectorDB:
|
| 18 |
"""Store face embeddings and query the nearest known identity."""
|
|
|
|
| 46 |
self.collection = self.client.get_or_create_collection(
|
| 47 |
name=name_collection, metadata={"hnsw:space": distance_metric}
|
| 48 |
)
|
| 49 |
+
logger.info(
|
| 50 |
+
"vector_db_initialized",
|
| 51 |
+
extra={
|
| 52 |
+
"extra_fields": {
|
| 53 |
+
"event": "vector_db_initialized",
|
| 54 |
+
"collection": name_collection,
|
| 55 |
+
"distance_metric": distance_metric,
|
| 56 |
+
"persistent": bool(persist_directory),
|
| 57 |
+
}
|
| 58 |
+
},
|
| 59 |
+
)
|
| 60 |
|
| 61 |
def add_embedding(self, embedding: np.ndarray, metadata: Mapping[str, Any]) -> None:
|
| 62 |
"""Store one embedding with its metadata.
|
|
|
|
| 70 |
metadatas=[metadata],
|
| 71 |
ids=[str(uuid.uuid4())],
|
| 72 |
)
|
| 73 |
+
logger.debug(
|
| 74 |
+
"vector_db_embedding_added",
|
| 75 |
+
extra={
|
| 76 |
+
"extra_fields": {
|
| 77 |
+
"event": "vector_db_embedding_added",
|
| 78 |
+
"embedding_shape": list(embedding.shape),
|
| 79 |
+
"metadata_keys": sorted(metadata.keys()),
|
| 80 |
+
}
|
| 81 |
+
},
|
| 82 |
+
)
|
| 83 |
|
| 84 |
def query_embedding(
|
| 85 |
self,
|
|
|
|
| 103 |
if n_results is None:
|
| 104 |
n_results = settings.vector_db_n_results
|
| 105 |
|
| 106 |
+
logger.debug(
|
| 107 |
+
"vector_db_query_started",
|
| 108 |
+
extra={
|
| 109 |
+
"extra_fields": {
|
| 110 |
+
"event": "vector_db_query_started",
|
| 111 |
+
"threshold": threshold,
|
| 112 |
+
"n_results": n_results,
|
| 113 |
+
"embedding_shape": list(embedding.shape),
|
| 114 |
+
}
|
| 115 |
+
},
|
| 116 |
+
)
|
| 117 |
result = self.collection.query(
|
| 118 |
query_embeddings=[embedding],
|
| 119 |
include=["metadatas", "distances", "embeddings"],
|
|
|
|
| 121 |
)
|
| 122 |
embeddings = result.get("embeddings")
|
| 123 |
if not embeddings or embeddings[0] is None or len(embeddings[0]) == 0:
|
| 124 |
+
logger.warning(
|
| 125 |
+
"vector_db_query_empty",
|
| 126 |
+
extra={"extra_fields": {"event": "vector_db_query_empty"}},
|
| 127 |
+
)
|
| 128 |
raise ValueError(
|
| 129 |
"No record found in the vector database. Add a person before verifying faces."
|
| 130 |
)
|
|
|
|
| 137 |
best_dist = dist
|
| 138 |
best_idx = i
|
| 139 |
|
| 140 |
+
matched = best_dist <= threshold
|
| 141 |
+
logger.debug(
|
| 142 |
+
"vector_db_query_completed",
|
| 143 |
+
extra={
|
| 144 |
+
"extra_fields": {
|
| 145 |
+
"event": "vector_db_query_completed",
|
| 146 |
+
"matched": matched,
|
| 147 |
+
"best_distance": float(best_dist),
|
| 148 |
+
"threshold": threshold,
|
| 149 |
+
"candidate_count": len(result["embeddings"][0]),
|
| 150 |
+
}
|
| 151 |
+
},
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
if matched:
|
| 155 |
return result["metadatas"][0][best_idx], best_dist
|
| 156 |
else:
|
| 157 |
return None, best_dist
|
src/faceverification/interfaces/fastapi_app.py
CHANGED
|
@@ -4,7 +4,9 @@ from contextlib import asynccontextmanager
|
|
| 4 |
from datetime import UTC, datetime, timedelta
|
| 5 |
from io import BytesIO
|
| 6 |
from secrets import compare_digest
|
|
|
|
| 7 |
from typing import Annotated, Protocol
|
|
|
|
| 8 |
|
| 9 |
import jwt
|
| 10 |
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile, status
|
|
@@ -15,8 +17,11 @@ from pydantic import BaseModel
|
|
| 15 |
|
| 16 |
from faceverification.config import settings
|
| 17 |
from faceverification.core.image_processor import FaceNotDetectedError
|
|
|
|
| 18 |
from faceverification.services.face_verification import UNREGISTERED_PERSON
|
| 19 |
|
|
|
|
|
|
|
| 20 |
bearer_scheme = HTTPBearer(auto_error=False)
|
| 21 |
logger = logging.getLogger(__name__)
|
| 22 |
|
|
@@ -107,6 +112,48 @@ app = FastAPI(
|
|
| 107 |
)
|
| 108 |
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
class HealthResponse(BaseModel):
|
| 111 |
status: str = "ok"
|
| 112 |
|
|
@@ -226,6 +273,16 @@ async def _read_image(upload: UploadFile) -> Image.Image:
|
|
| 226 |
)
|
| 227 |
|
| 228 |
contents = await upload.read()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
if not contents:
|
| 230 |
raise HTTPException(
|
| 231 |
status_code=status.HTTP_400_BAD_REQUEST,
|
|
@@ -240,7 +297,19 @@ async def _read_image(upload: UploadFile) -> Image.Image:
|
|
| 240 |
try:
|
| 241 |
image = Image.open(BytesIO(contents))
|
| 242 |
image = ImageOps.exif_transpose(image)
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
except (UnidentifiedImageError, OSError) as exc:
|
| 245 |
raise HTTPException(
|
| 246 |
status_code=status.HTTP_400_BAD_REQUEST,
|
|
@@ -269,8 +338,17 @@ def _bad_service_request(exc: ValueError) -> HTTPException:
|
|
| 269 |
)
|
| 270 |
|
| 271 |
|
| 272 |
-
def _unexpected_service_error(exc: Exception) -> HTTPException:
|
| 273 |
-
logger.exception(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
return HTTPException(
|
| 275 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 276 |
detail="Face verification failed.",
|
|
@@ -314,8 +392,13 @@ def login(
|
|
| 314 |
],
|
| 315 |
) -> TokenResponse:
|
| 316 |
if not _authenticate_demo_user(username, password):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
raise _unauthorized_error("Incorrect username or password.")
|
| 318 |
|
|
|
|
| 319 |
return TokenResponse(access_token=_create_access_token(username))
|
| 320 |
|
| 321 |
|
|
@@ -353,6 +436,16 @@ async def enroll_person(
|
|
| 353 |
)
|
| 354 |
|
| 355 |
pil_image = await _read_image(image)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
try:
|
| 357 |
annotated_image = service.add_person(pil_image, cleaned_name)
|
| 358 |
except FaceNotDetectedError as exc:
|
|
@@ -360,7 +453,17 @@ async def enroll_person(
|
|
| 360 |
except ValueError as exc:
|
| 361 |
raise _bad_service_request(exc) from exc
|
| 362 |
except Exception as exc:
|
| 363 |
-
raise _unexpected_service_error(exc) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
|
| 365 |
return EnrollResponse(
|
| 366 |
message="Person added to the embeddings database.",
|
|
@@ -391,6 +494,10 @@ async def verify_identity(
|
|
| 391 |
) -> VerifyResponse:
|
| 392 |
_ = current_username
|
| 393 |
pil_image = await _read_image(image)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
try:
|
| 395 |
name, annotated_image = service.verify_person(pil_image)
|
| 396 |
except FaceNotDetectedError as exc:
|
|
@@ -398,11 +505,23 @@ async def verify_identity(
|
|
| 398 |
except ValueError as exc:
|
| 399 |
raise _bad_service_request(exc) from exc
|
| 400 |
except Exception as exc:
|
| 401 |
-
raise _unexpected_service_error(exc) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
|
| 403 |
return VerifyResponse(
|
| 404 |
name=name,
|
| 405 |
-
matched=
|
| 406 |
annotated_image=_image_to_data_url(annotated_image) if include_image else None,
|
| 407 |
)
|
| 408 |
|
|
|
|
| 4 |
from datetime import UTC, datetime, timedelta
|
| 5 |
from io import BytesIO
|
| 6 |
from secrets import compare_digest
|
| 7 |
+
from time import perf_counter
|
| 8 |
from typing import Annotated, Protocol
|
| 9 |
+
from uuid import uuid4
|
| 10 |
|
| 11 |
import jwt
|
| 12 |
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile, status
|
|
|
|
| 17 |
|
| 18 |
from faceverification.config import settings
|
| 19 |
from faceverification.core.image_processor import FaceNotDetectedError
|
| 20 |
+
from faceverification.logging_config import configure_logging, request_id_context
|
| 21 |
from faceverification.services.face_verification import UNREGISTERED_PERSON
|
| 22 |
|
| 23 |
+
configure_logging()
|
| 24 |
+
|
| 25 |
bearer_scheme = HTTPBearer(auto_error=False)
|
| 26 |
logger = logging.getLogger(__name__)
|
| 27 |
|
|
|
|
| 112 |
)
|
| 113 |
|
| 114 |
|
| 115 |
+
@app.middleware("http")
|
| 116 |
+
async def log_requests(request: Request, call_next):
|
| 117 |
+
request_id = request.headers.get("x-request-id") or str(uuid4())
|
| 118 |
+
context_token = request_id_context.set(request_id)
|
| 119 |
+
started_at = perf_counter()
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
response = await call_next(request)
|
| 123 |
+
except Exception:
|
| 124 |
+
elapsed_ms = round((perf_counter() - started_at) * 1000, 2)
|
| 125 |
+
logger.exception(
|
| 126 |
+
"request_failed",
|
| 127 |
+
extra={
|
| 128 |
+
"extra_fields": {
|
| 129 |
+
"event": "request_failed",
|
| 130 |
+
"method": request.method,
|
| 131 |
+
"path": request.url.path,
|
| 132 |
+
"duration_ms": elapsed_ms,
|
| 133 |
+
}
|
| 134 |
+
},
|
| 135 |
+
)
|
| 136 |
+
raise
|
| 137 |
+
else:
|
| 138 |
+
elapsed_ms = round((perf_counter() - started_at) * 1000, 2)
|
| 139 |
+
response.headers["x-request-id"] = request_id
|
| 140 |
+
logger.info(
|
| 141 |
+
"request_completed",
|
| 142 |
+
extra={
|
| 143 |
+
"extra_fields": {
|
| 144 |
+
"event": "request_completed",
|
| 145 |
+
"method": request.method,
|
| 146 |
+
"path": request.url.path,
|
| 147 |
+
"status_code": response.status_code,
|
| 148 |
+
"duration_ms": elapsed_ms,
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
)
|
| 152 |
+
return response
|
| 153 |
+
finally:
|
| 154 |
+
request_id_context.reset(context_token)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
class HealthResponse(BaseModel):
|
| 158 |
status: str = "ok"
|
| 159 |
|
|
|
|
| 273 |
)
|
| 274 |
|
| 275 |
contents = await upload.read()
|
| 276 |
+
logger.debug(
|
| 277 |
+
"upload_received",
|
| 278 |
+
extra={
|
| 279 |
+
"extra_fields": {
|
| 280 |
+
"event": "upload_received",
|
| 281 |
+
"content_type": upload.content_type,
|
| 282 |
+
"size_bytes": len(contents),
|
| 283 |
+
}
|
| 284 |
+
},
|
| 285 |
+
)
|
| 286 |
if not contents:
|
| 287 |
raise HTTPException(
|
| 288 |
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
| 297 |
try:
|
| 298 |
image = Image.open(BytesIO(contents))
|
| 299 |
image = ImageOps.exif_transpose(image)
|
| 300 |
+
rgb_image = image.convert("RGB")
|
| 301 |
+
logger.debug(
|
| 302 |
+
"upload_image_decoded",
|
| 303 |
+
extra={
|
| 304 |
+
"extra_fields": {
|
| 305 |
+
"event": "upload_image_decoded",
|
| 306 |
+
"width": rgb_image.width,
|
| 307 |
+
"height": rgb_image.height,
|
| 308 |
+
"mode": rgb_image.mode,
|
| 309 |
+
}
|
| 310 |
+
},
|
| 311 |
+
)
|
| 312 |
+
return rgb_image
|
| 313 |
except (UnidentifiedImageError, OSError) as exc:
|
| 314 |
raise HTTPException(
|
| 315 |
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
| 338 |
)
|
| 339 |
|
| 340 |
|
| 341 |
+
def _unexpected_service_error(exc: Exception, operation: str) -> HTTPException:
|
| 342 |
+
logger.exception(
|
| 343 |
+
"face_service_failed",
|
| 344 |
+
extra={
|
| 345 |
+
"extra_fields": {
|
| 346 |
+
"event": "face_service_failed",
|
| 347 |
+
"operation": operation,
|
| 348 |
+
"exception_type": type(exc).__name__,
|
| 349 |
+
}
|
| 350 |
+
},
|
| 351 |
+
)
|
| 352 |
return HTTPException(
|
| 353 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 354 |
detail="Face verification failed.",
|
|
|
|
| 392 |
],
|
| 393 |
) -> TokenResponse:
|
| 394 |
if not _authenticate_demo_user(username, password):
|
| 395 |
+
logger.warning(
|
| 396 |
+
"auth_login_failed",
|
| 397 |
+
extra={"extra_fields": {"event": "auth_login_failed"}},
|
| 398 |
+
)
|
| 399 |
raise _unauthorized_error("Incorrect username or password.")
|
| 400 |
|
| 401 |
+
logger.debug("auth_login_succeeded", extra={"extra_fields": {"event": "auth_login_succeeded"}})
|
| 402 |
return TokenResponse(access_token=_create_access_token(username))
|
| 403 |
|
| 404 |
|
|
|
|
| 436 |
)
|
| 437 |
|
| 438 |
pil_image = await _read_image(image)
|
| 439 |
+
logger.debug(
|
| 440 |
+
"face_enroll_started",
|
| 441 |
+
extra={
|
| 442 |
+
"extra_fields": {
|
| 443 |
+
"event": "face_enroll_started",
|
| 444 |
+
"name_length": len(cleaned_name),
|
| 445 |
+
"include_image": include_image,
|
| 446 |
+
}
|
| 447 |
+
},
|
| 448 |
+
)
|
| 449 |
try:
|
| 450 |
annotated_image = service.add_person(pil_image, cleaned_name)
|
| 451 |
except FaceNotDetectedError as exc:
|
|
|
|
| 453 |
except ValueError as exc:
|
| 454 |
raise _bad_service_request(exc) from exc
|
| 455 |
except Exception as exc:
|
| 456 |
+
raise _unexpected_service_error(exc, "enroll") from exc
|
| 457 |
+
|
| 458 |
+
logger.debug(
|
| 459 |
+
"face_enroll_completed",
|
| 460 |
+
extra={
|
| 461 |
+
"extra_fields": {
|
| 462 |
+
"event": "face_enroll_completed",
|
| 463 |
+
"include_image": include_image,
|
| 464 |
+
}
|
| 465 |
+
},
|
| 466 |
+
)
|
| 467 |
|
| 468 |
return EnrollResponse(
|
| 469 |
message="Person added to the embeddings database.",
|
|
|
|
| 494 |
) -> VerifyResponse:
|
| 495 |
_ = current_username
|
| 496 |
pil_image = await _read_image(image)
|
| 497 |
+
logger.debug(
|
| 498 |
+
"face_verify_started",
|
| 499 |
+
extra={"extra_fields": {"event": "face_verify_started", "include_image": include_image}},
|
| 500 |
+
)
|
| 501 |
try:
|
| 502 |
name, annotated_image = service.verify_person(pil_image)
|
| 503 |
except FaceNotDetectedError as exc:
|
|
|
|
| 505 |
except ValueError as exc:
|
| 506 |
raise _bad_service_request(exc) from exc
|
| 507 |
except Exception as exc:
|
| 508 |
+
raise _unexpected_service_error(exc, "verify") from exc
|
| 509 |
+
|
| 510 |
+
matched = name != UNREGISTERED_PERSON
|
| 511 |
+
logger.debug(
|
| 512 |
+
"face_verify_completed",
|
| 513 |
+
extra={
|
| 514 |
+
"extra_fields": {
|
| 515 |
+
"event": "face_verify_completed",
|
| 516 |
+
"matched": matched,
|
| 517 |
+
"include_image": include_image,
|
| 518 |
+
}
|
| 519 |
+
},
|
| 520 |
+
)
|
| 521 |
|
| 522 |
return VerifyResponse(
|
| 523 |
name=name,
|
| 524 |
+
matched=matched,
|
| 525 |
annotated_image=_image_to_data_url(annotated_image) if include_image else None,
|
| 526 |
)
|
| 527 |
|
src/faceverification/interfaces/gradio_app.py
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import gradio as gr
|
| 2 |
from PIL import Image
|
| 3 |
|
| 4 |
from faceverification.core.image_processor import FaceNotDetectedError
|
| 5 |
-
from faceverification.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
def add_person_ui(image: Image.Image | None, name: str) -> Image.Image:
|
|
@@ -12,6 +24,7 @@ def add_person_ui(image: Image.Image | None, name: str) -> Image.Image:
|
|
| 12 |
if not name or not name.strip():
|
| 13 |
raise gr.Error("Enter a name before adding the person.")
|
| 14 |
|
|
|
|
| 15 |
return add_person(image, name.strip())
|
| 16 |
except FaceNotDetectedError as exc:
|
| 17 |
raise gr.Error(str(exc)) from exc
|
|
@@ -24,6 +37,7 @@ def verify_person_ui(image: Image.Image | None) -> tuple[str, Image.Image]:
|
|
| 24 |
if image is None:
|
| 25 |
raise gr.Error("Upload an image before verifying an identity.")
|
| 26 |
|
|
|
|
| 27 |
name, annotated_image = verify_person(image)
|
| 28 |
return name, annotated_image
|
| 29 |
except FaceNotDetectedError as exc:
|
|
@@ -168,7 +182,8 @@ If a face is detected, the annotated image confirms what face was stored.
|
|
| 168 |
|
| 169 |
def main():
|
| 170 |
FV_gr.launch(
|
| 171 |
-
|
|
|
|
| 172 |
theme=APP_THEME,
|
| 173 |
css=APP_CSS,
|
| 174 |
)
|
|
|
|
| 1 |
+
from functools import cache
|
| 2 |
+
from os import getenv
|
| 3 |
+
|
| 4 |
import gradio as gr
|
| 5 |
from PIL import Image
|
| 6 |
|
| 7 |
from faceverification.core.image_processor import FaceNotDetectedError
|
| 8 |
+
from faceverification.logging_config import configure_logging
|
| 9 |
+
|
| 10 |
+
configure_logging()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@cache
|
| 14 |
+
def _face_service():
|
| 15 |
+
from faceverification.services.face_verification import add_person, verify_person
|
| 16 |
+
|
| 17 |
+
return add_person, verify_person
|
| 18 |
|
| 19 |
|
| 20 |
def add_person_ui(image: Image.Image | None, name: str) -> Image.Image:
|
|
|
|
| 24 |
if not name or not name.strip():
|
| 25 |
raise gr.Error("Enter a name before adding the person.")
|
| 26 |
|
| 27 |
+
add_person, _ = _face_service()
|
| 28 |
return add_person(image, name.strip())
|
| 29 |
except FaceNotDetectedError as exc:
|
| 30 |
raise gr.Error(str(exc)) from exc
|
|
|
|
| 37 |
if image is None:
|
| 38 |
raise gr.Error("Upload an image before verifying an identity.")
|
| 39 |
|
| 40 |
+
_, verify_person = _face_service()
|
| 41 |
name, annotated_image = verify_person(image)
|
| 42 |
return name, annotated_image
|
| 43 |
except FaceNotDetectedError as exc:
|
|
|
|
| 182 |
|
| 183 |
def main():
|
| 184 |
FV_gr.launch(
|
| 185 |
+
server_name=getenv("GRADIO_SERVER_NAME") or None,
|
| 186 |
+
server_port=int(getenv("GRADIO_SERVER_PORT", "7860")),
|
| 187 |
theme=APP_THEME,
|
| 188 |
css=APP_CSS,
|
| 189 |
)
|
src/faceverification/logging_config.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import sys
|
| 4 |
+
from contextvars import ContextVar
|
| 5 |
+
from datetime import UTC, datetime
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from faceverification.config import settings
|
| 9 |
+
|
| 10 |
+
request_id_context: ContextVar[str | None] = ContextVar("request_id", default=None)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class RequestContextFilter(logging.Filter):
|
| 14 |
+
def filter(self, record: logging.LogRecord) -> bool:
|
| 15 |
+
record.request_id = request_id_context.get()
|
| 16 |
+
return True
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class JsonFormatter(logging.Formatter):
|
| 20 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 21 |
+
payload: dict[str, Any] = {
|
| 22 |
+
"timestamp": datetime.fromtimestamp(record.created, UTC).isoformat(),
|
| 23 |
+
"level": record.levelname,
|
| 24 |
+
"logger": record.name,
|
| 25 |
+
"message": record.getMessage(),
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
request_id = getattr(record, "request_id", None)
|
| 29 |
+
if request_id:
|
| 30 |
+
payload["request_id"] = request_id
|
| 31 |
+
|
| 32 |
+
for key, value in getattr(record, "extra_fields", {}).items():
|
| 33 |
+
if value is not None:
|
| 34 |
+
payload[key] = value
|
| 35 |
+
|
| 36 |
+
if record.exc_info:
|
| 37 |
+
payload["exception"] = self.formatException(record.exc_info)
|
| 38 |
+
|
| 39 |
+
return json.dumps(payload, ensure_ascii=False, default=str)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _build_handler() -> logging.Handler:
|
| 43 |
+
handler = logging.StreamHandler(sys.stdout)
|
| 44 |
+
handler.addFilter(RequestContextFilter())
|
| 45 |
+
|
| 46 |
+
if settings.log_format == "text":
|
| 47 |
+
formatter = logging.Formatter(
|
| 48 |
+
"%(asctime)s %(levelname)s [%(name)s] request_id=%(request_id)s %(message)s"
|
| 49 |
+
)
|
| 50 |
+
else:
|
| 51 |
+
formatter = JsonFormatter()
|
| 52 |
+
|
| 53 |
+
handler.setFormatter(formatter)
|
| 54 |
+
return handler
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def configure_logging() -> None:
|
| 58 |
+
level = logging.DEBUG if settings.debug else logging.INFO
|
| 59 |
+
handler = _build_handler()
|
| 60 |
+
|
| 61 |
+
logging.basicConfig(level=level, handlers=[handler], force=True)
|
| 62 |
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
| 63 |
+
logging.getLogger("multipart").setLevel(logging.WARNING)
|
| 64 |
+
|
| 65 |
+
if not settings.debug:
|
| 66 |
+
logging.getLogger("PIL").setLevel(logging.WARNING)
|
src/faceverification/services/face_verification.py
CHANGED
|
@@ -1,11 +1,14 @@
|
|
| 1 |
"""Application service functions for face enrollment and verification."""
|
| 2 |
|
|
|
|
|
|
|
| 3 |
from PIL import Image
|
| 4 |
|
| 5 |
from faceverification.core.image_processor import FaceNotDetectedError, ImageProcessor
|
| 6 |
from faceverification.core.vectordb import VectorDB
|
| 7 |
|
| 8 |
UNREGISTERED_PERSON = "Unregistered Person"
|
|
|
|
| 9 |
|
| 10 |
image_processor = ImageProcessor()
|
| 11 |
|
|
@@ -27,9 +30,17 @@ def add_person(image: Image.Image, name: str) -> Image.Image:
|
|
| 27 |
TypeError: If embedding extraction returns an unexpected value.
|
| 28 |
"""
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
img, presence = image_processor.detect_faces(image)
|
| 31 |
|
| 32 |
if not presence:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
raise FaceNotDetectedError("No faces were detected in the image.")
|
| 34 |
|
| 35 |
faces_pt = image_processor.get_embedding(img)
|
|
@@ -37,6 +48,10 @@ def add_person(image: Image.Image, name: str) -> Image.Image:
|
|
| 37 |
raise TypeError("The extracted face embedding is not a torch.Tensor.")
|
| 38 |
|
| 39 |
vector_db.add_embedding(faces_pt.cpu().numpy(), {"name": name})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
return img
|
| 42 |
|
|
@@ -54,18 +69,46 @@ def verify_person(image: Image.Image) -> tuple[str, Image.Image]:
|
|
| 54 |
FaceNotDetectedError: If no face is detected.
|
| 55 |
ValueError: If the vector database has no stored embeddings.
|
| 56 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
detected_faces, presence = image_processor.detect_faces(image.copy())
|
| 58 |
|
| 59 |
if not presence:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
raise FaceNotDetectedError("No faces were detected in the image.")
|
| 61 |
|
| 62 |
faces_pt = image_processor.get_embedding(image)
|
| 63 |
if faces_pt is None:
|
| 64 |
raise FaceNotDetectedError("No faces were detected in the image.")
|
| 65 |
|
| 66 |
-
metadata,
|
| 67 |
|
| 68 |
if metadata:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
return metadata["name"], detected_faces
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
return UNREGISTERED_PERSON, detected_faces
|
|
|
|
| 1 |
"""Application service functions for face enrollment and verification."""
|
| 2 |
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
from PIL import Image
|
| 6 |
|
| 7 |
from faceverification.core.image_processor import FaceNotDetectedError, ImageProcessor
|
| 8 |
from faceverification.core.vectordb import VectorDB
|
| 9 |
|
| 10 |
UNREGISTERED_PERSON = "Unregistered Person"
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
image_processor = ImageProcessor()
|
| 14 |
|
|
|
|
| 30 |
TypeError: If embedding extraction returns an unexpected value.
|
| 31 |
"""
|
| 32 |
|
| 33 |
+
logger.debug(
|
| 34 |
+
"face_enrollment_started",
|
| 35 |
+
extra={"extra_fields": {"event": "face_enrollment_started", "name_length": len(name)}},
|
| 36 |
+
)
|
| 37 |
img, presence = image_processor.detect_faces(image)
|
| 38 |
|
| 39 |
if not presence:
|
| 40 |
+
logger.debug(
|
| 41 |
+
"face_enrollment_no_face",
|
| 42 |
+
extra={"extra_fields": {"event": "face_enrollment_no_face"}},
|
| 43 |
+
)
|
| 44 |
raise FaceNotDetectedError("No faces were detected in the image.")
|
| 45 |
|
| 46 |
faces_pt = image_processor.get_embedding(img)
|
|
|
|
| 48 |
raise TypeError("The extracted face embedding is not a torch.Tensor.")
|
| 49 |
|
| 50 |
vector_db.add_embedding(faces_pt.cpu().numpy(), {"name": name})
|
| 51 |
+
logger.debug(
|
| 52 |
+
"face_enrollment_completed",
|
| 53 |
+
extra={"extra_fields": {"event": "face_enrollment_completed"}},
|
| 54 |
+
)
|
| 55 |
|
| 56 |
return img
|
| 57 |
|
|
|
|
| 69 |
FaceNotDetectedError: If no face is detected.
|
| 70 |
ValueError: If the vector database has no stored embeddings.
|
| 71 |
"""
|
| 72 |
+
logger.debug(
|
| 73 |
+
"face_verification_started",
|
| 74 |
+
extra={"extra_fields": {"event": "face_verification_started"}},
|
| 75 |
+
)
|
| 76 |
detected_faces, presence = image_processor.detect_faces(image.copy())
|
| 77 |
|
| 78 |
if not presence:
|
| 79 |
+
logger.debug(
|
| 80 |
+
"face_verification_no_face",
|
| 81 |
+
extra={"extra_fields": {"event": "face_verification_no_face"}},
|
| 82 |
+
)
|
| 83 |
raise FaceNotDetectedError("No faces were detected in the image.")
|
| 84 |
|
| 85 |
faces_pt = image_processor.get_embedding(image)
|
| 86 |
if faces_pt is None:
|
| 87 |
raise FaceNotDetectedError("No faces were detected in the image.")
|
| 88 |
|
| 89 |
+
metadata, distance = vector_db.query_embedding(faces_pt.cpu().numpy())
|
| 90 |
|
| 91 |
if metadata:
|
| 92 |
+
logger.debug(
|
| 93 |
+
"face_verification_completed",
|
| 94 |
+
extra={
|
| 95 |
+
"extra_fields": {
|
| 96 |
+
"event": "face_verification_completed",
|
| 97 |
+
"matched": True,
|
| 98 |
+
"distance": distance,
|
| 99 |
+
}
|
| 100 |
+
},
|
| 101 |
+
)
|
| 102 |
return metadata["name"], detected_faces
|
| 103 |
|
| 104 |
+
logger.debug(
|
| 105 |
+
"face_verification_completed",
|
| 106 |
+
extra={
|
| 107 |
+
"extra_fields": {
|
| 108 |
+
"event": "face_verification_completed",
|
| 109 |
+
"matched": False,
|
| 110 |
+
"distance": distance,
|
| 111 |
+
}
|
| 112 |
+
},
|
| 113 |
+
)
|
| 114 |
return UNREGISTERED_PERSON, detected_faces
|