Introduce the app/services/vision/ module with ABC interfaces, ONNX Runtime backend, model registry, and per-task implementations: - OpenCLIP ViT-B/32 embedder (image + text, 512-d) - RapidOCR engine (PP-OCRv4 via ONNX, no PaddlePaddle) - YOLOv8n object detector (raw ONNX, no ultralytics runtime) - YuNet + SFace face processor (Apache 2.0, opencv_zoo, 128-d) - DBSCAN face clustering helper Add VisionSettings to config (mulita.yml + Pydantic), bootstrap_models.py for first-boot weight downloads, models_data Docker volume, and ROCm backend stub for future GPU acceleration. No Celery tasks wired yet — models load but nothing invokes them. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
43 lines
1.2 KiB
Python
43 lines
1.2 KiB
Python
"""
|
|
Face embedding clustering using DBSCAN with cosine distance.
|
|
|
|
Called by the periodic `recluster_faces` Celery task (PR7).
|
|
"""
|
|
import logging
|
|
|
|
import numpy as np
|
|
from sklearn.cluster import DBSCAN
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def cluster_faces(
|
|
embeddings: np.ndarray,
|
|
eps: float = 0.35,
|
|
min_samples: int = 2,
|
|
) -> np.ndarray:
|
|
"""Cluster face embeddings using DBSCAN with cosine metric.
|
|
|
|
Args:
|
|
embeddings: (N, D) float32 array of L2-normalized face embeddings.
|
|
eps: Maximum cosine distance between two samples to be in the
|
|
same neighborhood. Lower = tighter clusters.
|
|
min_samples: Minimum cluster size.
|
|
|
|
Returns:
|
|
(N,) int array of cluster labels. -1 = noise / unclustered.
|
|
"""
|
|
if len(embeddings) < min_samples:
|
|
return np.full(len(embeddings), -1, dtype=int)
|
|
|
|
db = DBSCAN(eps=eps, min_samples=min_samples, metric="cosine")
|
|
labels = db.fit_predict(embeddings)
|
|
|
|
n_clusters = len(set(labels) - {-1})
|
|
n_noise = (labels == -1).sum()
|
|
logger.info(
|
|
"Face clustering: %d embeddings → %d clusters, %d noise",
|
|
len(embeddings), n_clusters, n_noise,
|
|
)
|
|
return labels
|