Skip to content

Cluster large galleries, and explain the clustering - #300

Open
lstein wants to merge 15 commits into
mainfrom
diag/image-map-clustering
Open

lstein wants to merge 15 commits into
mainfrom
diag/image-map-clustering

Conversation

@lstein

@lstein lstein commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

A gallery of ~170,000 images rendered a plausible UMAP with every point marked "unclustered", and nothing in the response said why.

Root cause: MAX_CLUSTERED_POINTS = 50_000 skipped DBSCAN entirely above that count and returned an all--1 label array. Per-image labels come from a different endpoint, which is why those kept working and hid it.

This PR makes the clustering explain itself, raises the cap so a six-figure gallery clusters at all, replaces the eps heuristic with one that does not leave a third of the map as noise, and adds a control for tuning it.

Before / after on a 170k-point map (synthetic UMAP-shaped fixture, 260 blobs of varying density):

before after
Unclustered 100% (clustering skipped) 4.2%
Clusters 0 218
Largest cluster 1.8% of the map
  • Diagnostics. GET /points logs one greppable line naming the gate that produced the outcome — the point cap, the eps resolution chain (adaptive_eps, coord_span, span_clamped_eps, floored_eps, resolved_eps, neighbor_pairs), and the resulting cluster histogram. All-noise outcomes log at INFO, healthy ones at debug; deduped per user and re-armed after 10 minutes so it can be reproduced on demand.
  • Cap raised to 300k, so the reported gallery clusters. The neighbour-pair budget, not the cap, is what bounds DBSCAN's memory.
  • Adaptive eps now walks the k-distance quantiles (ported from PhotoMapAI) instead of taking the median. The median makes about half the points core points by construction, which measured 29–35% noise on a structured 170k map.
  • "Clustering strength" control in the Image Map settings, showing the derived value until the user changes it; clearing it returns to the heuristic.
  • Footer readout: Cluster count: N, Largest cluster: X media points, Unclustered: Z media points beside the point count.

QA Instructions

All commands run from the worktree.

uv tool run ruff@0.11.2 check invokeai tests                      # clean
python -m pytest tests/app/services/image_index \
                 tests/app/routers/test_image_map.py -q           # 271 passed
pnpm -C invokeai/frontend/webv2 run lint                          # format + oxlint + tsc + architecture (70)
pnpm -C invokeai/frontend/webv2 exec vitest run \
  --config vitest.config.mts src/workbench/image-map              # 124 passed
pnpm -C invokeai/frontend/webv2 exec vitest run \
  --config vitest.browser.config.mts src/workbench/widgets/image-map  # 74 passed

Manual check: open the Image Map on a gallery large enough to cluster, read the footer, then change Clustering strength in the widget's settings and watch the map recluster. Clear the field and confirm it refills with the derived value after a moment.

