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

@@ -41,12 +41,10 @@ import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select, update, text
from sqlalchemy import select, update
from app.database import AsyncSessionLocal
from app.models.photos import Photo
from app.models.embeddings import Embedding
from app.config import settings
logger = logging.getLogger(__name__)
@@ -54,11 +52,6 @@ logger = logging.getLogger(__name__)
# pHash Hamming distance threshold (6 out of 64 bits).
DEFAULT_PHASH_THRESHOLD = 6
# CLIP cosine distance threshold. CLIP embeddings are L2-normalized,
# so cosine distance = 1 - dot(a, b). A threshold of 0.08 catches
# visually near-identical shots; 0.15 catches similar compositions.
DEFAULT_CLIP_THRESHOLD = 0.10
def _hex_to_int(h: str) -> int:
"""Parse a 16-char hex pHash to a Python int. Returns -1 on bad input
@@ -119,14 +112,12 @@ class _UnionFind:
async def regroup_duplicates(
phash_threshold: int = DEFAULT_PHASH_THRESHOLD,
clip_threshold: float = DEFAULT_CLIP_THRESHOLD,
**_ignored,
) -> dict:
"""Full recompute of duplicate groups using pHash + CLIP similarity.
"""Full recompute of duplicate groups using pHash similarity.
Idempotent — safe to call as often as you like. Returns a summary dict.
"""
embedder_model = settings.vision.embedder.name
async with AsyncSessionLocal() as session:
# Pull all visible photos with a phash or embedding.
rows = (
@@ -162,15 +153,6 @@ async def regroup_duplicates(
if _hamming(hi, hj) <= phash_threshold:
uf.union_by_key(phash_ids[i], phash_ids[j])
# ── Phase 2: CLIP similarity via pgvector ──
# For each photo with an embedding, find its nearest neighbors
# within the cosine distance threshold using the HNSW index.
clip_matches = await _clip_neighbor_scan(
session, ids, embedder_model, clip_threshold
)
for photo_id, neighbor_id in clip_matches:
uf.union_by_key(photo_id, neighbor_id)
# ── Write results ──
await _clear_all_groups(session)
@@ -202,16 +184,9 @@ async def regroup_duplicates(
async def incremental_regroup(
since: Optional[datetime] = None,
phash_threshold: int = DEFAULT_PHASH_THRESHOLD,
clip_threshold: float = DEFAULT_CLIP_THRESHOLD,
**_ignored,
) -> dict:
"""Incremental duplicate detection for newly added photos.
Only photos added after `since` are compared against the full library.
Much faster than a full regroup for post-scan updates:
O(new × log N) via HNSW instead of O(N²).
"""
embedder_model = settings.vision.embedder.name
"""Incremental duplicate detection for newly added photos using pHash."""
async with AsyncSessionLocal() as session:
# If no watermark, fall back to full regroup.
if since is None:
@@ -282,13 +257,6 @@ async def incremental_regroup(
if _hamming(nh, eh) <= phash_threshold:
uf.union_by_key(new_id, existing_id)
# ── Phase 2: CLIP — vector similarity for new photos only ──
clip_matches = await _clip_neighbor_scan(
session, new_ids, embedder_model, clip_threshold
)
for photo_id, neighbor_id in clip_matches:
uf.union_by_key(photo_id, neighbor_id)
# ── Write results ──
# Only update groups that contain at least one new photo.
# Clear all groups first, then rewrite.
@@ -323,73 +291,6 @@ async def incremental_regroup(
}
async def _clip_neighbor_scan(
session,
photo_ids: list[str],
embedder_model: str,
threshold: float,
) -> list[tuple[str, str]]:
"""For each photo in `photo_ids` that has a CLIP embedding, find
neighbors within cosine distance `threshold` using pgvector HNSW.
Returns a list of (photo_id, neighbor_id) pairs.
"""
matches: list[tuple[str, str]] = []
if not photo_ids:
return matches
# Batch: get all embeddings for the target photos.
target_embeddings = (
await session.execute(
select(Embedding.photo_id, Embedding.vector)
.where(Embedding.photo_id.in_(photo_ids))
.where(Embedding.model == embedder_model)
)
).all()
if not target_embeddings:
return matches
# For each target, query nearest neighbors via pgvector.
# We use raw SQL for the <=> cosine distance operator.
for photo_id, vector in target_embeddings:
# pgvector cosine distance: <=> operator
# Find top 20 nearest neighbors within threshold.
# Serialize the vector as "[a,b,c,...]" — pgvector's text
# format uses commas; numpy's default str() joins with spaces
# which Postgres rejects with "invalid input syntax for vector".
if hasattr(vector, 'tolist'):
vec_seq = vector.tolist()
else:
vec_seq = list(vector)
vec_text = '[' + ','.join(f'{float(x):.8f}' for x in vec_seq) + ']'
result = await session.execute(
text("""
SELECT e.photo_id, (e.vector <=> :vec) AS distance
FROM embeddings e
JOIN photos p ON p.id = e.photo_id
WHERE e.model = :model
AND e.photo_id != :pid
AND p.is_trashed = false
AND p.is_hidden = false
AND (e.vector <=> :vec) < :threshold
ORDER BY e.vector <=> :vec
LIMIT 20
"""),
{
'vec': vec_text,
'pid': photo_id,
'model': embedder_model,
'threshold': threshold,
}
)
for row in result.all():
matches.append((photo_id, row[0]))
return matches
async def _clear_all_groups(session) -> None:
"""Reset duplicate_group_id / is_duplicate on every photo."""
await session.execute(