Files
mule-image/backend/app/services/duplicates.py
dtoro 574d71371f 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>
2026-04-14 22:27:17 +02:00

299 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)
)