feat: runtime feature flags, upload/download, RAW decoding

Adds Redis-backed feature flags for vision stages with admin UI toggles
and manual backfill trigger, photo upload and download routers with
frontend upload modal, and rawpy-based RAW decoding with JPEG fallback
for misnamed DNGs. Fixes pgvector serialization, is_trashed filter, and
naive-datetime bind in incremental duplicate regrouping; bumps Celery
time limits on regroup tasks beyond the 5-minute default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-14 21:31:52 +02:00
parent 800ee447ad
commit 5c531f11da
16 changed files with 2232 additions and 35 deletions

View File

@@ -20,6 +20,14 @@ from PIL import Image
from app.models.embeddings import Embedding
from app.config import settings
from app.services.feature_flags import (
is_enabled,
FLAG_VISION_ENABLED,
FLAG_OCR_ENABLED,
FLAG_DETECTOR_ENABLED,
FLAG_FACES_ENABLED,
FLAG_CLASSIFIER_ENABLED,
)
logger = logging.getLogger(__name__)
@@ -83,7 +91,7 @@ def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None:
@shared_task(name='embed_photo', queue='vision', bind=True, max_retries=3)
def embed_photo(self, photo_id: str):
"""Generate CLIP embedding for a photo and store in pgvector."""
if not settings.vision.enabled:
if not is_enabled(FLAG_VISION_ENABLED):
return {'status': 'skipped', 'reason': 'vision disabled'}
image = _load_thumb(photo_id, "medium") # 640px
@@ -128,18 +136,18 @@ def embed_photo(self, photo_id: str):
@shared_task(name='vision_fanout', queue='vision')
def vision_fanout(photo_id: str):
"""Dispatch all enabled vision tasks for a photo."""
if not settings.vision.enabled:
if not is_enabled(FLAG_VISION_ENABLED):
return {'status': 'skipped', 'reason': 'vision disabled'}
embed_photo.delay(photo_id)
if settings.vision.ocr.enabled:
if is_enabled(FLAG_OCR_ENABLED):
ocr_photo.delay(photo_id)
if settings.vision.detector.enabled:
if is_enabled(FLAG_DETECTOR_ENABLED):
detect_objects.delay(photo_id)
if settings.vision.faces.enabled:
if is_enabled(FLAG_FACES_ENABLED):
extract_faces.delay(photo_id)
if settings.vision.classifier.enabled:
if is_enabled(FLAG_CLASSIFIER_ENABLED):
classify_content.delay(photo_id)
return {'status': 'dispatched', 'photo_id': photo_id}
@@ -148,7 +156,7 @@ def vision_fanout(photo_id: str):
@shared_task(name='ocr_photo', queue='vision', bind=True, max_retries=3)
def ocr_photo(self, photo_id: str):
"""Run OCR on a photo and store text regions."""
if not settings.vision.enabled or not settings.vision.ocr.enabled:
if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_OCR_ENABLED):
return {'status': 'skipped', 'reason': 'OCR disabled'}
image = _load_thumb(photo_id, "large") # 1280px for better OCR accuracy
@@ -195,7 +203,7 @@ def ocr_photo(self, photo_id: str):
def detect_objects(self, photo_id: str):
"""Detect objects in a photo, create Tag(kind=object) rows, and
link via photo_tags with confidence/bbox/source."""
if not settings.vision.enabled or not settings.vision.detector.enabled:
if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_DETECTOR_ENABLED):
return {'status': 'skipped', 'reason': 'detection disabled'}
image = _load_thumb(photo_id, "medium") # 640px
@@ -279,7 +287,7 @@ def detect_objects(self, photo_id: str):
def classify_content(self, photo_id: str):
"""Classify image content type (screenshot, document, artwork, etc.)
using CLIP zero-shot classification. Writes Tag(kind=content_type)."""
if not settings.vision.enabled or not settings.vision.classifier.enabled:
if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_CLASSIFIER_ENABLED):
return {'status': 'skipped', 'reason': 'classifier disabled'}
image = _load_thumb(photo_id, "medium")
@@ -394,7 +402,7 @@ def extract_faces(self, photo_id: str):
"""Detect faces and store recognition embeddings using InsightFace
(RetinaFace + ArcFace). No YOLO workaround needed — RetinaFace has
strong human-vs-non-human precision on its own."""
if not settings.vision.enabled or not settings.vision.faces.enabled:
if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_FACES_ENABLED):
return {'status': 'skipped', 'reason': 'faces disabled'}
image = _load_original(photo_id)
@@ -478,7 +486,7 @@ def recluster_faces(self):
logger.info("Vision worker not ready yet — retrying in 30s")
raise self.retry(countdown=30)
if not settings.vision.enabled or not settings.vision.faces.enabled:
if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_FACES_ENABLED):
return {'status': 'skipped', 'reason': 'faces disabled'}
from app.models import Photo
@@ -603,7 +611,7 @@ def backfill_vision(self, task: str | None = None, limit: int | None = None):
embed_ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()]
ocr_ids = []
if task in ('ocr', None) and settings.vision.ocr.enabled:
if task in ('ocr', None) and is_enabled(FLAG_OCR_ENABLED):
sql = f"""
SELECT p.id FROM photos p
LEFT JOIN ocr_text o ON o.photo_id = p.id
@@ -613,7 +621,7 @@ def backfill_vision(self, task: str | None = None, limit: int | None = None):
ocr_ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()]
detect_ids = []
if task in ('detect', None) and settings.vision.detector.enabled:
if task in ('detect', None) and is_enabled(FLAG_DETECTOR_ENABLED):
sql = f"""
SELECT p.id FROM photos p
WHERE p.processing_status = 'completed'
@@ -626,7 +634,7 @@ def backfill_vision(self, task: str | None = None, limit: int | None = None):
detect_ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()]
face_ids = []
if task in ('faces', None) and settings.vision.faces.enabled:
if task in ('faces', None) and is_enabled(FLAG_FACES_ENABLED):
sql = f"""
SELECT p.id FROM photos p
LEFT JOIN face_embeddings fe ON fe.photo_id = p.id
@@ -636,7 +644,7 @@ def backfill_vision(self, task: str | None = None, limit: int | None = None):
face_ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()]
classify_ids = []
if task in ('classify', None) and settings.vision.classifier.enabled:
if task in ('classify', None) and is_enabled(FLAG_CLASSIFIER_ENABLED):
sql = f"""
SELECT p.id FROM photos p
WHERE p.processing_status = 'completed'