diff --git a/backend/alembic/versions/0005_face_embeddings.py b/backend/alembic/versions/0005_face_embeddings.py new file mode 100644 index 0000000..05a19cc --- /dev/null +++ b/backend/alembic/versions/0005_face_embeddings.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5c6b298..8d45771 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.tags import Tag, PhotoTag from app.models.heaps import Heap, HeapPhoto from app.models.embeddings import Embedding from app.models.ocr_text import OCRText +from app.models.face_embedding import FaceEmbedding __all__ = [ 'Photo', @@ -18,4 +19,5 @@ __all__ = [ 'HeapPhoto', 'Embedding', 'OCRText', + 'FaceEmbedding', ] \ No newline at end of file diff --git a/backend/app/models/face_embedding.py b/backend/app/models/face_embedding.py new file mode 100644 index 0000000..138e61f --- /dev/null +++ b/backend/app/models/face_embedding.py @@ -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()) diff --git a/backend/app/tasks/vision.py b/backend/app/tasks/vision.py index 796f48b..0f9d4d8 100644 --- a/backend/app/tasks/vision.py +++ b/backend/app/tasks/vision.py @@ -198,8 +198,117 @@ async def _detect_objects_async(photo_id: str): @shared_task(name='extract_faces', queue='vision') def extract_faces(photo_id: str): - """Detect faces and extract embeddings — implemented in PR7.""" - return {'status': 'not_implemented'} + """Detect faces and store recognition embeddings. Clustering is + 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')