feat: auto-chain vision backfill + face recluster after scan, show progress in UI

Scan now automatically queues backfill_vision (+90s) and recluster_faces
(+300s) after dispatching folder scans. Face extraction also schedules a
debounced recluster via Redis so incremental file-watcher imports get
clustered without manual intervention.

The ScanProgress widget now tracks worker queue activity beyond the scan
phase, showing a "Processing Photos" indicator with vision queue counts
while background tasks (embeddings, faces, tags, OCR) are running.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-12 18:51:38 +02:00
parent 6457dc9da5
commit d933ea3842
3 changed files with 202 additions and 80 deletions

View File

@@ -348,13 +348,40 @@ def _save_faces(photo_id: str, faces) -> dict:
if faces:
logger.info("Extracted %d verified face(s) from photo %s", len(faces), photo_id)
_schedule_recluster_debounced()
return {'status': 'success', 'photo_id': photo_id, 'faces': len(faces)}
RECLUSTER_DEBOUNCE_KEY = "mule:recluster_faces:pending"
RECLUSTER_DELAY = 120 # seconds after last face extraction
def _schedule_recluster_debounced():
"""Schedule a recluster_faces run, debounced so rapid-fire face
extractions don't spawn hundreds of redundant cluster jobs."""
try:
import redis as _redis
r = _redis.from_url(settings.redis_url)
already_pending = r.set(RECLUSTER_DEBOUNCE_KEY, "1",
ex=RECLUSTER_DELAY, nx=True)
if already_pending:
recluster_faces.apply_async(countdown=RECLUSTER_DELAY)
logger.info("Scheduled debounced recluster_faces in %ds", RECLUSTER_DELAY)
except Exception as e:
logger.debug("recluster debounce check failed: %s", e)
@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."""
# Clear debounce key so new face extractions can schedule another round.
try:
import redis as _redis
_redis.from_url(settings.redis_url).delete(RECLUSTER_DEBOUNCE_KEY)
except Exception:
pass
if not settings.vision.enabled or not settings.vision.faces.enabled:
return {'status': 'skipped', 'reason': 'faces disabled'}