fix: sync DB sessions in Celery, letterbox YuNet, dedupe detections

- Rewrite all vision tasks to use sync psycopg2 sessions instead of
  asyncpg — fixes 'another operation in progress' and event loop errors
  when Celery forks workers sharing the async connection pool
- Letterbox-pad images to exactly 640x640 for YuNet face detector
  (was crashing on non-square thumbnails)
- Deduplicate object detections per label per photo — keep highest
  confidence only to avoid photo_tags PK violation on multiple
  detections of the same class
- Add all queues (-Q default,high,low,vision) to worker command

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 10:28:17 +02:00
parent 2a6661f779
commit 7558aeb5e6
3 changed files with 99 additions and 95 deletions

View File

@@ -68,7 +68,7 @@ class YuNetSFaceProcessor(FaceProcessor):
def process(self, image: np.ndarray) -> list[FaceDetection]: def process(self, image: np.ndarray) -> list[FaceDetection]:
orig_h, orig_w = image.shape[:2] orig_h, orig_w = image.shape[:2]
# Scale image for YuNet (expects fixed input size) # Scale + letterbox to exactly 640x640 (YuNet fixed input)
scale = min(_YUNET_INPUT_SIZE / orig_w, _YUNET_INPUT_SIZE / orig_h) scale = min(_YUNET_INPUT_SIZE / orig_w, _YUNET_INPUT_SIZE / orig_h)
new_w = int(orig_w * scale) new_w = int(orig_w * scale)
new_h = int(orig_h * scale) new_h = int(orig_h * scale)
@@ -79,12 +79,17 @@ class YuNetSFaceProcessor(FaceProcessor):
dtype=np.uint8, dtype=np.uint8,
) )
# YuNet expects BGR, uint8, NHWC # Letterbox pad to 640x640
bgr = resized[:, :, ::-1].copy() canvas = np.full((_YUNET_INPUT_SIZE, _YUNET_INPUT_SIZE, 3), 128, dtype=np.uint8)
pad_y = (_YUNET_INPUT_SIZE - new_h) // 2
pad_x = (_YUNET_INPUT_SIZE - new_w) // 2
canvas[pad_y:pad_y + new_h, pad_x:pad_x + new_w] = resized
# YuNet expects BGR
bgr = canvas[:, :, ::-1].copy()
# Run detection # Run detection
det_input = self._detector.get_inputs()[0] det_input = self._detector.get_inputs()[0]
# YuNet uses dynamic input — reshape
blob = bgr.astype(np.float32)[np.newaxis] # (1, H, W, 3) blob = bgr.astype(np.float32)[np.newaxis] # (1, H, W, 3)
# Some YuNet ONNX exports expect (1, 3, H, W) # Some YuNet ONNX exports expect (1, 3, H, W)
if det_input.shape and len(det_input.shape) == 4 and det_input.shape[1] == 3: if det_input.shape and len(det_input.shape) == 4 and det_input.shape[1] == 3:
@@ -109,11 +114,11 @@ class YuNetSFaceProcessor(FaceProcessor):
if face_size < self._min_face_size: if face_size < self._min_face_size:
continue continue
# Rescale to original image coords # Remove letterbox padding and rescale to original image coords
x1 = x / scale x1 = (x - pad_x) / scale
y1 = y / scale y1 = (y - pad_y) / scale
x2 = (x + w) / scale x2 = (x + w - pad_x) / scale
y2 = (y + h) / scale y2 = (y + h - pad_y) / scale
bbox = [ bbox = [
max(0, x1 / orig_w), max(0, x1 / orig_w),
@@ -124,7 +129,9 @@ class YuNetSFaceProcessor(FaceProcessor):
# Extract landmarks (5 points) for alignment # Extract landmarks (5 points) for alignment
if len(det) >= 15: if len(det) >= 15:
landmarks = det[5:15].reshape(5, 2) / scale landmarks = det[5:15].reshape(5, 2)
landmarks[:, 0] = (landmarks[:, 0] - pad_x) / scale
landmarks[:, 1] = (landmarks[:, 1] - pad_y) / scale
else: else:
# Fallback: no landmarks, skip recognition # Fallback: no landmarks, skip recognition
continue continue

View File

@@ -5,24 +5,32 @@ face recognition.
All tasks run on the dedicated `vision` queue with limited concurrency All tasks run on the dedicated `vision` queue with limited concurrency
(memory-bound CPU inference). They read thumbnails generated by (memory-bound CPU inference). They read thumbnails generated by
generate_thumbnails, so they MUST run after thumbs complete. generate_thumbnails, so they MUST run after thumbs complete.
DB access uses sync psycopg2 sessions (not asyncpg) because Celery
forks workers and asyncpg connections can't be shared across forks.
""" """
import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
from celery import shared_task from celery import shared_task
from sqlalchemy import select, delete, text from sqlalchemy import create_engine, text as sa_text, select, delete
from sqlalchemy.orm import Session, sessionmaker
from PIL import Image from PIL import Image
from app.database import AsyncSessionLocal
from app.models import Photo
from app.models.embeddings import Embedding from app.models.embeddings import Embedding
from app.config import settings from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_sync_session() -> Session:
"""Create a sync DB session for use in Celery workers."""
sync_url = settings.database_url.replace("+asyncpg", "+psycopg2").replace("+aiosqlite", "")
engine = create_engine(sync_url, pool_pre_ping=True)
return sessionmaker(bind=engine)()
def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None: def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None:
"""Load a thumbnail as an RGB numpy array.""" """Load a thumbnail as an RGB numpy array."""
thumb_path = Path(f"/data/thumbs/{photo_id}/{size}.webp") thumb_path = Path(f"/data/thumbs/{photo_id}/{size}.webp")
@@ -38,10 +46,7 @@ def embed_photo(photo_id: str):
"""Generate CLIP embedding for a photo and store in pgvector.""" """Generate CLIP embedding for a photo and store in pgvector."""
if not settings.vision.enabled: if not settings.vision.enabled:
return {'status': 'skipped', 'reason': 'vision disabled'} return {'status': 'skipped', 'reason': 'vision disabled'}
return asyncio.run(_embed_photo_async(photo_id))
async def _embed_photo_async(photo_id: str):
image = _load_thumb(photo_id, "medium") # 640px image = _load_thumb(photo_id, "medium") # 640px
if image is None: if image is None:
return {'status': 'error', 'message': 'thumbnail not found'} return {'status': 'error', 'message': 'thumbnail not found'}
@@ -52,9 +57,9 @@ async def _embed_photo_async(photo_id: str):
model_name = settings.vision.embedder.name model_name = settings.vision.embedder.name
async with AsyncSessionLocal() as session: session = _get_sync_session()
# Upsert: delete existing then insert try:
await session.execute( session.execute(
delete(Embedding).where( delete(Embedding).where(
Embedding.photo_id == photo_id, Embedding.photo_id == photo_id,
Embedding.model == model_name, Embedding.model == model_name,
@@ -66,7 +71,9 @@ async def _embed_photo_async(photo_id: str):
vector=vector.tolist(), vector=vector.tolist(),
) )
session.add(emb) session.add(emb)
await session.commit() session.commit()
finally:
session.close()
logger.info("Embedded photo %s with %s", photo_id, model_name) logger.info("Embedded photo %s with %s", photo_id, model_name)
return {'status': 'success', 'photo_id': photo_id} return {'status': 'success', 'photo_id': photo_id}
@@ -95,10 +102,7 @@ def ocr_photo(photo_id: str):
"""Run OCR on a photo and store text regions.""" """Run OCR on a photo and store text regions."""
if not settings.vision.enabled or not settings.vision.ocr.enabled: if not settings.vision.enabled or not settings.vision.ocr.enabled:
return {'status': 'skipped', 'reason': 'OCR disabled'} return {'status': 'skipped', 'reason': 'OCR disabled'}
return asyncio.run(_ocr_photo_async(photo_id))
async def _ocr_photo_async(photo_id: str):
image = _load_thumb(photo_id, "large") # 1280px for better OCR accuracy image = _load_thumb(photo_id, "large") # 1280px for better OCR accuracy
if image is None: if image is None:
return {'status': 'error', 'message': 'thumbnail not found'} return {'status': 'error', 'message': 'thumbnail not found'}
@@ -113,11 +117,9 @@ async def _ocr_photo_async(photo_id: str):
from app.models.ocr_text import OCRText from app.models.ocr_text import OCRText
async with AsyncSessionLocal() as session: session = _get_sync_session()
# Delete existing OCR results for this photo (re-run safe) try:
await session.execute( session.execute(delete(OCRText).where(OCRText.photo_id == photo_id))
delete(OCRText).where(OCRText.photo_id == photo_id)
)
for r in results: for r in results:
session.add(OCRText( session.add(OCRText(
photo_id=photo_id, photo_id=photo_id,
@@ -126,7 +128,9 @@ async def _ocr_photo_async(photo_id: str):
confidence=r.confidence, confidence=r.confidence,
bbox=r.bbox, bbox=r.bbox,
)) ))
await session.commit() session.commit()
finally:
session.close()
logger.info("OCR: %d text regions for photo %s", len(results), photo_id) logger.info("OCR: %d text regions for photo %s", len(results), photo_id)
return {'status': 'success', 'photo_id': photo_id, 'regions': len(results)} return {'status': 'success', 'photo_id': photo_id, 'regions': len(results)}
@@ -138,10 +142,7 @@ def detect_objects(photo_id: str):
link via photo_tags with confidence/bbox/source.""" link via photo_tags with confidence/bbox/source."""
if not settings.vision.enabled or not settings.vision.detector.enabled: if not settings.vision.enabled or not settings.vision.detector.enabled:
return {'status': 'skipped', 'reason': 'detection disabled'} return {'status': 'skipped', 'reason': 'detection disabled'}
return asyncio.run(_detect_objects_async(photo_id))
async def _detect_objects_async(photo_id: str):
image = _load_thumb(photo_id, "medium") # 640px image = _load_thumb(photo_id, "medium") # 640px
if image is None: if image is None:
return {'status': 'error', 'message': 'thumbnail not found'} return {'status': 'error', 'message': 'thumbnail not found'}
@@ -158,38 +159,47 @@ async def _detect_objects_async(photo_id: str):
source_name = "vision:yolov8n" source_name = "vision:yolov8n"
async with AsyncSessionLocal() as session: session = _get_sync_session()
try:
# Wipe previous detection results for this photo from this model # Wipe previous detection results for this photo from this model
await session.execute( session.execute(
delete(photo_tags).where( delete(photo_tags).where(
photo_tags.c.photo_id == photo_id, photo_tags.c.photo_id == photo_id,
photo_tags.c.source == source_name, photo_tags.c.source == source_name,
) )
) )
# Group detections by label, keep highest confidence per label
best_per_label: dict[str, tuple[float, list]] = {}
for det in detections: for det in detections:
if det.label not in best_per_label or det.confidence > best_per_label[det.label][0]:
best_per_label[det.label] = (det.confidence, det.bbox)
for label, (confidence, bbox) in best_per_label.items():
# Find or create the object tag # Find or create the object tag
result = await session.execute( tag = session.execute(
select(Tag).where(Tag.name == det.label, Tag.kind == 'object') select(Tag).where(Tag.name == label, Tag.kind == 'object')
) ).scalar_one_or_none()
tag = result.scalar_one_or_none()
if not tag: if not tag:
tag = Tag(name=det.label, kind='object', source=source_name) tag = Tag(name=label, kind='object', source=source_name)
session.add(tag) session.add(tag)
await session.flush() # get tag.id session.flush() # get tag.id
# Insert photo_tags association with ML metadata # Insert photo_tags association with ML metadata
await session.execute( session.execute(
photo_tags.insert().values( photo_tags.insert().values(
photo_id=photo_id, photo_id=photo_id,
tag_id=tag.id, tag_id=tag.id,
confidence=det.confidence, confidence=confidence,
bbox=det.bbox, bbox=bbox,
source=source_name, source=source_name,
) )
) )
await session.commit() session.commit()
finally:
session.close()
labels = [d.label for d in detections] labels = [d.label for d in detections]
logger.info("Detected %d objects in photo %s: %s", len(detections), photo_id, labels) logger.info("Detected %d objects in photo %s: %s", len(detections), photo_id, labels)
@@ -202,10 +212,7 @@ def extract_faces(photo_id: str):
handled separately by recluster_faces (periodic task).""" handled separately by recluster_faces (periodic task)."""
if not settings.vision.enabled or not settings.vision.faces.enabled: if not settings.vision.enabled or not settings.vision.faces.enabled:
return {'status': 'skipped', 'reason': 'faces disabled'} 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 image = _load_thumb(photo_id, "large") # 1280px for better face detection
if image is None: if image is None:
return {'status': 'error', 'message': 'thumbnail not found'} return {'status': 'error', 'message': 'thumbnail not found'}
@@ -220,20 +227,20 @@ async def _extract_faces_async(photo_id: str):
from app.models.face_embedding import FaceEmbedding from app.models.face_embedding import FaceEmbedding
async with AsyncSessionLocal() as session: session = _get_sync_session()
# Wipe previous face results for this photo (re-run safe) try:
await session.execute( session.execute(delete(FaceEmbedding).where(FaceEmbedding.photo_id == photo_id))
delete(FaceEmbedding).where(FaceEmbedding.photo_id == photo_id)
)
for face in faces: for face in faces:
session.add(FaceEmbedding( session.add(FaceEmbedding(
photo_id=photo_id, photo_id=photo_id,
bbox=face.bbox, bbox=face.bbox,
vector=face.embedding.tolist(), vector=face.embedding.tolist(),
quality=face.quality, quality=face.quality,
cluster_id=None, # assigned by recluster_faces cluster_id=None,
)) ))
await session.commit() session.commit()
finally:
session.close()
logger.info("Extracted %d faces from photo %s", len(faces), photo_id) logger.info("Extracted %d faces from photo %s", len(faces), photo_id)
return {'status': 'success', 'photo_id': photo_id, 'faces': len(faces)} return {'status': 'success', 'photo_id': photo_id, 'faces': len(faces)}
@@ -242,36 +249,27 @@ async def _extract_faces_async(photo_id: str):
@shared_task(name='recluster_faces', queue='vision') @shared_task(name='recluster_faces', queue='vision')
def recluster_faces(): def recluster_faces():
"""Run DBSCAN clustering over all face embeddings and assign/create """Run DBSCAN clustering over all face embeddings and assign/create
Tag(kind=face_cluster) entries. Should be called periodically or Tag(kind=face_cluster) entries."""
manually after a batch of new faces is extracted."""
if not settings.vision.enabled or not settings.vision.faces.enabled: if not settings.vision.enabled or not settings.vision.faces.enabled:
return {'status': 'skipped', 'reason': 'faces disabled'} 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.face_embedding import FaceEmbedding
from app.models.tags import Tag from app.models.tags import Tag
from app.services.vision.clustering import cluster_faces from app.services.vision.clustering import cluster_faces
async with AsyncSessionLocal() as session: session = _get_sync_session()
# Load all face embeddings try:
result = await session.execute( face_rows = session.execute(
select(FaceEmbedding).order_by(FaceEmbedding.created_at) select(FaceEmbedding).order_by(FaceEmbedding.created_at)
) ).scalars().all()
face_rows = result.scalars().all()
if len(face_rows) < 2: if len(face_rows) < 2:
logger.info("Not enough faces for clustering (%d)", len(face_rows)) logger.info("Not enough faces for clustering (%d)", len(face_rows))
return {'status': 'success', 'clusters': 0} return {'status': 'success', 'clusters': 0}
embeddings = np.array([f.vector for f in face_rows], dtype=np.float32) embeddings = np.array([f.vector for f in face_rows], dtype=np.float32)
labels = cluster_faces( labels = cluster_faces(embeddings, eps=settings.vision.faces.cluster_eps)
embeddings,
eps=settings.vision.faces.cluster_eps,
)
# Map cluster labels → Tag(kind=face_cluster)
cluster_tag_map: dict[int, str] = {} cluster_tag_map: dict[int, str] = {}
source_name = "vision:sface" source_name = "vision:sface"
@@ -281,16 +279,15 @@ async def _recluster_faces_async():
continue continue
if label not in cluster_tag_map: if label not in cluster_tag_map:
# Check if a cluster tag already exists for this cluster
cluster_name = f"Person {label + 1}" cluster_name = f"Person {label + 1}"
tag_result = await session.execute( tag = session.execute(
select(Tag).where( select(Tag).where(
Tag.kind == 'face_cluster', Tag.kind == 'face_cluster',
Tag.source == source_name, Tag.source == source_name,
Tag.name == cluster_name, Tag.name == cluster_name,
) )
) ).scalar_one_or_none()
tag = tag_result.scalar_one_or_none()
if not tag: if not tag:
tag = Tag( tag = Tag(
name=cluster_name, name=cluster_name,
@@ -299,12 +296,14 @@ async def _recluster_faces_async():
representative_photo_id=face_rows[i].photo_id, representative_photo_id=face_rows[i].photo_id,
) )
session.add(tag) session.add(tag)
await session.flush() session.flush()
cluster_tag_map[label] = tag.id cluster_tag_map[label] = tag.id
face_rows[i].cluster_id = cluster_tag_map[label] face_rows[i].cluster_id = cluster_tag_map[label]
await session.commit() session.commit()
finally:
session.close()
n_clusters = len(cluster_tag_map) n_clusters = len(cluster_tag_map)
logger.info("Face clustering: %d clusters from %d faces", n_clusters, len(face_rows)) logger.info("Face clustering: %d clusters from %d faces", n_clusters, len(face_rows))
@@ -313,27 +312,25 @@ async def _recluster_faces_async():
@shared_task(name='backfill_vision') @shared_task(name='backfill_vision')
def backfill_vision(task: str | None = None, limit: int | None = None): def backfill_vision(task: str | None = None, limit: int | None = None):
"""Queue vision tasks for photos that haven't been processed yet.""" """Queue vision tasks for photos that haven't been processed yet.
return asyncio.run(_backfill_vision_async(task, limit)) Uses a sync DB connection to avoid asyncpg conflicts in Celery."""
async def _backfill_vision_async(task: str | None, limit: int | None):
model_name = settings.vision.embedder.name model_name = settings.vision.embedder.name
sql = """
SELECT p.id FROM photos p
LEFT JOIN embeddings e ON e.photo_id = p.id AND e.model = :model
WHERE e.photo_id IS NULL
AND p.processing_status = 'completed'
ORDER BY p.added_at DESC
"""
if limit:
sql += f" LIMIT {limit}"
async with AsyncSessionLocal() as session: session = _get_sync_session()
# Find photos without embeddings try:
stmt = text(""" result = session.execute(sa_text(sql), {"model": model_name})
SELECT p.id FROM photos p
LEFT JOIN embeddings e ON e.photo_id = p.id AND e.model = :model
WHERE e.photo_id IS NULL
AND p.processing_status = 'completed'
ORDER BY p.created_at DESC
""")
if limit:
stmt = text(str(stmt) + f" LIMIT {limit}")
result = await session.execute(stmt, {"model": model_name})
photo_ids = [row[0] for row in result.fetchall()] photo_ids = [row[0] for row in result.fetchall()]
finally:
session.close()
count = 0 count = 0
for pid in photo_ids: for pid in photo_ids:

View File

@@ -62,7 +62,7 @@ services:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: mulita-worker container_name: mulita-worker
command: sh -c "python -m app.services.vision.bootstrap_models && celery -A app.tasks.celery worker --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERYD_CONCURRENCY:-4}" command: sh -c "python -m app.services.vision.bootstrap_models && celery -A app.tasks.celery worker --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERYD_CONCURRENCY:-4} -Q default,high,low,vision"
volumes: volumes:
- ./mulita.yml:/app/config/mulita.yml:ro - ./mulita.yml:/app/config/mulita.yml:ro
- ${PHOTO_DIRS:-./photos}:/photos:rw - ${PHOTO_DIRS:-./photos}:/photos:rw