leandrodevai commited on
Commit
88cf4ae
·
verified ·
1 Parent(s): e3dac32

Sync from GitHub via hub-sync

Browse files
README.md CHANGED
@@ -76,7 +76,16 @@ Interactive API documentation is available at:
76
 
77
  Protected endpoints require a bearer token. The default demo credentials are
78
  `demo` / `demo123`; override them with `FACEVERIFICATION_DEMO_USERNAME` and
79
- `FACEVERIFICATION_DEMO_PASSWORD` in `.env`.
 
 
 
 
 
 
 
 
 
80
 
81
  ```bash
82
  curl -X POST http://localhost:8000/auth/login \
@@ -97,6 +106,9 @@ Authorization: Bearer <access_token>
97
  - `POST /persons`: enrolls a known person from an uploaded image and form `name`.
98
  - `POST /verify`: verifies whether an uploaded face matches a known person.
99
 
 
 
 
100
  ## Deployment Notes
101
 
102
  For API deployments, the recommended baseline is the FastAPI container running
 
76
 
77
  Protected endpoints require a bearer token. The default demo credentials are
78
  `demo` / `demo123`; override them with `FACEVERIFICATION_DEMO_USERNAME` and
79
+ `FACEVERIFICATION_DEMO_PASSWORD` in `.env`. The JWT secret is also configurable
80
+ and should be changed outside local demos.
81
+
82
+ ```env
83
+ FACEVERIFICATION_DEMO_USERNAME=demo
84
+ FACEVERIFICATION_DEMO_PASSWORD=demo123
85
+ FACEVERIFICATION_JWT_SECRET_KEY=replace-this-with-a-long-random-secret
86
+ FACEVERIFICATION_JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
87
+ FACEVERIFICATION_MAX_UPLOAD_BYTES=5242880
88
+ ```
89
 
90
  ```bash
91
  curl -X POST http://localhost:8000/auth/login \
 
106
  - `POST /persons`: enrolls a known person from an uploaded image and form `name`.
107
  - `POST /verify`: verifies whether an uploaded face matches a known person.
108
 
109
+ Both face endpoints return an annotated image by default. Add
110
+ `?include_image=false` when the client only needs the JSON result.
111
+
112
  ## Deployment Notes
113
 
114
  For API deployments, the recommended baseline is the FastAPI container running
src/faceverification/config.py CHANGED
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
19
  jwt_secret_key: str = "change-me-in-production-demo-secret-32-bytes-min"
20
  jwt_algorithm: str = "HS256"
21
  jwt_access_token_expire_minutes: int = 60
 
22
 
