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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user