Measurements (this machine; synthetic fixtures, not the reporting user's real projection — cluster counts on real data will differ):

Clustering 170k points 6.1s, 141MB peak, 2.4M neighbour pairs against the 50M budget
Same before the quantile scan ~2.0s (the scan probes up to six DBSCAN fits)
label_clusters at 170k / 768 dims 0.94s
Resolving a requested eps of 2.0, dense 150k map 73s → 4.0s (chunked pair counting), same resolved eps
Noise fraction, median heuristic → quantile scan 29–35% → 3.7–4.4% across four fixture shapes

Not verified: behaviour on the reporting user's actual 170k library, and the map has not been exercised against a live backend at that scale — only against synthetic coordinates and the mock backend.

Two deliberate consequences worth a reviewer's eye:

  • /cluster_labels now actually runs at this scale, where the old cap made it a no-op. It reads the accessible embedding matrix (~0.5GB at 170k × 768) and the LRU holds two. One redundant full-matrix copy is removed in this PR; the per-cluster gathers remain and set the peak.
  • On a small, genuinely structureless map, the looser span clamp plus the "nothing passed" fallback can now render one large cluster where the old clamp produced all noise.

Review

Five independent read-only review passes (correctness/spec, architecture/operational safety/performance, test value/product quality, and two blocker-only rounds). Material findings resolved:

  • The binary search over eps candidates was unsound. I had optimised the quantile walk on the claim that top-cluster share is monotone in eps. It is not — border points get reassigned to a different cluster as eps grows, so the largest cluster can shrink. Review produced a 412-point map where the search picked an eps 1.3× looser than the documented rule. Reverted to the faithful walk; the regression test is that map, and it fails for any implementation that takes the loosest passing candidate.
  • A requested eps of 2.0 cost 73s of server CPU on a dense map: thirteen full passes counting billions of neighbour pairs, only to reject each. Pair counting now abandons a radius once it passes the budget. Same resolved eps, verified by differential testing.
  • Log volume: the diagnostics line fired at INFO on every clustering, including healthy ones, and a bug in the dedupe's LRU meant that past 32 concurrent users it degraded into per-request logging.
  • Clearing the strength field refilled it under the caret, so the next keystrokes appended and the map reclustered at a number nobody typed.
  • Changing the strength left the previous clustering's labels looking valid, because renumbering does not move visibleHash.
  • A stored eps above the endpoint's maximum would have 422'd every refresh with no way back through the UI.

Behaviour-preservation for the clustering refactor was established by differential testing against origin/main — 12,420 parameter combinations comparing resolved eps and full label arrays, zero mismatches — and each new test was checked by mutating the mechanism it names.

Compatibility / Rollout

No behavioural API or persisted-data changes: eps was already a query parameter, and the strength is stored in existing widget instance values (absent means "derive it"). A stored value outside the endpoint's range falls back to the heuristic rather than failing every request.

invokeai/frontend/web/openapi.json and the legacy schema.ts are regenerated, because the eps parameter's description was reworded — the old text said the value is clamped to the coordinate span, which stopped being true for a supplied eps. The regenerated diff is those description lines and nothing else. (I originally claimed no artifacts needed regenerating; openapi-checks and typegen-checks correctly disagreed.)

MAX_CLUSTERED_POINTS = 300_000 raises the cluster cache's worst case from ~13MB to ~77MB across its 32-user pool; if the cap stays this high the pool size wants revisiting.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Meaningful regression coverage added / updated where needed; obsolete tests/code removed
  • Persisted-state and API changes include required migrations / compatibility validation
  • Relevant performance/efficiency opportunities considered; material claims have evidence
  • Material review findings resolved and relevant checks rerun
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

🤖 Generated with Claude Code

lstein and others added 7 commits September 20, 2026 19:21
/points logs the clustering's diagnostics — point count against the cap,
the full eps resolution chain, the neighbor-pair budget, and the resulting
cluster histogram — so an all-noise map can be explained from one line.
All-noise outcomes log at INFO, healthy ones at debug; deduped per user and
re-armed after 10 minutes.

The map footer now reads the cluster count, largest, smallest and
unclustered totals beside the point count, derived from the served points.

Folds the resolve-then-cluster sequence into one cluster_with_diagnostics,
dropping a redundant KD-tree pass per uncached request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Raises MAX_CLUSTERED_POINTS from 50k so a 170k-item gallery clusters at all
and its cost can be measured; the neighbor-pair budget still bounds DBSCAN's
memory. Measured at 170k: 2.0s, 141MB peak, 2.4M neighbor pairs against the
50M budget.

Smallest cluster can never be below min_samples, so the footer reported a
constant. Removed from the readout and from the stats behind it; the backend
diagnostic keeps it, where the floor is worth seeing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A spinner in the Image Map settings holds the DBSCAN eps. It shows the
value the server derived until the user changes it, and clearing it hands
the choice back to the heuristic after a short debounce.

The span clamp no longer applies to an eps the caller supplied — clamping a
number the user typed would silently retune it, and on a small map it would
override most of the control's range. The pair budget still applies to
both, since that one bounds memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cluster-labels endpoint strips noise rows before calling label_clusters,
so its own noise mask selected every row and copied the whole matrix — 522MB
on a 170k-item gallery. Reachable at that size only since the clustering cap
was raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports PhotoMapAI's rule: instead of taking the median k-distance, candidate
quantiles are walked upward and the loosest one whose largest cluster stays
under a quarter of the map is chosen. The median makes about half the points
core points by construction, which measured 29-35% noise on a 170k-point
map; the scan measures 3.7-4.4% on the same fixtures.

The span clamp moves 0.05 -> 0.25 with it. 0.05 suited a median and would
override the scan's answer outright on a map of well-separated blobs.

The boundary is binary-searched rather than walked, since the top-cluster
share is monotone in eps: 1 fit instead of 6 when nothing blobs, which keeps
a 170k recluster at 2.8-4.0s against 8-9.5s for the plain walk. A test pins
the search to the walk's answer across boundary positions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntrol

Counting neighbour pairs now abandons a radius once it passes the budget,
so resolving a requested eps of 2.0 on a dense 150k-point map costs 4.0s
instead of 73s — thirteen full passes counted billions of pairs only to
reject every one. Same resolved eps.

Clearing the box refilled it with the heuristic while the caret was still
inside, so the next keystrokes appended and the map clustered at a number
nobody typed; the refill now selects what it writes. Cluster labels are
retired when the strength changes, since renumbering leaves visibleHash
untouched and the hover card would otherwise name another clustering's
tags. A stored eps above the endpoint's maximum no longer 422s every
refresh, an edit survives the dialog closing, and a value with more digits
than the box shows is no longer truncated under the caret.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The top-cluster share is not monotone in eps, so the passing candidates are
not a prefix and the binary search was unsound. Raising eps grows every
core-point cluster, but a border point can be claimed by another cluster
that has just come into reach, and the largest cluster loses it — measured
on ~10% of random blob maps, and at the candidate quantiles themselves on
16 of 16,000. The search then read a pass after a failure and kept climbing,
choosing an eps 1.3x looser than the rule on some maps and none on others.

Walking costs 6.1s instead of 3.1s on a 170k map, once per gallery change.
The regression test is a map whose share genuinely dips, and it fails for
any implementation that takes the loosest passing candidate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rewording the `eps` query parameter's description changed the published
schema, which openapi-checks and typegen-checks compare against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ring

# Conflicts:
#	invokeai/frontend/webv2/src/workbench/image-map/imageMapStore.ts
#	invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetFooter.tsx
@lstein
lstein enabled auto-merge September 23, 2026 20:13
@lstein
lstein disabled auto-merge September 24, 2026 00:16
@lstein
lstein enabled auto-merge September 24, 2026 00:17
…yboard

The clustering-strength tests typed through Playwright's keyboard and waited out real debounces for ~12s beside other browser test files; whole-value edits now set the input directly on a fake clock, and only keystroke-level cases type.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant