feat: add face detection, recognition, and clustering
- Create face_embeddings table with pgvector Vector(128) + HNSW index - Implement extract_faces task (YuNet detection + SFace recognition) - Implement recluster_faces task (DBSCAN clustering → Tag(kind=face_cluster)) - Clusters are named "Person N" and get representative_photo_id - cluster_id FK → tags.id, SET NULL on delete for merge/rename support Migration 0005 creates the face_embeddings table. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
41
backend/alembic/versions/0005_face_embeddings.py
Normal file
41
backend/alembic/versions/0005_face_embeddings.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""face_embeddings table
|
||||||
|
|
||||||
|
Revision ID: 0005_face_embeddings
|
||||||
|
Revises: 0004_ocr_fts
|
||||||
|
Create Date: 2026-04-10
|
||||||
|
|
||||||
|
Create face_embeddings table with pgvector Vector(128) for SFace
|
||||||
|
recognition embeddings and HNSW index.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0005_face_embeddings"
|
||||||
|
down_revision: Union[str, None] = "0004_ocr_fts"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS face_embeddings (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
photo_id VARCHAR NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||||
|
bbox JSONB,
|
||||||
|
vector vector(128),
|
||||||
|
cluster_id VARCHAR REFERENCES tags(id) ON DELETE SET NULL,
|
||||||
|
quality FLOAT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("CREATE INDEX IF NOT EXISTS ix_face_embeddings_photo_id ON face_embeddings(photo_id)")
|
||||||
|
op.execute("CREATE INDEX IF NOT EXISTS ix_face_embeddings_cluster_id ON face_embeddings(cluster_id)")
|
||||||
|
op.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_face_embeddings_vector_hnsw
|
||||||
|
ON face_embeddings USING hnsw (vector vector_cosine_ops)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS face_embeddings")
|
||||||
@@ -7,6 +7,7 @@ from app.models.tags import Tag, PhotoTag
|
|||||||
from app.models.heaps import Heap, HeapPhoto
|
from app.models.heaps import Heap, HeapPhoto
|
||||||
from app.models.embeddings import Embedding
|
from app.models.embeddings import Embedding
|
||||||
from app.models.ocr_text import OCRText
|
from app.models.ocr_text import OCRText
|
||||||
|
from app.models.face_embedding import FaceEmbedding
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'Photo',
|
'Photo',
|
||||||
@@ -18,4 +19,5 @@ __all__ = [
|
|||||||
'HeapPhoto',
|
'HeapPhoto',
|
||||||
'Embedding',
|
'Embedding',
|
||||||
'OCRText',
|
'OCRText',
|
||||||
|
'FaceEmbedding',
|
||||||
]
|
]
|
||||||
24
backend/app/models/face_embedding.py
Normal file
24
backend/app/models/face_embedding.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
Face embedding model — stores per-face detection + recognition vectors.
|
||||||
|
|
||||||
|
cluster_id FKs to tags.id where kind='face_cluster'. Null means
|
||||||
|
unclustered (will be assigned by recluster_faces).
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, String, Float, ForeignKey, DateTime, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from pgvector.sqlalchemy import Vector
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class FaceEmbedding(Base):
|
||||||
|
__tablename__ = 'face_embeddings'
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||||
|
bbox = Column(JSONB) # [x1, y1, x2, y2] normalized 0-1
|
||||||
|
vector = Column(Vector(128)) # SFace → 128-d
|
||||||
|
cluster_id = Column(String, ForeignKey('tags.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||||
|
quality = Column(Float)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -198,8 +198,117 @@ async def _detect_objects_async(photo_id: str):
|
|||||||
|
|
||||||
@shared_task(name='extract_faces', queue='vision')
|
@shared_task(name='extract_faces', queue='vision')
|
||||||
def extract_faces(photo_id: str):
|
def extract_faces(photo_id: str):
|
||||||
"""Detect faces and extract embeddings — implemented in PR7."""
|
"""Detect faces and store recognition embeddings. Clustering is
|
||||||
return {'status': 'not_implemented'}
|
handled separately by recluster_faces (periodic task)."""
|
||||||
|
if not settings.vision.enabled or not settings.vision.faces.enabled:
|
||||||
|
return {'status': 'skipped', 'reason': 'faces disabled'}
|
||||||
|
return asyncio.run(_extract_faces_async(photo_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_faces_async(photo_id: str):
|
||||||
|
image = _load_thumb(photo_id, "large") # 1280px for better face detection
|
||||||
|
if image is None:
|
||||||
|
return {'status': 'error', 'message': 'thumbnail not found'}
|
||||||
|
|
||||||
|
from app.services.vision.registry import registry
|
||||||
|
face_proc = registry.get_face_processor()
|
||||||
|
faces = face_proc.process(image)
|
||||||
|
|
||||||
|
if not faces:
|
||||||
|
logger.info("No faces detected for photo %s", photo_id)
|
||||||
|
return {'status': 'success', 'photo_id': photo_id, 'faces': 0}
|
||||||
|
|
||||||
|
from app.models.face_embedding import FaceEmbedding
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
# Wipe previous face results for this photo (re-run safe)
|
||||||
|
await session.execute(
|
||||||
|
delete(FaceEmbedding).where(FaceEmbedding.photo_id == photo_id)
|
||||||
|
)
|
||||||
|
for face in faces:
|
||||||
|
session.add(FaceEmbedding(
|
||||||
|
photo_id=photo_id,
|
||||||
|
bbox=face.bbox,
|
||||||
|
vector=face.embedding.tolist(),
|
||||||
|
quality=face.quality,
|
||||||
|
cluster_id=None, # assigned by recluster_faces
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info("Extracted %d faces from photo %s", len(faces), photo_id)
|
||||||
|
return {'status': 'success', 'photo_id': photo_id, 'faces': len(faces)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name='recluster_faces', queue='vision')
|
||||||
|
def recluster_faces():
|
||||||
|
"""Run DBSCAN clustering over all face embeddings and assign/create
|
||||||
|
Tag(kind=face_cluster) entries. Should be called periodically or
|
||||||
|
manually after a batch of new faces is extracted."""
|
||||||
|
if not settings.vision.enabled or not settings.vision.faces.enabled:
|
||||||
|
return {'status': 'skipped', 'reason': 'faces disabled'}
|
||||||
|
return asyncio.run(_recluster_faces_async())
|
||||||
|
|
||||||
|
|
||||||
|
async def _recluster_faces_async():
|
||||||
|
from app.models.face_embedding import FaceEmbedding
|
||||||
|
from app.models.tags import Tag
|
||||||
|
from app.services.vision.clustering import cluster_faces
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
# Load all face embeddings
|
||||||
|
result = await session.execute(
|
||||||
|
select(FaceEmbedding).order_by(FaceEmbedding.created_at)
|
||||||
|
)
|
||||||
|
face_rows = result.scalars().all()
|
||||||
|
|
||||||
|
if len(face_rows) < 2:
|
||||||
|
logger.info("Not enough faces for clustering (%d)", len(face_rows))
|
||||||
|
return {'status': 'success', 'clusters': 0}
|
||||||
|
|
||||||
|
embeddings = np.array([f.vector for f in face_rows], dtype=np.float32)
|
||||||
|
labels = cluster_faces(
|
||||||
|
embeddings,
|
||||||
|
eps=settings.vision.faces.cluster_eps,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map cluster labels → Tag(kind=face_cluster)
|
||||||
|
cluster_tag_map: dict[int, str] = {}
|
||||||
|
source_name = "vision:sface"
|
||||||
|
|
||||||
|
for i, label in enumerate(labels):
|
||||||
|
if label == -1:
|
||||||
|
face_rows[i].cluster_id = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if label not in cluster_tag_map:
|
||||||
|
# Check if a cluster tag already exists for this cluster
|
||||||
|
cluster_name = f"Person {label + 1}"
|
||||||
|
tag_result = await session.execute(
|
||||||
|
select(Tag).where(
|
||||||
|
Tag.kind == 'face_cluster',
|
||||||
|
Tag.source == source_name,
|
||||||
|
Tag.name == cluster_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tag = tag_result.scalar_one_or_none()
|
||||||
|
if not tag:
|
||||||
|
tag = Tag(
|
||||||
|
name=cluster_name,
|
||||||
|
kind='face_cluster',
|
||||||
|
source=source_name,
|
||||||
|
representative_photo_id=face_rows[i].photo_id,
|
||||||
|
)
|
||||||
|
session.add(tag)
|
||||||
|
await session.flush()
|
||||||
|
cluster_tag_map[label] = tag.id
|
||||||
|
|
||||||
|
face_rows[i].cluster_id = cluster_tag_map[label]
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
n_clusters = len(cluster_tag_map)
|
||||||
|
logger.info("Face clustering: %d clusters from %d faces", n_clusters, len(face_rows))
|
||||||
|
return {'status': 'success', 'clusters': n_clusters, 'faces': len(face_rows)}
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name='backfill_vision')
|
@shared_task(name='backfill_vision')
|
||||||
|
|||||||
Reference in New Issue
Block a user