""" Duplicate detection: group photos by perceptual-hash + CLIP similarity. Strategy -------- Two complementary signals are fused into a single grouping: 1. **Perceptual hash (pHash)** — 16-char hex hash from the thumbnail worker. Catches byte-identical copies and mild re-encodes via Hamming distance (threshold ≤ 6 bits out of 64). 2. **CLIP embedding similarity** — cosine distance over 512-d vectors stored in the `embeddings` table with an HNSW index. Catches visually similar photos even when pHash diverges (e.g. crops, different formats, screenshots of the same content). Both signals feed a union-find structure that merges overlapping matches into connected components. Incremental mode (default post-scan) ------------------------------------- `incremental_regroup` only compares *newly added* photos (those whose `added_at` > watermark) against the entire library. Each new photo does: - An HNSW vector similarity query: O(log N) via the index. - A pHash comparison against a small candidate set (same group members or nearby CLIP results) rather than the full N² sweep. This makes the post-scan cost O(new × log N) instead of O(N²). Full regroup ------------ `regroup_duplicates` still performs the full pairwise pHash pass + CLIP sweep, used for initial setup and manual re-detection. """ from __future__ import annotations import logging import uuid from datetime import datetime, timezone from typing import Optional from sqlalchemy import select, update from app.database import AsyncSessionLocal from app.models.photos import Photo logger = logging.getLogger(__name__) # pHash Hamming distance threshold (6 out of 64 bits). DEFAULT_PHASH_THRESHOLD = 6 def _hex_to_int(h: str) -> int: """Parse a 16-char hex pHash to a Python int. Returns -1 on bad input so the pairwise loop can skip the row without raising.""" try: return int(h, 16) except (TypeError, ValueError): return -1 def _hamming(a: int, b: int) -> int: """Population count of XOR — the canonical hash distance metric.""" x = a ^ b try: return x.bit_count() # type: ignore[attr-defined] except AttributeError: return bin(x).count('1') class _UnionFind: """Tiny union-find / disjoint-set used to merge similar photos into connected components.""" 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.rank = [0] * n def find(self, x: int) -> int: while self.parent[x] != x: self.parent[x] = self.parent[self.parent[x]] x = self.parent[x] return x def union_by_key(self, key_a: str, key_b: str) -> None: 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: return if self.rank[ra] < self.rank[rb]: ra, rb = rb, ra self.parent[rb] = ra if self.rank[ra] == self.rank[rb]: 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( phash_threshold: int = DEFAULT_PHASH_THRESHOLD, **_ignored, ) -> dict: """Full recompute of duplicate groups using pHash similarity. Idempotent — safe to call as often as you like. Returns a summary dict. """ async with AsyncSessionLocal() as session: # Pull all visible photos with a phash or embedding. rows = ( await session.execute( select(Photo.id, Photo.phash) .where(Photo.is_discarded.is_(False)) .where(Photo.is_hidden.is_(False)) ) ).all() if not rows: await _clear_all_groups(session) await session.commit() return {'photos_considered': 0, 'groups': 0, 'members': 0} ids = [row[0] for row in rows] phash_map = {row[0]: _hex_to_int(row[1]) for row in rows if row[1]} uf = _UnionFind(ids) # ── Phase 1: pHash pairwise (O(N²) on photos with phash) ── 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): hi = phash_vals[i] if hi < 0: continue for j in range(i + 1, n): hj = phash_vals[j] if hj < 0: continue if _hamming(hi, hj) <= phash_threshold: uf.union_by_key(phash_ids[i], phash_ids[j]) # ── Write results ── await _clear_all_groups(session) groups = uf.components(ids) groups_created = 0 members_total = 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) await session.commit() logger.info( f"regroup_duplicates: {len(ids)} photos, " f"{groups_created} group(s), {members_total} member(s)" ) return { 'photos_considered': len(ids), 'groups': groups_created, 'members': members_total, } async def incremental_regroup( since: Optional[datetime] = None, phash_threshold: int = DEFAULT_PHASH_THRESHOLD, **_ignored, ) -> dict: """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: # 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) # Photo.added_at is stored as TIMESTAMP WITHOUT TIME ZONE, so # asyncpg rejects aware datetimes with "can't subtract offset-naive # and offset-aware". Normalise: if `since` has a tzinfo, convert # it to UTC and drop the tzinfo so the bind parameter is naive. if since.tzinfo is not None: since = since.astimezone(timezone.utc).replace(tzinfo=None) # 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) # ── 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 _clear_all_groups(session) -> None: """Reset duplicate_group_id / is_duplicate on every photo.""" await session.execute( update(Photo).values(duplicate_group_id=None, is_duplicate=False) )