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