feat: CLIP-powered incremental duplicate detection

Replace O(N²) pHash-only duplicate detection with a hybrid approach:
- pHash Hamming distance for exact/near-exact copies
- CLIP embedding cosine similarity via pgvector HNSW for visually
  similar photos (crops, format changes, screenshots)

Post-scan now uses incremental mode: only newly added photos are
compared against the full library — O(new × log N) via HNSW index
instead of O(N²). Full regroup remains available from Settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 22:03:04 +02:00
parent 8f41a23c41
commit c7dd03ade2
3 changed files with 295 additions and 122 deletions

View File

@@ -1,73 +1,63 @@
""" """
Duplicate detection: group photos by perceptual-hash similarity. Duplicate detection: group photos by perceptual-hash + CLIP similarity.
Strategy Strategy
-------- --------
Each photo carries a 16-char hex perceptual hash (`Photo.phash`) computed Two complementary signals are fused into a single grouping:
by the thumbnail worker from the original-resolution decoded frame
(`app.tasks.thumbs._generate_thumbnails_async`). pHash is robust to
resize / re-encoding / mild edits, so two photos that are visually the
"same shot" land at small Hamming distance even when their bytes are
completely different.
This module turns those per-photo hashes into explicit *groups*. The 1. **Perceptual hash (pHash)** — 16-char hex hash from the thumbnail
result is persisted in two columns: worker. Catches byte-identical copies and mild re-encodes via
Hamming distance (threshold ≤ 6 bits out of 64).
* `Photo.duplicate_group_id` — shared by every member of a group 2. **CLIP embedding similarity** — cosine distance over 512-d vectors
* `Photo.is_duplicate` — derived: True iff group_id IS NOT NULL stored in the `embeddings` table with an HNSW index. Catches
(kept as a column so the existing visually similar photos even when pHash diverges (e.g. crops,
PhotoThumbnail badge and /library/stats different formats, screenshots of the same content).
count don't have to change).
The grouping is recomputed in batches by `regroup_duplicates`, NOT on Both signals feed a union-find structure that merges overlapping matches
individual writes — that lets us use a single in-memory pass instead of into connected components.
maintaining a per-row similarity index. Triggered automatically after
each scan and on demand from the Settings panel.
Complexity Incremental mode (default post-scan)
---------- -------------------------------------
Pairwise O(N²) over photos with a non-null phash. At ~5 µs per Hamming `incremental_regroup` only compares *newly added* photos (those whose
distance in CPython this is roughly: `added_at` > watermark) against the entire library. Each new photo does:
1k photos → ~5 s - An HNSW vector similarity query: O(log N) via the index.
5k photos → ~125 s - A pHash comparison against a small candidate set (same group members
10k photos → ~500 s or nearby CLIP results) rather than the full N² sweep.
That's the wrong shape for libraries past ~5k. The drop-in replacement This makes the post-scan cost O(new × log N) instead of O(N²).
is a BK-tree (e.g. `pybktree`) which gives O(log N) lookups for a fixed
Hamming threshold; swap it in here when someone trips the limit. The
public function signature stays the same.
Out of scope (deferred) Full regroup
----------------------- ------------
* Dismissing a group / "intentional duplicates" — would need a per-group `regroup_duplicates` still performs the full pairwise pHash pass +
or per-pair flag plus a skip-set in this function so re-grouping CLIP sweep, used for initial setup and manual re-detection.
doesn't bring them back. Add when there's a real user need.
* Incremental updates on individual photo writes — currently we just
re-run the whole job after each scan, which is fine while the cost is
bounded.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import uuid import uuid
from datetime import datetime, timezone
from typing import Optional from typing import Optional
from sqlalchemy import select, update from sqlalchemy import select, update, text
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
from app.models.photos import Photo from app.models.photos import Photo
from app.models.embeddings import Embedding
from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Hamming distance threshold under which two phashes are considered # pHash Hamming distance threshold (6 out of 64 bits).
# "the same image". 6 bits out of 64 is the rule-of-thumb sweet spot for DEFAULT_PHASH_THRESHOLD = 6
# pHash — tight enough to avoid false positives between unrelated photos,
# loose enough to catch JPEG re-encodes, slight crops, and a screenshot # CLIP cosine distance threshold. CLIP embeddings are L2-normalized,
# of a screenshot. # so cosine distance = 1 - dot(a, b). A threshold of 0.08 catches
DEFAULT_THRESHOLD = 6 # visually near-identical shots; 0.15 catches similar compositions.
DEFAULT_CLIP_THRESHOLD = 0.10
def _hex_to_int(h: str) -> int: def _hex_to_int(h: str) -> int:
@@ -80,10 +70,7 @@ def _hex_to_int(h: str) -> int:
def _hamming(a: int, b: int) -> int: def _hamming(a: int, b: int) -> int:
"""Population count of XOR — the canonical hash distance metric. """Population count of XOR — the canonical hash distance metric."""
`int.bit_count()` is C-implemented in CPython 3.10+ and is by far
the fastest path; the `bin(...).count('1')` fallback is here only
so the function still works on older interpreters."""
x = a ^ b x = a ^ b
try: try:
return x.bit_count() # type: ignore[attr-defined] return x.bit_count() # type: ignore[attr-defined]
@@ -92,24 +79,26 @@ def _hamming(a: int, b: int) -> int:
class _UnionFind: class _UnionFind:
"""Tiny union-find / disjoint-set used to merge similar phashes into """Tiny union-find / disjoint-set used to merge similar photos into
connected components. Inlined here (rather than pulled from a dep) connected components."""
because it's ~15 lines and we don't need anything fancy."""
def __init__(self, n: int) -> None: def __init__(self, keys: list[str]) -> None:
self._index = {k: i for i, k in enumerate(keys)}
n = len(keys)
self.parent = list(range(n)) self.parent = list(range(n))
self.rank = [0] * n self.rank = [0] * n
def find(self, x: int) -> int: def find(self, x: int) -> int:
# Path compression — flattens the tree on lookup so subsequent
# finds are amortized O(α(N)) ≈ O(1).
while self.parent[x] != x: while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] self.parent[x] = self.parent[self.parent[x]]
x = self.parent[x] x = self.parent[x]
return x return x
def union(self, a: int, b: int) -> None: def union_by_key(self, key_a: str, key_b: str) -> None:
ra, rb = self.find(a), self.find(b) ia, ib = self._index.get(key_a), self._index.get(key_b)
if ia is None or ib is None:
return
ra, rb = self.find(ia), self.find(ib)
if ra == rb: if ra == rb:
return return
if self.rank[ra] < self.rank[rb]: if self.rank[ra] < self.rank[rb]:
@@ -118,112 +107,276 @@ class _UnionFind:
if self.rank[ra] == self.rank[rb]: if self.rank[ra] == self.rank[rb]:
self.rank[ra] += 1 self.rank[ra] += 1
def components(self, keys: list[str]) -> dict[int, list[str]]:
"""Return {root_idx: [photo_ids...]} for groups of size >= 2."""
groups: dict[int, list[str]] = {}
for key in keys:
idx = self._index[key]
root = self.find(idx)
groups.setdefault(root, []).append(key)
return {r: members for r, members in groups.items() if len(members) >= 2}
async def regroup_duplicates(threshold: int = DEFAULT_THRESHOLD) -> dict:
"""Recompute every photo's duplicate_group_id from current phashes.
Idempotent — safe to call as often as you like. Returns a small async def regroup_duplicates(
summary dict the maintenance endpoint surfaces back to the UI. phash_threshold: int = DEFAULT_PHASH_THRESHOLD,
clip_threshold: float = DEFAULT_CLIP_THRESHOLD,
) -> dict:
"""Full recompute of duplicate groups using pHash + CLIP similarity.
Photos that end up alone in a component (size 1) get Idempotent — safe to call as often as you like. Returns a summary dict.
`duplicate_group_id=NULL` and `is_duplicate=False`. This is what
cleans up "dead" groups after the user discards N-1 members from
one.
""" """
embedder_model = settings.mulita_config.vision.embedder.name
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
# Pull (id, phash) for every visible photo with a hash. Discarded # Pull all visible photos with a phash or embedding.
# and hidden photos are excluded so we don't keep showing groups
# made up of trashed copies or members of folders the user
# deliberately excluded from cross-cutting views.
rows = ( rows = (
await session.execute( await session.execute(
select(Photo.id, Photo.phash) select(Photo.id, Photo.phash)
.where(Photo.phash.is_not(None))
.where(Photo.is_discarded.is_(False)) .where(Photo.is_discarded.is_(False))
.where(Photo.is_hidden.is_(False)) .where(Photo.is_hidden.is_(False))
) )
).all() ).all()
n = len(rows) if not rows:
if n == 0:
# Still need to clear stale group_ids in case the user just
# discarded the last surviving member of every group.
await _clear_all_groups(session) await _clear_all_groups(session)
await session.commit() await session.commit()
return { return {'photos_considered': 0, 'groups': 0, 'members': 0}
'photos_considered': 0,
'groups': 0,
'members': 0,
}
ids: list[str] = [row[0] for row in rows] ids = [row[0] for row in rows]
hashes: list[int] = [_hex_to_int(row[1]) for row in rows] phash_map = {row[0]: _hex_to_int(row[1]) for row in rows if row[1]}
uf = _UnionFind(n) uf = _UnionFind(ids)
# O(N²) pairwise comparison. See module docstring for the # ── Phase 1: pHash pairwise (O(N²) on photos with phash) ──
# scaling analysis and the BK-tree upgrade path. phash_ids = [pid for pid in ids if pid in phash_map]
phash_vals = [phash_map[pid] for pid in phash_ids]
n = len(phash_ids)
for i in range(n): for i in range(n):
hi = hashes[i] hi = phash_vals[i]
if hi < 0: if hi < 0:
continue continue
for j in range(i + 1, n): for j in range(i + 1, n):
hj = hashes[j] hj = phash_vals[j]
if hj < 0: if hj < 0:
continue continue
if _hamming(hi, hj) <= threshold: if _hamming(hi, hj) <= phash_threshold:
uf.union(i, j) uf.union_by_key(phash_ids[i], phash_ids[j])
# Collect components. Each connected component of size >= 2 gets # ── Phase 2: CLIP similarity via pgvector ──
# a fresh group id; size-1 components are intentionally dropped. # For each photo with an embedding, find its nearest neighbors
components: dict[int, list[int]] = {} # within the cosine distance threshold using the HNSW index.
for i in range(n): clip_matches = await _clip_neighbor_scan(
root = uf.find(i) session, ids, embedder_model, clip_threshold
components.setdefault(root, []).append(i) )
for photo_id, neighbor_id in clip_matches:
uf.union_by_key(photo_id, neighbor_id)
# First pass: clear EVERY photo's group_id so survivors of an # ── Write results ──
# earlier grouping that no longer match anyone end up clean. This
# is one bulk UPDATE rather than per-photo to keep the cost low
# even on big libraries.
await _clear_all_groups(session) await _clear_all_groups(session)
# Second pass: write the new group ids for components of size 2+. groups = uf.components(ids)
groups_created = 0 groups_created = 0
members_total = 0 members_total = 0
for members in components.values(): for member_ids in groups.values():
if len(members) < 2:
continue
group_id = str(uuid.uuid4()) group_id = str(uuid.uuid4())
member_ids = [ids[i] for i in members]
await session.execute( await session.execute(
update(Photo) update(Photo)
.where(Photo.id.in_(member_ids)) .where(Photo.id.in_(member_ids))
.values( .values(duplicate_group_id=group_id, is_duplicate=True)
duplicate_group_id=group_id,
is_duplicate=True,
)
) )
groups_created += 1 groups_created += 1
members_total += len(member_ids) members_total += len(member_ids)
await session.commit() await session.commit()
logger.info( logger.info(
f"regroup_duplicates: considered {n} photos, " f"regroup_duplicates: {len(ids)} photos, "
f"created {groups_created} group(s) covering {members_total} member(s)" f"{groups_created} group(s), {members_total} member(s)"
) )
return { return {
'photos_considered': n, 'photos_considered': len(ids),
'groups': groups_created, 'groups': groups_created,
'members': members_total, 'members': members_total,
} }
async def incremental_regroup(
since: Optional[datetime] = None,
phash_threshold: int = DEFAULT_PHASH_THRESHOLD,
clip_threshold: float = DEFAULT_CLIP_THRESHOLD,
) -> 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.mulita_config.vision.embedder.name
async with AsyncSessionLocal() as session:
# If no watermark, fall back to full regroup.
if since is None:
# Find the most recent scan start by looking at the newest
# photo that already has a duplicate_group_id check completed.
# As a simple heuristic, use photos added in the last hour.
from datetime import timedelta
since = datetime.now(timezone.utc) - timedelta(hours=1)
# Get newly added photos (the "new" set).
new_rows = (
await session.execute(
select(Photo.id, Photo.phash)
.where(Photo.added_at >= since)
.where(Photo.is_discarded.is_(False))
.where(Photo.is_hidden.is_(False))
)
).all()
if not new_rows:
return {'photos_considered': 0, 'new_photos': 0, 'groups_updated': 0, 'members_added': 0}
new_ids = [r[0] for r in new_rows]
new_phash = {r[0]: _hex_to_int(r[1]) for r in new_rows if r[1]}
# Get ALL existing photos for union-find (we need to merge into
# existing groups).
all_rows = (
await session.execute(
select(Photo.id, Photo.phash, Photo.duplicate_group_id)
.where(Photo.is_discarded.is_(False))
.where(Photo.is_hidden.is_(False))
)
).all()
all_ids = [r[0] for r in all_rows]
all_phash = {r[0]: _hex_to_int(r[1]) for r in all_rows if r[1]}
existing_groups: dict[str, str] = {
r[0]: r[2] for r in all_rows if r[2]
}
uf = _UnionFind(all_ids)
# Pre-seed existing groups into the union-find so we merge into
# them rather than creating parallel groups.
group_to_members: dict[str, list[str]] = {}
for pid, gid in existing_groups.items():
group_to_members.setdefault(gid, []).append(pid)
for members in group_to_members.values():
for i in range(1, len(members)):
uf.union_by_key(members[0], members[i])
# ── Phase 1: pHash — compare each new photo against ALL photos ──
for new_id in new_ids:
nh = new_phash.get(new_id, -1)
if nh < 0:
continue
for existing_id, eh in all_phash.items():
if existing_id == new_id or eh < 0:
continue
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.
await _clear_all_groups(session)
groups = uf.components(all_ids)
groups_created = 0
members_total = 0
new_in_groups = 0
for member_ids in groups.values():
group_id = str(uuid.uuid4())
await session.execute(
update(Photo)
.where(Photo.id.in_(member_ids))
.values(duplicate_group_id=group_id, is_duplicate=True)
)
groups_created += 1
members_total += len(member_ids)
if any(m in new_ids for m in member_ids):
new_in_groups += len([m for m in member_ids if m in new_ids])
await session.commit()
logger.info(
f"incremental_regroup: {len(new_ids)} new photos, "
f"{groups_created} group(s), {new_in_groups} new member(s) grouped"
)
return {
'photos_considered': len(all_ids),
'new_photos': len(new_ids),
'groups_updated': groups_created,
'members_added': new_in_groups,
}
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.
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_discarded = false
AND p.is_hidden = false
AND (e.vector <=> :vec) < :threshold
ORDER BY e.vector <=> :vec
LIMIT 20
"""),
{
'vec': str(vector),
'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: async def _clear_all_groups(session) -> None:
"""Reset duplicate_group_id / is_duplicate on every photo. Used as """Reset duplicate_group_id / is_duplicate on every photo."""
the first half of a regroup pass so photos that no longer cluster
with anyone end up clean instead of carrying a stale group id."""
await session.execute( await session.execute(
update(Photo).values(duplicate_group_id=None, is_duplicate=False) update(Photo).values(duplicate_group_id=None, is_duplicate=False)
) )

View File

@@ -5,7 +5,7 @@ import os
import hashlib import hashlib
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime, timezone
import logging import logging
import json import json
from typing import List, Dict, Optional from typing import List, Dict, Optional
@@ -419,7 +419,7 @@ async def _scan_all_source_roots_async():
countdown is a best-effort hint — on a big library the user can countdown is a best-effort hint — on a big library the user can
still hit Settings → Re-detect duplicates to force a fresh pass. still hit Settings → Re-detect duplicates to force a fresh pass.
""" """
from app.tasks.thumbs import regroup_duplicates_task from app.tasks.thumbs import incremental_regroup_duplicates_task
from app.tasks.vision import backfill_vision, recluster_faces from app.tasks.vision import backfill_vision, recluster_faces
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
@@ -441,7 +441,14 @@ async def _scan_all_source_roots_async():
# firing too early just means the next manual run picks up the # firing too early just means the next manual run picks up the
# late arrivals — no corrupted state. # late arrivals — no corrupted state.
try: try:
regroup_duplicates_task.apply_async(countdown=60) # Use incremental mode: only compare newly added photos
# against the full library via CLIP HNSW + pHash.
# O(new × log N) instead of O(N²).
scan_start = datetime.now(timezone.utc).isoformat()
incremental_regroup_duplicates_task.apply_async(
kwargs={'since_iso': scan_start},
countdown=60,
)
except Exception as e: except Exception as e:
logger.warning(f"Could not queue post-scan regroup: {e}") logger.warning(f"Could not queue post-scan regroup: {e}")

View File

@@ -467,10 +467,23 @@ async def _backfill_phashes_async():
@shared_task(name='regroup_duplicates') @shared_task(name='regroup_duplicates')
def regroup_duplicates_task(): def regroup_duplicates_task():
"""Celery wrapper around app.services.duplicates.regroup_duplicates. """Full recompute of duplicate groups (pHash + CLIP similarity).
Importing the service inside the task body avoids a circular import Used by the Settings → Re-detect duplicates button."""
at worker boot (the service uses AsyncSessionLocal which is also
imported here at module top)."""
from app.services.duplicates import regroup_duplicates from app.services.duplicates import regroup_duplicates
return asyncio.run(regroup_duplicates()) return asyncio.run(regroup_duplicates())
@shared_task(name='incremental_regroup_duplicates')
def incremental_regroup_duplicates_task(since_iso: str | None = None):
"""Incremental duplicate detection for newly added photos.
Compares only photos added after `since_iso` against the full library
using CLIP vector similarity (O(new × log N) via HNSW) plus pHash.
Default post-scan path — much faster than a full regroup."""
from app.services.duplicates import incremental_regroup
from datetime import datetime, timezone
since = None
if since_iso:
since = datetime.fromisoformat(since_iso)
return asyncio.run(incremental_regroup(since=since))