23
  model_config = SettingsConfigDict(
24
  env_file=".env",
 
19
  jwt_secret_key: str = "change-me-in-production-demo-secret-32-bytes-min"
20
  jwt_algorithm: str = "HS256"
21
  jwt_access_token_expire_minutes: int = 60
22
+ max_upload_bytes: int = 5 * 1024 * 1024
23
 
24
  model_config = SettingsConfigDict(
25
  env_file=".env",
src/faceverification/core/image_processor.py CHANGED
@@ -1,9 +1,4 @@
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
 
@@ -20,12 +15,7 @@ class FaceNotDetectedError(ValueError):
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,
@@ -33,16 +23,12 @@ class ImageProcessor:
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
@@ -65,16 +51,16 @@ class ImageProcessor:
65
  self.facenet = InceptionResnetV1(pretrained=facenet_pretrained).eval().to(self.device)
66
 
67
  def get_embedding(self, image: Image.Image) -> torch.Tensor:
68
- """Return a normalized embedding for the detected face in an image.
69
 
70
  Args:
71
- image: PIL image containing a face.
72
 
73
  Returns:
74
- A one-dimensional normalized FaceNet embedding tensor.
75
 
76
  Raises:
77
- FaceNotDetectedError: If no face can be detected in the image.
78
  """
79
  face_tensor = self.mtcnn(image)
80
  if face_tensor is None:
@@ -91,14 +77,13 @@ class ImageProcessor:
91
  return features.squeeze(0)
92
 
93
  def detect_faces(self, image: Image.Image) -> tuple[Image.Image, bool]:
94
- """Draw detected face bounding boxes on an image.
95
 
96
  Args:
97
  image: PIL image to inspect and annotate.
98
 
99
  Returns:
100
- A tuple with the annotated image and a boolean indicating whether at
101
- least one face was detected.
102
  """
103
  boxes, probs = self.mtcnn.detect(image)
104
 
 
1
+ """Face detection and FaceNet embedding utilities."""
 
 
 
 
 
2
 
3
  from collections.abc import Sequence
4
 
 
15
 
16
 
17
  class ImageProcessor:
18
+ """Wrap MTCNN detection and FaceNet embedding extraction."""
 
 
 
 
 
19
 
20
  def __init__(
21
  self,
 
23
  mtcnn_thresholds: Sequence[float] | None = None,
24
  facenet_pretrained: str | None = None,
25
  ):
26
+ """Load models using explicit values or application settings.
27
 
28
  Args:
29
+ device: Inference device. Use `"auto"` to prefer CUDA when available.
 
30
  mtcnn_thresholds: Detection thresholds for the three MTCNN stages.
31
+ facenet_pretrained: Pretrained FaceNet weights name.
 
 
 
32
  """
33
  if device is None:
34
  device = settings.device
 
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.
55
 
56
  Args:
57
+ image: PIL image containing a detectable face.
58
 
59
  Returns:
60
+ One normalized FaceNet embedding tensor.
61
 
62
  Raises:
63
+ FaceNotDetectedError: If MTCNN cannot extract a face.
64
  """
65
  face_tensor = self.mtcnn(image)
66
  if face_tensor is None:
 
77
  return features.squeeze(0)
78
 
79
  def detect_faces(self, image: Image.Image) -> tuple[Image.Image, bool]:
80
+ """Draw face boxes and return whether any face was found.
81
 
82
  Args:
83
  image: PIL image to inspect and annotate.
84
 
85
  Returns:
86
+ The annotated image and a flag indicating if at least one face was found.
 
87
  """
88
  boxes, probs = self.mtcnn.detect(image)
89
 
src/faceverification/core/vectordb.py CHANGED
@@ -1,10 +1,4 @@
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 collections.abc import Mapping
@@ -18,13 +12,7 @@ from faceverification.config import settings
18
 
19
 
20
  class VectorDB:
21
- """Store and query face embeddings in a ChromaDB collection.
22
-
23
- The collection is configured with the selected HNSW distance metric and can
24
- run either in memory or against a persistent directory when one is provided.
25
- Query results are post-processed with NumPy so the service layer receives a
26
- simple `(metadata, distance)` pair.
27
- """
28
 
29
  def __init__(
30
  self,
@@ -32,15 +20,12 @@ class VectorDB:
32
  name_collection: str | None = None,
33
  persist_directory: str | None = None,
34
  ):
35
- """Initialize the ChromaDB client and face embeddings collection.
36
 
37
  Args:
38
- distance_metric: HNSW distance metric used by ChromaDB. Common
39
- values are `"l2"`, `"cosine"`, and `"ip"`.
40
- name_collection: Name of the collection that stores face
41
- embeddings.
42
- persist_directory: Optional directory where ChromaDB should persist
43
- data. When omitted, the database runs in memory.
44
  """
45
  if distance_metric is None:
46
  distance_metric = settings.vector_db_distance_metric
@@ -60,13 +45,11 @@ class VectorDB:
60
  )
61
 
62
  def add_embedding(self, embedding: np.ndarray, metadata: Mapping[str, Any]) -> None:
63
- """Add one face embedding and its metadata to the collection.
64
 
65
  Args:
66
- embedding: Face embedding vector produced by the face recognition
67
- model.
68
- metadata: Metadata associated with the embedding, such as the
69
- person's name.
70
  """
71
  self.collection.add(
72
  embeddings=[embedding],
@@ -80,18 +63,16 @@ class VectorDB:
80
  threshold: float | None = None,
81
  n_results: int | None = None,
82
  ) -> tuple[Mapping[str, Any] | None, float]:
83
- """Find the closest stored embedding within the configured threshold.
84
 
85
  Args:
86
- embedding: Query embedding vector to compare against stored
87
- embeddings.
88
- threshold: Maximum Euclidean distance accepted as a match.
89
- n_results: Number of nearest ChromaDB candidates to inspect.
90
 
91
  Returns:
92
- A tuple containing the matched metadata and its distance. If no
93
- candidate is within the threshold, metadata is `None` and the best
94
- distance is still returned.
95
  """
96
  if threshold is None:
97
  threshold = settings.face_match_threshold
 
1
+ """Small ChromaDB adapter for face embedding storage and lookup."""
 
 
 
 
 
 
2
 
3
  import uuid
4
  from collections.abc import Mapping
 
12
 
13
 
14
  class VectorDB:
15
+ """Store face embeddings and query the nearest known identity."""
 
 
 
 
 
 
16
 
17
  def __init__(
18
  self,
 
20
  name_collection: str | None = None,
21
  persist_directory: str | None = None,
22
  ):
23
+ """Create an in-memory or persistent Chroma collection.
24
 
25
  Args:
26
+ distance_metric: Chroma HNSW metric, such as `"l2"` or `"cosine"`.
27
+ name_collection: Collection name for stored face embeddings.
28
+ persist_directory: Directory for persistent storage, or `None` for memory.
 
 
 
29
  """
30
  if distance_metric is None:
31
  distance_metric = settings.vector_db_distance_metric
 
45
  )
46
 
47
  def add_embedding(self, embedding: np.ndarray, metadata: Mapping[str, Any]) -> None:
48
+ """Store one embedding with its metadata.
49
 
50
  Args:
51
+ embedding: Face embedding vector.
52
+ metadata: Data associated with the embedding, such as a person's name.
 
 
53
  """
54
  self.collection.add(
55
  embeddings=[embedding],
 
63
  threshold: float | None = None,
64
  n_results: int | None = None,
65
  ) -> tuple[Mapping[str, Any] | None, float]:
66
+ """Return matched metadata and distance, or `None` when outside threshold.
67
 
68
  Args:
69
+ embedding: Query embedding vector.
70
+ threshold: Maximum accepted distance for a match.
71
+ n_results: Number of nearest Chroma candidates to inspect.
 
72
 
73
  Returns:
74
+ Matched metadata and best distance. Metadata is `None` when no
75
+ candidate is close enough.
 
76
  """
77
  if threshold is None:
78
  threshold = settings.face_match_threshold
src/faceverification/interfaces/fastapi_app.py CHANGED
@@ -1,40 +1,24 @@
1
- """HTTP API for enrolling and verifying faces.
2
-
3
- The module exposes a small FastAPI application around the service layer:
4
-
5
- - ``POST /auth/login`` issues a short-lived JWT for the demo user.
6
- - ``POST /persons`` stores a known person embedding from an uploaded image.
7
- - ``POST /verify`` checks whether an uploaded face matches the local database.
8
-
9
- FastAPI uses the route metadata, Pydantic field descriptions, and endpoint
10
- docstrings below to build the interactive documentation at ``/docs`` and
11
- ``/redoc``.
12
- """
13
-
14
  from base64 import b64encode
15
  from contextlib import asynccontextmanager
16
  from datetime import UTC, datetime, timedelta
17
  from io import BytesIO
18
  from secrets import compare_digest
19
- from types import ModuleType
20
- from typing import Annotated
21
 
22
  import jwt
23
- from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
24
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
25
  from jwt import ExpiredSignatureError, InvalidTokenError
26
  from PIL import Image, ImageOps, UnidentifiedImageError
27
- from pydantic import BaseModel, Field
28
 
29
  from faceverification.config import settings
30
  from faceverification.core.image_processor import FaceNotDetectedError
 
31
 
32
  bearer_scheme = HTTPBearer(auto_error=False)
33
-
34
- DATA_URL_DESCRIPTION = (
35
- "PNG image encoded as a data URL. The image contains the service annotations "
36
- "for the detected face."
37
- )
38
 
39
  AUTH_RESPONSES = {
40
  status.HTTP_401_UNAUTHORIZED: {
@@ -64,6 +48,14 @@ IMAGE_ERROR_RESPONSES = {
64
  },
65
  },
66
  },
 
 
 
 
 
 
 
 
67
  status.HTTP_422_UNPROCESSABLE_CONTENT: {
68
  "description": "The request is valid, but no usable face or name was found.",
69
  "content": {
@@ -83,9 +75,20 @@ IMAGE_ERROR_RESPONSES = {
83
  }
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  @asynccontextmanager
87
  async def lifespan(app: FastAPI):
88
- """Load the face verification service once when the API starts."""
89
  from faceverification.services import face_verification
90
 
91
  app.state.face_service = face_verification
@@ -94,11 +97,7 @@ async def lifespan(app: FastAPI):
94
 
95
  app = FastAPI(
96
  title="Face Verification API",
97
- description=(
98
- "Demo API for enrolling known people and verifying uploaded face images. "
99
- "Authenticate with `/auth/login`, then send the returned bearer token to "
100
- "the protected face-verification endpoints."
101
- ),
102
  version="0.1.0",
103
  lifespan=lifespan,
104
  contact={
@@ -109,17 +108,13 @@ app = FastAPI(
109
 
110
 
111
  class HealthResponse(BaseModel):
112
- """Health-check payload returned by the system endpoint."""
113
-
114
- status: str = Field(default="ok", description="Current API status.")
115
 
116
 
117
  class EnrollResponse(BaseModel):
118
- """Response returned after a person is stored in the embeddings database."""
119
-
120
- message: str = Field(description="Human-readable result message.")
121
- name: str = Field(description="Normalized person name stored with the embedding.")
122
- annotated_image: str = Field(description=DATA_URL_DESCRIPTION)
123
 
124
  model_config = {
125
  "json_schema_extra": {
@@ -133,16 +128,9 @@ class EnrollResponse(BaseModel):
133
 
134
 
135
  class VerifyResponse(BaseModel):
136
- """Response returned after comparing an uploaded face against known people."""
137
-
138
- name: str = Field(
139
- description=(
140
- "Matched person name. Returns `Unregistered Person` when the closest "
141
- "embedding is outside the configured match threshold."
142
- ),
143
- )
144
- matched: bool = Field(description="Whether the uploaded face matched a known person.")
145
- annotated_image: str = Field(description=DATA_URL_DESCRIPTION)
146
 
147
  model_config = {
148
  "json_schema_extra": {
@@ -156,10 +144,8 @@ class VerifyResponse(BaseModel):
156
 
157
 
158
  class TokenResponse(BaseModel):
159
- """Bearer token returned by the demo authentication endpoint."""
160
-
161
- access_token: str = Field(description="JWT access token used in the Authorization header.")
162
- token_type: str = Field(default="bearer", description="OAuth2-compatible token type.")
163
 
164
  model_config = {
165
  "json_schema_extra": {
@@ -171,13 +157,11 @@ class TokenResponse(BaseModel):
171
  }
172
 
173
 
174
- def get_face_service(request: Request) -> ModuleType:
175
- """Return the service module stored during application startup."""
176
  return request.app.state.face_service
177
 
178
 
179
  def _unauthorized_error(detail: str = "Could not validate credentials.") -> HTTPException:
180
- """Build a consistent 401 response with the bearer authentication challenge."""
181
  return HTTPException(
182
  status_code=status.HTTP_401_UNAUTHORIZED,
183
  detail=detail,
@@ -186,14 +170,12 @@ def _unauthorized_error(detail: str = "Could not validate credentials.") -> HTTP
186
 
187
 
188
  def _create_access_token(username: str) -> str:
189
- """Create a signed JWT for the authenticated demo user."""
190
  expires_at = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes)
191
  payload = {"sub": username, "exp": expires_at}
192
  return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
193
 
194
 
195
  def _authenticate_demo_user(username: str, password: str) -> bool:
196
- """Validate demo credentials using constant-time comparisons."""
197
  valid_username = compare_digest(username, settings.demo_username)
198
  valid_password = compare_digest(password, settings.demo_password)
199
  return valid_username and valid_password
@@ -202,7 +184,6 @@ def _authenticate_demo_user(username: str, password: str) -> bool:
202
  def get_current_username(
203
  credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
204
  ) -> str:
205
- """Decode the bearer token and return the authenticated username."""
206
  if credentials is None:
207
  raise _unauthorized_error("Not authenticated")
208
 
@@ -226,7 +207,18 @@ def get_current_username(
226
 
227
 
228
  async def _read_image(upload: UploadFile) -> Image.Image:
229
- """Read an uploaded image, apply EXIF orientation, and return it as RGB."""
 
 
 
 
 
 
 
 
 
 
 
230
  if upload.content_type and not upload.content_type.startswith("image/"):
231
  raise HTTPException(
232
  status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
@@ -239,6 +231,11 @@ async def _read_image(upload: UploadFile) -> Image.Image:
239
  status_code=status.HTTP_400_BAD_REQUEST,
240
  detail="Uploaded image is empty.",
241
  )
 
 
 
 
 
242
 
243
  try:
244
  image = Image.open(BytesIO(contents))
@@ -252,25 +249,28 @@ async def _read_image(upload: UploadFile) -> Image.Image:
252
 
253
 
254
  def _image_to_data_url(image: Image.Image) -> str:
255
- """Serialize a PIL image as a PNG data URL for JSON responses."""
256
  buffer = BytesIO()
257
  image.save(buffer, format="PNG")
258
  encoded = b64encode(buffer.getvalue()).decode("ascii")
259
  return f"data:image/png;base64,{encoded}"
260
 
261
 
262
- def _service_error(exc: Exception) -> HTTPException:
263
- """Map service-layer exceptions to API-friendly HTTP errors."""
264
- if isinstance(exc, FaceNotDetectedError):
265
- return HTTPException(
266
- status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
267
- detail=str(exc),
268
- )
269
- if isinstance(exc, ValueError):
270
- return HTTPException(
271
- status_code=status.HTTP_400_BAD_REQUEST,
272
- detail=str(exc),
273
- )
 
 
 
 
274
  return HTTPException(
275
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
276
  detail="Face verification failed.",
@@ -281,19 +281,16 @@ def _service_error(exc: Exception) -> HTTPException:
281
  "/health",
282
  response_model=HealthResponse,
283
  summary="Check API health",
284
- response_description="The API is running.",
285
  tags=["system"],
286
  )
287
  def health() -> HealthResponse:
288
- """Return a lightweight status response for uptime checks."""
289
  return HealthResponse()
290
 
291
 
292
  @app.post(
293
  "/auth/login",
294
  response_model=TokenResponse,
295
- summary="Issue a demo access token",
296
- response_description="JWT bearer token for protected endpoints.",
297
  responses={
298
  status.HTTP_401_UNAUTHORIZED: {
299
  "description": "The username or password is incorrect.",
@@ -309,14 +306,13 @@ def health() -> HealthResponse:
309
  def login(
310
  username: Annotated[
311
  str,
312
- Form(description="Demo username configured with `FACEVERIFICATION_DEMO_USERNAME`."),
313
  ],
314
  password: Annotated[
315
  str,
316
- Form(description="Demo password configured with `FACEVERIFICATION_DEMO_PASSWORD`."),
317
  ],
318
  ) -> TokenResponse:
319
- """Authenticate the demo user and return a signed JWT access token."""
320
  if not _authenticate_demo_user(username, password):
321
  raise _unauthorized_error("Incorrect username or password.")
322
 
@@ -326,9 +322,9 @@ def login(
326
  @app.post(
327
  "/persons",
328
  response_model=EnrollResponse,
 
329
  status_code=status.HTTP_201_CREATED,
330
  summary="Enroll a known person",
331
- response_description="The person was stored and the annotated upload is returned.",
332
  responses={**AUTH_RESPONSES, **IMAGE_ERROR_RESPONSES},
333
  tags=["face verification"],
334
  )
@@ -342,14 +338,13 @@ async def enroll_person(
342
  Form(description="Person name to associate with the generated face embedding."),
343
  ],
344
  current_username: Annotated[str, Depends(get_current_username)],
345
- service: Annotated[ModuleType, Depends(get_face_service)],
 
 
 
 
346
  ) -> EnrollResponse:
347
- """Store a new known person in the embeddings database.
348
-
349
- The endpoint extracts a face embedding from the uploaded image and stores it
350
- under the submitted name. It returns the normalized name and a PNG data URL
351
- with the annotated detection result.
352
- """
353
  cleaned_name = name.strip()
354
  if not cleaned_name:
355
  raise HTTPException(
@@ -360,21 +355,25 @@ async def enroll_person(
360
  pil_image = await _read_image(image)
361
  try:
362
  annotated_image = service.add_person(pil_image, cleaned_name)
 
 
 
 
363
  except Exception as exc:
364
- raise _service_error(exc) from exc
365
 
366
  return EnrollResponse(
367
  message="Person added to the embeddings database.",
368
  name=cleaned_name,
369
- annotated_image=_image_to_data_url(annotated_image),
370
  )
371
 
372
 
373
  @app.post(
374
  "/verify",
375
  response_model=VerifyResponse,
 
376
  summary="Verify an uploaded face",
377
- response_description="Best match result and the annotated upload.",
378
  responses={**AUTH_RESPONSES, **IMAGE_ERROR_RESPONSES},
379
  tags=["face verification"],
380
  )
@@ -384,23 +383,27 @@ async def verify_identity(
384
  File(description="Image containing one clear face to compare with known people."),
385
  ],
386
  current_username: Annotated[str, Depends(get_current_username)],
387
- service: Annotated[ModuleType, Depends(get_face_service)],
 
 
 
 
388
  ) -> VerifyResponse:
389
- """Compare an uploaded face against the local embeddings database.
390
-
391
- A successful response always includes the closest label and whether it is
392
- considered a match according to the configured distance threshold.
393
- """
394
  pil_image = await _read_image(image)
395
  try:
396
  name, annotated_image = service.verify_person(pil_image)
 
 
 
 
397
  except Exception as exc:
398
- raise _service_error(exc) from exc
399
 
400
  return VerifyResponse(
401
  name=name,
402
- matched=name != "Unregistered Person",
403
- annotated_image=_image_to_data_url(annotated_image),
404
  )
405
 
406
 
 
1
+ import logging
 
 
 
 
 
 
 
 
 
 
 
 
2
  from base64 import b64encode
3
  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
11
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
12
  from jwt import ExpiredSignatureError, InvalidTokenError
13
  from PIL import Image, ImageOps, UnidentifiedImageError
14
+ 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
 
23
  AUTH_RESPONSES = {
24
  status.HTTP_401_UNAUTHORIZED: {
 
48
  },
49
  },
50
  },
51
+ status.HTTP_413_CONTENT_TOO_LARGE: {
52
+ "description": "The uploaded image is too large.",
53
+ "content": {
54
+ "application/json": {
55
+ "example": {"detail": "Uploaded image is too large."},
56
+ },
57
+ },
58
+ },
59
  status.HTTP_422_UNPROCESSABLE_CONTENT: {
60
  "description": "The request is valid, but no usable face or name was found.",
61
  "content": {
 
75
  }
76
 
77
 
78
+ class FaceService(Protocol):
79
+ """Service contract required by the HTTP layer.
80
+
81
+ Implementations must accept normalized PIL images and return annotated PIL
82
+ images for optional API responses.
83
+ """
84
+
85
+ def add_person(self, image: Image.Image, name: str) -> Image.Image: ...
86
+
87
+ def verify_person(self, image: Image.Image) -> tuple[str, Image.Image]: ...
88
+
89
+
90
  @asynccontextmanager
91
  async def lifespan(app: FastAPI):
 
92
  from faceverification.services import face_verification
93
 
94
  app.state.face_service = face_verification
 
97
 
98
  app = FastAPI(
99
  title="Face Verification API",
100
+ description="Enroll known people and verify uploaded face images.",
 
 
 
 
101
  version="0.1.0",
102
  lifespan=lifespan,
103
  contact={
 
108
 
109
 
110
  class HealthResponse(BaseModel):
111
+ status: str = "ok"
 
 
112
 
113
 
114
  class EnrollResponse(BaseModel):
115
+ message: str
116
+ name: str
117
+ annotated_image: str | None = None
 
 
118
 
119
  model_config = {
120
  "json_schema_extra": {
 
128
 
129
 
130
  class VerifyResponse(BaseModel):
131
+ name: str
132
+ matched: bool
133
+ annotated_image: str | None = None
 
 
 
 
 
 
 
134
 
135
  model_config = {
136
  "json_schema_extra": {
 
144
 
145
 
146
  class TokenResponse(BaseModel):
147
+ access_token: str
148
+ token_type: str = "bearer"
 
 
149
 
150
  model_config = {
151
  "json_schema_extra": {
 
157
  }
158
 
159
 
160
+ def get_face_service(request: Request) -> FaceService:
 
161
  return request.app.state.face_service
162
 
163
 
164
  def _unauthorized_error(detail: str = "Could not validate credentials.") -> HTTPException:
 
165
  return HTTPException(
166
  status_code=status.HTTP_401_UNAUTHORIZED,
167
  detail=detail,
 
170
 
171
 
172
  def _create_access_token(username: str) -> str:
 
173
  expires_at = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes)
174
  payload = {"sub": username, "exp": expires_at}
175
  return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
176
 
177
 
178
  def _authenticate_demo_user(username: str, password: str) -> bool:
 
179
  valid_username = compare_digest(username, settings.demo_username)
180
  valid_password = compare_digest(password, settings.demo_password)
181
  return valid_username and valid_password
 
184
  def get_current_username(
185
  credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
186
  ) -> str:
 
187
  if credentials is None:
188
  raise _unauthorized_error("Not authenticated")
189
 
 
207
 
208
 
209
  async def _read_image(upload: UploadFile) -> Image.Image:
210
+ """Validate an upload and return it as an RGB image.
211
+
212
+ Args:
213
+ upload: Multipart file received by FastAPI.
214
+
215
+ Returns:
216
+ RGB PIL image with EXIF orientation applied.
217
+
218
+ Raises:
219
+ HTTPException: If the file is not an image, is empty, too large, or invalid.
220
+ """
221
+
222
  if upload.content_type and not upload.content_type.startswith("image/"):
223
  raise HTTPException(
224
  status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
 
231
  status_code=status.HTTP_400_BAD_REQUEST,
232
  detail="Uploaded image is empty.",
233
  )
234
+ if len(contents) > settings.max_upload_bytes:
235
+ raise HTTPException(
236
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
237
+ detail="Uploaded image is too large.",
238
+ )
239
 
240
  try:
241
  image = Image.open(BytesIO(contents))
 
249
 
250
 
251
  def _image_to_data_url(image: Image.Image) -> str:
 
252
  buffer = BytesIO()
253
  image.save(buffer, format="PNG")
254
  encoded = b64encode(buffer.getvalue()).decode("ascii")
255
  return f"data:image/png;base64,{encoded}"
256
 
257
 
258
+ def _face_not_detected_error(exc: FaceNotDetectedError) -> HTTPException:
259
+ return HTTPException(
260
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
261
+ detail=str(exc),
262
+ )
263
+
264
+
265
+ def _bad_service_request(exc: ValueError) -> HTTPException:
266
+ return HTTPException(
267
+ status_code=status.HTTP_400_BAD_REQUEST,
268
+ detail=str(exc),
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.",
 
281
  "/health",
282
  response_model=HealthResponse,
283
  summary="Check API health",
 
284
  tags=["system"],
285
  )
286
  def health() -> HealthResponse:
 
287
  return HealthResponse()
288
 
289
 
290
  @app.post(
291
  "/auth/login",
292
  response_model=TokenResponse,
293
+ summary="Log in",
 
294
  responses={
295
  status.HTTP_401_UNAUTHORIZED: {
296
  "description": "The username or password is incorrect.",
 
306
  def login(
307
  username: Annotated[
308
  str,
309
+ Form(),
310
  ],
311
  password: Annotated[
312
  str,
313
+ Form(),
314
  ],
315
  ) -> TokenResponse:
 
316
  if not _authenticate_demo_user(username, password):
317
  raise _unauthorized_error("Incorrect username or password.")
318
 
 
322
  @app.post(
323
  "/persons",
324
  response_model=EnrollResponse,
325
+ response_model_exclude_none=True,
326
  status_code=status.HTTP_201_CREATED,
327
  summary="Enroll a known person",
 
328
  responses={**AUTH_RESPONSES, **IMAGE_ERROR_RESPONSES},
329
  tags=["face verification"],
330
  )
 
338
  Form(description="Person name to associate with the generated face embedding."),
339
  ],
340
  current_username: Annotated[str, Depends(get_current_username)],
341
+ service: Annotated[FaceService, Depends(get_face_service)],
342
+ include_image: Annotated[
343
+ bool,
344
+ Query(description="Include the annotated image as a base64 data URL."),
345
+ ] = True,
346
  ) -> EnrollResponse:
347
+ _ = current_username
 
 
 
 
 
348
  cleaned_name = name.strip()
349
  if not cleaned_name:
350
  raise HTTPException(
 
355
  pil_image = await _read_image(image)
356
  try:
357
  annotated_image = service.add_person(pil_image, cleaned_name)
358
+ except FaceNotDetectedError as exc:
359
+ raise _face_not_detected_error(exc) from exc
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.",
367
  name=cleaned_name,
368
+ annotated_image=_image_to_data_url(annotated_image) if include_image else None,
369
  )
370
 
371
 
372
  @app.post(
373
  "/verify",
374
  response_model=VerifyResponse,
375
+ response_model_exclude_none=True,
376
  summary="Verify an uploaded face",
 
377
  responses={**AUTH_RESPONSES, **IMAGE_ERROR_RESPONSES},
378
  tags=["face verification"],
379
  )
 
383
  File(description="Image containing one clear face to compare with known people."),
384
  ],
385
  current_username: Annotated[str, Depends(get_current_username)],
386
+ service: Annotated[FaceService, Depends(get_face_service)],
387
+ include_image: Annotated[
388
+ bool,
389
+ Query(description="Include the annotated image as a base64 data URL."),
390
+ ] = True,
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:
397
+ raise _face_not_detected_error(exc) from exc
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
 
409
 
src/faceverification/services/face_verification.py CHANGED
@@ -1,32 +1,30 @@
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)
@@ -35,26 +33,26 @@ def add_person(image: Image.Image, name: str) -> Image.Image:
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
 
@@ -62,12 +60,12 @@ def verify_person(image: Image.Image) -> tuple[str, Image.Image]:
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.")
 
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
 
12
  vector_db = VectorDB()
13
 
14
 
15
  def add_person(image: Image.Image, name: str) -> Image.Image:
16
+ """Store a person's face embedding and return the annotated image.
17
 
18
  Args:
19
+ image: PIL image containing the person's face.
20
+ name: Person name to store with the embedding.
21
 
22
  Returns:
23
+ Image annotated with detected face boxes.
24
 
25
  Raises:
26
+ FaceNotDetectedError: If no face is detected.
27
+ TypeError: If embedding extraction returns an unexpected value.
28
  """
29
 
30
  img, presence = image_processor.detect_faces(image)
 
33
  raise FaceNotDetectedError("No faces were detected in the image.")
34
 
35
  faces_pt = image_processor.get_embedding(img)
36
+ if faces_pt is None:
 
 
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
 
43
 
44
  def verify_person(image: Image.Image) -> tuple[str, Image.Image]:
45
+ """Return the closest known person name and the annotated image.
46
 
47
  Args:
48
+ image: PIL image containing the face to verify.
49
 
50
  Returns:
51
+ Matched person name, or `UNREGISTERED_PERSON`, plus the annotated image.
 
52
 
53
  Raises:
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
 
 
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
test/test_fastapi_app.py CHANGED
@@ -3,6 +3,7 @@ from io import BytesIO
3
  from fastapi.testclient import TestClient
4
  from PIL import Image
5
 
 
6
  from faceverification.core.image_processor import FaceNotDetectedError
7
  from faceverification.interfaces.fastapi_app import app, get_face_service
8
 
@@ -125,6 +126,24 @@ def test_verify_identity_returns_match_result():
125
  assert body["annotated_image"].startswith("data:image/png;base64,")
126
 
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  def test_verify_identity_returns_unprocessable_when_no_face_is_detected():
129
  class NoFaceService(FakeService):
130
  def verify_person(self, image):
@@ -145,6 +164,26 @@ def test_verify_identity_returns_unprocessable_when_no_face_is_detected():
145
  assert response.json() == {"detail": "No faces were detected in the image."}
146
 
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  def test_enroll_person_rejects_blank_name():
149
  app.dependency_overrides[get_face_service] = lambda: FakeService()
150
  try:
@@ -176,3 +215,20 @@ def test_upload_rejects_non_image_content_type():
176
 
177
  assert response.status_code == 415
178
  assert response.json() == {"detail": "Uploaded file must be an image."}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from fastapi.testclient import TestClient
4
  from PIL import Image
5
 
6
+ from faceverification.config import settings
7
  from faceverification.core.image_processor import FaceNotDetectedError
8
  from faceverification.interfaces.fastapi_app import app, get_face_service
9
 
 
126
  assert body["annotated_image"].startswith("data:image/png;base64,")
127
 
128
 
129
+ def test_verify_identity_can_skip_annotated_image():
130
+ fake_service = FakeService()
131
+ app.dependency_overrides[get_face_service] = lambda: fake_service
132
+ try:
133
+ client = TestClient(app)
134
+ response = client.post(
135
+ "/verify?include_image=false",
136
+ headers=_auth_headers(client),
137
+ files={"image": ("face.png", _image_bytes(), "image/png")},
138
+ )
139
+ finally:
140
+ app.dependency_overrides.clear()
141
+
142
+ body = response.json()
143
+ assert response.status_code == 200
144
+ assert body == {"name": "Ada", "matched": True}
145
+
146
+
147
  def test_verify_identity_returns_unprocessable_when_no_face_is_detected():
148
  class NoFaceService(FakeService):
149
  def verify_person(self, image):
 
164
  assert response.json() == {"detail": "No faces were detected in the image."}
165
 
166
 
167
+ def test_verify_identity_returns_internal_error_for_unexpected_service_failure():
168
+ class BrokenService(FakeService):
169
+ def verify_person(self, image):
170
+ raise RuntimeError("model failed")
171
+
172
+ app.dependency_overrides[get_face_service] = lambda: BrokenService()
173
+ try:
174
+ client = TestClient(app)
175
+ response = client.post(
176
+ "/verify",
177
+ headers=_auth_headers(client),
178
+ files={"image": ("face.png", _image_bytes(), "image/png")},
179
+ )
180
+ finally:
181
+ app.dependency_overrides.clear()
182
+
183
+ assert response.status_code == 500
184
+ assert response.json() == {"detail": "Face verification failed."}
185
+
186
+
187
  def test_enroll_person_rejects_blank_name():
188
  app.dependency_overrides[get_face_service] = lambda: FakeService()
189
  try:
 
215
 
216
  assert response.status_code == 415
217
  assert response.json() == {"detail": "Uploaded file must be an image."}
218
+
219
+
220
+ def test_upload_rejects_large_image(monkeypatch):
221
+ monkeypatch.setattr(settings, "max_upload_bytes", 1)
222
+ app.dependency_overrides[get_face_service] = lambda: FakeService()
223
+ try:
224
+ client = TestClient(app)
225
+ response = client.post(
226
+ "/verify",
227
+ headers=_auth_headers(client),
228
+ files={"image": ("face.png", _image_bytes(), "image/png")},
229
+ )
230
+ finally:
231
+ app.dependency_overrides.clear()
232
+
233
+ assert response.status_code == 413
234
+ assert response.json() == {"detail": "Uploaded image is too large."}