Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ vite/*
secrets/*
# Local testing @ildyria
public/uploads-bck/*
public/uploads/*
*.sql

# Node
node_modules/
Expand All @@ -56,8 +58,12 @@ npm-debug.log
# Mapping for database and config used by docker compose
lychee/*

# Python
ai-vision-service/*

# Laravel
/storage/logs/*
/storage/tmp/*
/storage/framework/cache/*
/storage/framework/sessions/*
/storage/framework/views/*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
name: Python AI Vision Service
name: Python AI Vision Service for face recognition

on:
push:
branches:
- master
- assisted-vision
paths:
- 'ai-vision-service/**'
- 'ai-vision-service/face-recognition/**'
pull_request:
paths:
- 'ai-vision-service/**'
- 'ai-vision-service/face-recognition/**'
workflow_dispatch:

# Declare default permissions as read only.
Expand All @@ -22,7 +22,7 @@ concurrency:

defaults:
run:
working-directory: ai-vision-service
working-directory: ai-vision-service/face-recognition

jobs:
# ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@

from app.api.dependencies import get_detector, get_store, require_api_key
from app.api.schemas import (
ClusterFaceResult,
ClusterResponse,
DeleteEmbeddingsRequest,
DeleteEmbeddingsResponse,
DetectCallbackPayload,
DetectCallbackResponse,
DetectRequest,
Expand All @@ -29,6 +33,7 @@
MatchResult,
SuggestionResult,
)
from app.clustering.clusterer import FaceClusterer
from app.config import AppSettings, get_settings
from app.detection.cropper import generate_crop

Expand Down Expand Up @@ -124,6 +129,63 @@ async def match(
return MatchResponse(matches=[MatchResult(lychee_face_id=face_id, confidence=conf) for face_id, conf in matches])


# ---------------------------------------------------------------------------
# DELETE /embeddings
# ---------------------------------------------------------------------------


@router.delete("/embeddings")
async def delete_embeddings(
body: DeleteEmbeddingsRequest,
request: Request,
_: None = Depends(require_api_key),
) -> DeleteEmbeddingsResponse:
"""Delete face embeddings by their Lychee Face IDs.

Called by Lychee when dismissed faces are permanently removed or when
a photo is deleted.
"""
store: EmbeddingStore = get_store(request)
deleted = store.delete_many(body.face_ids)
return DeleteEmbeddingsResponse(deleted=deleted)


# ---------------------------------------------------------------------------
# POST /cluster
# ---------------------------------------------------------------------------


@router.post("/cluster")
async def cluster(
request: Request,
settings: AppSettings = Depends(get_settings),
_: None = Depends(require_api_key),
) -> ClusterResponse:
"""Run DBSCAN clustering over all stored face embeddings.

Reads every embedding from the store, clusters them, and returns the
per-face cluster assignments. The PHP layer uses these to set
``faces.cluster_label`` for the cluster-review UI.
"""
store: EmbeddingStore = get_store(request)
all_embeddings = store.get_all()

if not all_embeddings:
return ClusterResponse(total_faces=0, num_clusters=0, assignments=[])

clusterer = FaceClusterer(eps=settings.cluster_eps)
results = clusterer.cluster(all_embeddings)

assignments = [ClusterFaceResult(lychee_face_id=fid, cluster_label=label) for fid, label in results]
distinct_labels = {label for _, label in results if label != -1}

return ClusterResponse(
total_faces=len(results),
num_clusters=len(distinct_labels),
assignments=assignments,
)


# ---------------------------------------------------------------------------
# GET /health
# ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,53 @@ class MatchResponse(BaseModel):
"""Top-N matches above ``VISION_FACE_MATCH_THRESHOLD``, ordered by descending confidence."""


# ---------------------------------------------------------------------------
# DELETE /embeddings - Lychee -> Python
# ---------------------------------------------------------------------------


class DeleteEmbeddingsRequest(BaseModel):
"""Body sent by Lychee to delete face embeddings from the store."""

face_ids: list[str] = Field(min_length=1)
"""List of Lychee ``Face.id`` values whose embeddings should be removed."""


class DeleteEmbeddingsResponse(BaseModel):
"""Response body for ``DELETE /embeddings``."""

deleted: int
"""Number of embeddings actually removed (IDs not found are silently skipped)."""


# ---------------------------------------------------------------------------
# POST /cluster - Lychee -> Python
# ---------------------------------------------------------------------------


class ClusterFaceResult(BaseModel):
"""One face's cluster assignment."""

lychee_face_id: str
"""Lychee ``Face.id``."""

cluster_label: int
"""DBSCAN cluster label. ``-1`` = noise / unassigned."""


class ClusterResponse(BaseModel):
"""Response body for ``POST /cluster``."""

total_faces: int
"""Total number of embeddings that were clustered."""

num_clusters: int
"""Number of distinct clusters found (excluding noise)."""

assignments: list[ClusterFaceResult]
"""Per-face cluster label assignments."""


# ---------------------------------------------------------------------------
# /health
# ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@

from __future__ import annotations

import logging

import numpy as np
from sklearn.cluster import DBSCAN

logger = logging.getLogger(__name__)


class FaceClusterer:
Expand Down Expand Up @@ -50,8 +55,6 @@ def cluster(self, face_embeddings: list[tuple[str, list[float]]]) -> list[tuple[
if not face_embeddings:
return []

from sklearn.cluster import DBSCAN

ids = [fid for fid, _ in face_embeddings]
vectors = np.array([emb for _, emb in face_embeddings], dtype=np.float32)

Expand All @@ -63,7 +66,14 @@ def cluster(self, face_embeddings: list[tuple[str, list[float]]]) -> list[tuple[
norms = np.where(norms == 0, 1.0, norms)
vectors = vectors / norms

logger.info(
"Start clustering %d face embeddings with DBSCAN (eps=%.2f, min_samples=%d)",
len(face_embeddings),
self._eps,
self._min_samples,
)
db = DBSCAN(eps=self._eps, min_samples=self._min_samples, metric="cosine")
labels: list[int] = db.fit_predict(vectors).tolist()
logger.info("Done clustering %d face embeddings with DBSCAN", len(face_embeddings))

return list(zip(ids, labels, strict=True))
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ class AppSettings(BaseSettings):
log_level: str = "info"
"""Uvicorn/application log level."""

# --- Clustering ---
cluster_eps: float = 0.6
"""DBSCAN epsilon (max cosine distance) for face clustering.
Lower values produce tighter, more homogeneous clusters."""

# --- Quality filtering ---
blur_threshold: float = 100.0
"""Laplacian variance threshold for blur detection.
Face crops with a variance below this value are discarded before embedding."""

model_config = SettingsConfigDict(
env_prefix="VISION_FACE_",
# Support .env files in development but never require them in production.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ def __init__(
self,
model_name: str = "buffalo_l",
detection_threshold: float = 0.5,
blur_threshold: float = 100.0,
model_root: str = "/root/.insightface",
) -> None:
self._model_name = model_name
self._detection_threshold = detection_threshold
self._blur_threshold = blur_threshold
self._model_root = model_root
self._app: Any = None # insightface.app.FaceAnalysis - untyped library
self._lock = threading.Lock()
Expand Down Expand Up @@ -137,6 +139,8 @@ def detect_bytes(self, image_bytes: bytes) -> list[DetectedFace]:

def _detect_array(self, img: Any) -> list[DetectedFace]:
"""Run detection on a BGR numpy array (internal)."""
import cv2

if self._app is None:
raise RuntimeError("FaceDetector not loaded - call load() first.")

Expand All @@ -155,6 +159,20 @@ def _detect_array(self, img: Any) -> list[DetectedFace]:
bbox: Any = face.bbox # [x1, y1, x2, y2] absolute pixels
x1, y1, x2, y2 = float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3])

# Blur filter: compute Laplacian variance on the face crop region.
# A value of 0.0 disables the filter (all faces pass).
if self._blur_threshold > 0.0:
px1 = max(0, int(x1))
py1 = max(0, int(y1))
px2 = min(w, int(x2))
py2 = min(h, int(y2))
if px2 > px1 and py2 > py1:
crop_region = img[py1:py2, px1:px2]
gray = cv2.cvtColor(crop_region, cv2.COLOR_BGR2GRAY)
variance: float = float(cv2.Laplacian(gray, cv2.CV_64F).var())
if variance < self._blur_threshold:
continue

# Normalise to [0, 1] and clamp
fx = max(0.0, min(1.0, x1 / w))
fy = max(0.0, min(1.0, y1 / h))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,31 @@ def similarity_search(
rows: list[Any] = cur.fetchall()
return [(row[0], float(row[1])) for row in rows]

def delete_many(self, lychee_face_ids: list[str]) -> int:
"""Remove multiple embeddings by Lychee Face ID."""
if not lychee_face_ids:
return 0
with self._lock:
conn = self._get_conn()
with conn.cursor() as cur:
# Use ANY(%s) with a list parameter for batch delete
cur.execute(
"DELETE FROM face_embeddings WHERE lychee_face_id = ANY(%s)",
(lychee_face_ids,),
)
deleted: int = cur.rowcount
conn.commit()
return deleted

def get_all(self) -> list[tuple[str, list[float]]]:
"""Return all stored embeddings as (face_id, embedding) pairs."""
with self._lock:
conn = self._get_conn()
with conn.cursor() as cur:
cur.execute("SELECT lychee_face_id, embedding FROM face_embeddings")
rows: list[Any] = cur.fetchall()
return [(row[0], [float(v) for v in row[1]]) for row in rows]

def count(self) -> int:
"""Return the total number of stored embeddings."""
with self._lock:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,48 @@ def similarity_search(
finally:
conn.close()

def delete_many(self, lychee_face_ids: list[str]) -> int:
"""Remove multiple embeddings by Lychee Face ID."""
if not lychee_face_ids:
return 0
deleted = 0
with self._lock:
conn = self._connect()
try:
for fid in lychee_face_ids:
row = conn.execute(
"SELECT vec_rowid FROM face_meta WHERE lychee_face_id = ?",
[fid],
).fetchone()
if row is not None:
conn.execute("DELETE FROM vec_faces WHERE rowid = ?", [row[0]])
conn.execute("DELETE FROM face_meta WHERE lychee_face_id = ?", [fid])
deleted += 1
conn.commit()
finally:
conn.close()
return deleted

def get_all(self) -> list[tuple[str, list[float]]]:
"""Return all stored embeddings as (face_id, embedding) pairs."""
conn = self._connect()
try:
rows = conn.execute(
"""
SELECT m.lychee_face_id, v.face_embedding
FROM face_meta m
JOIN vec_faces v ON v.rowid = m.vec_rowid
""",
).fetchall()
results: list[tuple[str, list[float]]] = []
for lychee_face_id, blob in rows:
count = len(blob) // 4 # float32 = 4 bytes
embedding = list(struct.unpack(f"{count}f", blob))
results.append((lychee_face_id, embedding))
return results
finally:
conn.close()

def count(self) -> int:
"""Return the number of stored embeddings."""
conn = self._connect()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,27 @@ def similarity_search(
"""
...

def delete_many(self, lychee_face_ids: list[str]) -> int:
"""Remove multiple embeddings by Lychee Face ID.

Args:
lychee_face_ids: List of Lychee ``Face.id`` strings to remove.

Returns:
Number of embeddings actually deleted (IDs not found are silently
skipped).
"""
...

def get_all(self) -> list[tuple[str, list[float]]]:
"""Return all stored embeddings.

Returns:
List of ``(lychee_face_id, embedding)`` pairs. Used by the
clustering endpoint to read the full dataset into memory.
"""
...

def count(self) -> int:
"""Return the total number of stored embeddings."""
...
Loading
Loading