refactor: strip AI pipeline to binary photo/other classifier

Drops face recognition, OCR, object detection, and semantic embeddings.
The sole remaining vision task is a CLIP-based binary classifier
(photography vs other); photos in "other" get needs_review=true so
screenshots, documents, memes and scans can be triaged from a new
filter pill in the UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 22:27:17 +02:00
parent 5c531f11da
commit 574d71371f
50 changed files with 700 additions and 3068 deletions

View File

@@ -320,13 +320,8 @@ async def update_feature_flag(
class BackfillVisionBody(BaseModel):
"""POST body for triggering a vision backfill. ``task`` picks a
specific stage (``embed`` / ``ocr`` / ``detect`` / ``faces`` /
``classify``); leaving it null runs every enabled stage. ``limit``
caps how many photos per stage are queued — useful for smoke-
testing a newly-enabled feature before committing a full run.
"""
task: Optional[str] = None
"""POST body for triggering a classifier backfill. ``limit`` caps how
many photos are queued."""
limit: Optional[int] = None
@@ -335,61 +330,29 @@ async def trigger_ai_backfill(
body: BackfillVisionBody,
admin: User = Depends(require_admin),
):
"""Queue a vision backfill pass. Identical code path as the automatic
post-scan backfill — just triggered manually from the UI."""
"""Queue a classifier backfill pass."""
if not is_enabled(FLAG_VISION_ENABLED):
raise HTTPException(
status_code=400,
detail="Vision is currently disabled; enable it before running a backfill.",
)
valid_tasks = {'embed', 'ocr', 'detect', 'faces', 'classify'}
if body.task is not None and body.task not in valid_tasks:
raise HTTPException(
status_code=400,
detail=f"task must be one of {sorted(valid_tasks)} or null",
)
if body.limit is not None and body.limit <= 0:
raise HTTPException(status_code=400, detail="limit must be positive")
# Import lazily so importing admin.py doesn't pull in the whole
# vision stack on startup (Celery task module loads numpy etc.).
from app.tasks.vision import backfill_vision
result = backfill_vision.apply_async(
kwargs={'task': body.task, 'limit': body.limit}
)
result = backfill_vision.apply_async(kwargs={'limit': body.limit})
logger.info(
f"Admin '{admin.username}' queued vision backfill "
f"(task={body.task}, limit={body.limit}, celery_id={result.id})"
f"(limit={body.limit}, celery_id={result.id})"
)
return {
"status": "queued",
"task_id": result.id,
"task": body.task,
"limit": body.limit,
}
@router.post("/ai/recluster-faces")
async def trigger_face_recluster(admin: User = Depends(require_admin)):
"""Kick off face recluster. Normally auto-fires after a scan via a
debounced scheduler; this endpoint is for admins who want to force
a fresh clustering pass (e.g. after tweaking ``cluster_eps`` in
the YAML config)."""
if not is_enabled(FLAG_VISION_ENABLED):
raise HTTPException(
status_code=400,
detail="Vision is currently disabled; enable it before reclustering.",
)
from app.tasks.vision import recluster_faces
result = recluster_faces.apply_async()
logger.info(
f"Admin '{admin.username}' queued face recluster (celery_id={result.id})"
)
return {"status": "queued", "task_id": result.id}
@router.post("/ai/rescan")
async def trigger_full_rescan(admin: User = Depends(require_admin)):
"""Dispatch the same scan_all_source_roots job the backend runs at

View File

@@ -89,6 +89,12 @@ async def get_library_stats(
)
).scalar() or 0
needs_review_count = (
await db.execute(
select(func.count(Photo.id)).where(visible, Photo.needs_review.is_(True))
)
).scalar() or 0
# Legacy split (kept for the existing /stats consumers).
photo_count = (
await db.execute(
@@ -120,6 +126,7 @@ async def get_library_stats(
"with_gps": with_gps_count,
"duplicates": duplicates_count,
"discarded": discarded_count,
"needs_review": needs_review_count,
"total_photos": photo_count,
"total_videos": video_count,
"total_size": size,
@@ -437,10 +444,7 @@ async def get_worker_status(
r = _redis.Redis.from_url(settings.redis_url, socket_timeout=1.0)
r.ping()
broker_ok = True
# `vision` is the big one — embed / classify / detect / ocr /
# extract_faces all land here, so it's where backlogs actually
# pile up. Leaving it off the dashboard made it look like the
# queue was always empty while the worker was clearly busy.
# `vision` runs the content classifier — the only heavy queue.
for q in ('default', 'high', 'low', 'vision'):
try:
queue_depths[q] = int(r.llen(q) or 0)
@@ -525,8 +529,6 @@ async def get_pipeline_stats(
Keep the shape flat + serialisable; the frontend turns it straight
into a list of rows without needing to know about the models.
"""
from app.config import settings as _settings
from app.models import Embedding, FaceEmbedding, OCRText
from app.models.tags import photo_tags # association Table, not a model
owner = _owner_filter(current_user, scope)
@@ -572,45 +574,15 @@ async def get_pipeline_stats(
)
)
# Embeddings: count distinct photos that have a row for the currently
# configured embedder model. A photo can have multiple model rows
# (historical re-embeds) so COUNT(DISTINCT) is the right thing here.
embedder_model = _settings.vision.embedder.name
embeddings_done = await scalar_count(
select(func.count(func.distinct(Embedding.photo_id)))
.select_from(Embedding)
.join(Photo, Photo.id == Embedding.photo_id)
.where(not_discarded, Embedding.model == embedder_model)
)
tagged_photos = await scalar_count(
# Classified: distinct photos with a content_type tag.
classified_done = await scalar_count(
select(func.count(func.distinct(photo_tags.c.photo_id)))
.select_from(photo_tags)
.join(Photo, Photo.id == photo_tags.c.photo_id)
.where(not_discarded)
.where(not_discarded, photo_tags.c.source == 'vision:clip_classifier')
)
ocr_done = await scalar_count(
select(func.count(func.distinct(OCRText.photo_id)))
.select_from(OCRText)
.join(Photo, Photo.id == OCRText.photo_id)
.where(not_discarded)
)
# Faces: photos that have at least one face_embeddings row. A photo
# with no faces legitimately finishes face extraction with zero rows,
# so this undercounts by exactly "images with no visible people". We
# surface the photo-with-faces count rather than "images scanned for
# faces" because the latter isn't tracked anywhere.
faces_photos = await scalar_count(
select(func.count(func.distinct(FaceEmbedding.photo_id)))
.select_from(FaceEmbedding)
.join(Photo, Photo.id == FaceEmbedding.photo_id)
.where(not_discarded)
)
face_rows = await scalar_count(select(func.count(FaceEmbedding.id)))
face_clusters = await scalar_count(
select(func.count(func.distinct(FaceEmbedding.cluster_id)))
.where(FaceEmbedding.cluster_id.is_not(None))
needs_review_count = await scalar_count(
select(func.count(Photo.id)).where(not_discarded, Photo.needs_review.is_(True))
)
duplicate_groups = await scalar_count(
@@ -657,43 +629,11 @@ async def get_pipeline_stats(
"hint": "Feeds duplicate detection.",
},
{
"key": "embeddings",
"label": f"Embeddings ({embedder_model})",
"done": embeddings_done,
"key": "classification",
"label": "Content classification (photo vs other)",
"done": classified_done,
"total": total_images,
"hint": "Semantic search + content classification.",
},
{
"key": "tags",
"label": "Object tags (YOLO)",
"done": tagged_photos,
"total": total_images,
"hint": "Auto-generated object labels. Not every photo has a detectable object.",
"partial": True,
},
{
"key": "ocr",
"label": "OCR text",
"done": ocr_done,
"total": total_images,
"hint": "Extracted text from screenshots / documents. Many photos have none.",
"partial": True,
},
{
"key": "faces",
"label": "Face detection",
"done": faces_photos,
"total": total_images,
"hint": f"{face_rows} face rows detected across {faces_photos} photos.",
"partial": True,
},
{
"key": "face_clusters",
"label": "Face clusters",
"done": face_clusters,
"total": face_clusters, # no meaningful "total" — it's just the current count
"hint": "Built by recluster_faces. Run it after backfill to populate the People view.",
"standalone": True,
"hint": f"{needs_review_count} photos flagged for review.",
},
{
"key": "duplicates",
@@ -708,7 +648,6 @@ async def get_pipeline_stats(
return {
"total_photos": total_photos,
"total_images": total_images,
"embedder_model": embedder_model,
"stages": stages,
}

View File

@@ -48,6 +48,7 @@ async def list_photos(
color_label: Optional[str] = None,
is_discarded: Optional[bool] = False,
is_duplicate: Optional[bool] = None,
needs_review: Optional[bool] = None,
has_date_warning: Optional[bool] = None,
heap_id: Optional[str] = None,
sort: str = "taken_at",
@@ -196,6 +197,8 @@ async def list_photos(
# view shows everything regardless of duplicate status.
if is_duplicate is not None:
filters.append(Photo.is_duplicate == is_duplicate)
if needs_review is not None:
filters.append(Photo.needs_review == needs_review)
if has_date_warning is not None:
filters.append(Photo.has_date_warning == has_date_warning)

View File

@@ -25,13 +25,7 @@ class SearchRequest(BaseModel):
@router.post("")
async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Unified search endpoint. Every query runs hybrid (FTS + semantic)
by default — the user never picks a mode.
Filters:
- tag_ids: list of tag IDs (any kind: user, object, face_cluster)
- date_from / date_to: ISO date strings
"""
"""FTS search over photo metadata with optional tag and date filters."""
filters = body.filters or {}
results = await hybrid_search(

View File

@@ -1,10 +1,8 @@
"""
Tags API router.
Unified across user tags, ML-detected objects, and face clusters via
the `kind` query parameter. Default behaviour (no kind filter) returns
all tags — the frontend's "Hide auto-generated tags" toggle filters
client-side or passes `kind=user`.
Unified across user tags and the binary content-type classifier
('photography' | 'other') via the `kind` column.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -34,15 +32,11 @@ class TagUpdate(BaseModel):
color: Optional[str] = None
class TagMerge(BaseModel):
target_id: str # tag to merge INTO
# ── Endpoints ─────────────────────────────────────────────────────────────
@router.get("")
async def list_tags(
kind: Optional[str] = Query(None, description="Filter by kind: user, object, scene, face_cluster"),
kind: Optional[str] = Query(None, description="Filter by kind: user, content_type"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -90,7 +84,7 @@ async def list_tags(
"color": tag.color,
"kind": tag.kind,
"source": tag.source,
"representative_photo_id": tag.representative_photo_id or first_photo_id,
"representative_photo_id": first_photo_id,
"photo_count": int(count or 0),
}
for tag, count, first_photo_id in rows
@@ -130,7 +124,7 @@ async def update_tag(
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Rename or recolor a tag (works for any kind — user, object, face_cluster)."""
"""Rename or recolor a tag."""
result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))
tag = result.scalar_one_or_none()
if not tag:
@@ -149,52 +143,6 @@ async def update_tag(
return {"id": tag.id, "name": tag.name, "color": tag.color, "kind": tag.kind}
@router.post("/{tag_id}/merge")
async def merge_tag(
tag_id: str, body: TagMerge, db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Merge tag_id INTO target_id. All photo associations from the source
tag are moved to the target, then the source tag is deleted.
Useful for merging auto-detected face clusters (e.g. "Person 3""Alice")
or merging duplicate object labels."""
if tag_id == body.target_id:
raise HTTPException(status_code=400, detail="Cannot merge a tag into itself")
source = (await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))).scalar_one_or_none()
target = (await db.execute(select(Tag).where(Tag.id == body.target_id, Tag.user_id == current_user.id))).scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Source tag not found")
if not target:
raise HTTPException(status_code=404, detail="Target tag not found")
# Move photo associations: update tag_id from source → target.
# Skip any that would violate the PK (photo already tagged with target).
existing_target_photos = select(photo_tags.c.photo_id).where(
photo_tags.c.tag_id == body.target_id
)
await db.execute(
update(photo_tags)
.where(
photo_tags.c.tag_id == tag_id,
photo_tags.c.photo_id.notin_(existing_target_photos),
)
.values(tag_id=body.target_id)
)
# Delete remaining source associations (duplicates that couldn't move)
from sqlalchemy import delete as sa_delete
await db.execute(
sa_delete(photo_tags).where(photo_tags.c.tag_id == tag_id)
)
# Delete source tag
await db.delete(source)
await db.commit()
return {"merged_into": target.id, "target_name": target.name}
@router.delete("/{tag_id}", status_code=204)
async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Delete a tag. Photo associations cascade-delete via the FK."""