diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index cd93804..ca2a7df 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -289,6 +289,75 @@ def _gallery_images(rows, artists: dict[int, dict]) -> list[GalleryImage]: ] +def _diversify_similar(src, rows, limit, *, dup_threshold=6, lam=0.55): + """Trim a nearest-cosine candidate pool down to `limit` diverse picks. + + 1. pHash collapse: drop any candidate whose perceptual hash is within + `dup_threshold` Hamming bits of the anchor or an already-kept candidate — + so a reposted banner (and the anchor's own clones) appears at most once. + 2. MMR (Maximal Marginal Relevance): greedily pick the candidate maximising + `lam * sim_to_anchor - (1 - lam) * max_sim_to_already_picked`. This keeps + the most relevant up top but pushes the selection to SPAN clusters + instead of returning 40 variations of one image. + + Falls back to nearest-order (`rows[:limit]`) on any failure or a small pool. + """ + if len(rows) <= 1: + return rows[:limit] + try: + import imagehash + import numpy as np + except Exception: + return rows[:limit] + + # --- 1. pHash near-duplicate collapse (videos/NULL phash pass through) --- + kept = [] + seen = [] + if src.phash: + try: + seen.append(imagehash.hex_to_hash(src.phash)) + except Exception: + pass + for row in rows: + ph = row[0].phash + if ph: + try: + h = imagehash.hex_to_hash(ph) + if any((h - k) <= dup_threshold for k in seen): + continue + seen.append(h) + except Exception: + pass + kept.append(row) + if len(kept) <= limit: + return kept + + # --- 2. MMR re-rank on the L2-normalised SigLIP embeddings --- + try: + a = np.asarray(src.siglip_embedding, dtype=np.float32) + a = a / (np.linalg.norm(a) or 1.0) + V = np.vstack([ + np.asarray(row[0].siglip_embedding, dtype=np.float32) for row in kept + ]) + V = V / np.clip(np.linalg.norm(V, axis=1, keepdims=True), 1e-8, None) + except Exception: + return kept[:limit] + + rel = V @ a # (N,) cosine to the anchor + n = len(kept) + picked_mask = np.zeros(n, dtype=bool) + max_sim = np.zeros(n, dtype=np.float32) # max sim to anything picked yet + order = [] + for _ in range(min(limit, n)): + scores = lam * rel - (1.0 - lam) * max_sim + scores[picked_mask] = -np.inf + i = int(np.argmax(scores)) + order.append(i) + picked_mask[i] = True + max_sim = np.maximum(max_sim, V @ V[i]) + return [kept[i] for i in order] + + async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]: """Map image_id -> {"name","slug"} via the canonical image_record.artist_id (FC-2d-vii-c). Bounded by page size.""" @@ -565,14 +634,20 @@ class GalleryService: untagged: bool = False, no_artist: bool = False, date_from: datetime | None = None, date_to: datetime | None = None, ) -> list[GalleryImage] | None: - """Visual "more like this": images ranked by cosine distance to - `image_id`'s SigLIP embedding (pgvector, HNSW-indexed — alembic 0036). - No ML inference here; the embedding was computed at import. + """Visual "more like this": images near `image_id`'s SigLIP embedding + (pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result + doesn't collapse into one cluster. No ML inference here. - Returns None if the source image doesn't exist (→ 404), [] if it has - no embedding (a video / not-yet-embedded). Composes with the Phase-1/2 - scope filters (AND) but REPLACES the date sort — always nearest-first, - bounded to `limit` (no cursor; distance-ranking has no date cursor). + Pure nearest-cosine piles up near-identical images — a reposted banner + fills the whole grid, and once you wander into a B&W / comic-panel + cluster every neighbour is more of the same with no way back to colour + (operator-reported 2026-06-30). So we pull a WIDER candidate pool, then: + 1. collapse near-duplicate pHashes (and drop clones of the anchor), + 2. MMR re-rank — pick for closeness-to-anchor but penalise similarity + to what's already picked, so the result SPANS clusters. + + Returns None if the source doesn't exist (→ 404), [] if it has no + embedding. Composes with the scope filters (AND); REPLACES the date sort. """ if limit < 1 or limit > 200: raise ValueError("limit must be between 1 and 200") @@ -582,6 +657,9 @@ class GalleryService: if src.siglip_embedding is None: return [] + # Over-fetch so diversification has clusters to spread across — without a + # wide pool there's nothing but the near-dupes to choose from. + pool_n = min(200, max(limit * 5, 60)) distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding) eff = _effective_date_col() stmt = select(ImageRecord, Post.post_date, eff.label("eff")) @@ -597,8 +675,9 @@ class GalleryService: platform=platform, untagged=untagged, no_artist=no_artist, date_from=date_from, date_to=date_to, ) - stmt = stmt.order_by(distance.asc()).limit(limit) + stmt = stmt.order_by(distance.asc()).limit(pool_n) rows = (await self.session.execute(stmt)).all() + rows = _diversify_similar(src, rows, limit) artists = await _artists_for(self.session, [r[0].id for r in rows]) return _gallery_images(rows, artists) diff --git a/frontend/src/stores/explore.js b/frontend/src/stores/explore.js index 1bb269f..8b5c45f 100644 --- a/frontend/src/stores/explore.js +++ b/frontend/src/stores/explore.js @@ -93,13 +93,11 @@ export const useExploreStore = defineStore('explore', () => { // a crumb (which snaps the cursor back into the trail — the "loops back" // report). Fall back to the full set only if every neighbour's been seen. const seen = new Set(breadcrumb.value.map((c) => c.id)) - let pool = neighbors.value.filter((n) => !seen.has(n.id)) - if (!pool.length) pool = neighbors.value - // neighbors come similarity-sorted (nearest first). Skip the closest slice — - // those near-duplicates are exactly what you get stuck cycling through — and - // pick from the more-varied remainder, for real variance in the walk. - const skip = pool.length >= 6 ? Math.floor(pool.length / 3) : 0 - const cands = pool.slice(skip) + const pool = neighbors.value.filter((n) => !seen.has(n.id)) + const cands = pool.length ? pool : neighbors.value + // The list is already pHash-deduped + MMR-diversified server-side (it spans + // clusters, not 40 near-dupes), so a plain random pick gives real variance — + // no need to skip the nearest slice the way the raw nearest-list required. return cands[Math.floor(Math.random() * cands.length)].id } diff --git a/tests/test_gallery_similar.py b/tests/test_gallery_similar.py index 07ae149..ea9b355 100644 --- a/tests/test_gallery_similar.py +++ b/tests/test_gallery_similar.py @@ -87,6 +87,29 @@ async def test_similar_composes_with_tag_filter(db): assert [i.id for i in res] == [tagged.id] # scope AND-narrows the ranked set +@pytest.mark.asyncio +async def test_similar_collapses_near_duplicate_phashes(db): + # The reported failure: a reposted image fills the whole neighbour grid. + # A wall of same-pHash reposts must collapse to at most one, and the + # genuinely distinct images must still come through. + src = await _img(db, 1, _vec(1, 0)) + dupes = [] + for n in range(2, 7): # 5 near-identical reposts + r = await _img(db, n, _vec(1, 0.01 * n)) + r.phash = "ffffffffffffffff" # identical perceptual hash + dupes.append(r) + distinct_a = await _img(db, 7, _vec(1, 1)) + distinct_a.phash = "0000000000000000" + distinct_b = await _img(db, 8, _vec(0, 1)) + distinct_b.phash = "0f0f0f0f0f0f0f0f" + await db.flush() + + res = await GalleryService(db).similar(src.id, limit=10) + ids = [i.id for i in res] + assert sum(1 for d in dupes if d.id in ids) <= 1 # repost wall collapsed + assert distinct_a.id in ids and distinct_b.id in ids + + @pytest.mark.asyncio async def test_similar_respects_limit(db): src = await _img(db, 1, _vec(1, 0))