fix(explore): diversify "more like this" so it stops getting stuck (#1188)
Pure nearest-cosine piled near-identical images into the neighbour grid — a reposted banner filled all 24 slots, and once you wandered into a B&W / comic-panel cluster every neighbour was more of the same with no way back to colour without the Random button (operator-reported, with screenshot). similar() now over-fetches a wide candidate pool (5x the requested limit, cap 200), then diversifies down to `limit`: - pHash near-duplicate collapse: drop candidates within 6 Hamming bits of the anchor or an already-kept candidate, so a repost (and the anchor's own clones) appears at most once. - MMR re-rank: greedily pick for closeness-to-anchor minus similarity-to-already -picked (lambda 0.55), so the result SPANS clusters instead of returning 40 variations of one image. Falls back to nearest-order on any failure / small pool, so existing nearest-first behaviour is unchanged when there's nothing to diversify. Frontend forwardTarget drops the now-redundant skip-nearest-third hack (the list is already diversified server-side) — plain random-over-unvisited gives the variance now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user