leandrodevai commited on
Commit
475623e
·
verified ·
1 Parent(s): a78e6a9

Sync from GitHub via hub-sync

Browse files
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 python app.py
53
  ```
54
 
55
  Using pip:
56
 
57
  ```bash
58
- pip install -r requirements.txt
59
- python app.py
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 --host 0.0.0.0 --port 8000
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
- GRADIO_SERVER_NAME: 0.0.0.0
33
- GRADIO_SERVER_PORT: 7860
 
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
- if best_dist <= threshold:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return image.convert("RGB")
 
 
 
 
 
 
 
 
 
 
 
 
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("Face verification service failed")
 
 
 
 
 
 
 
 
 
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=name != UNREGISTERED_PERSON,
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.services.face_verification import add_person, verify_person
 
 
 
 
 
 
 
 
 
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
- server_port=7860,
 
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, _ = 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
 
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