Conversation
/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>
lstein
requested review from
JPPhoto,
Pfannkuchensack and
blessedcoolant
as code owners
September 21, 2026 01:16
7 tasks
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
7 tasks
lstein
enabled auto-merge
September 23, 2026 20:13
7 tasks
lstein
disabled auto-merge
September 24, 2026 00:16
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_000skipped DBSCAN entirely above that count and returned an all--1label 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):
GET /pointslogs 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.Cluster count: N, Largest cluster: X media points, Unclustered: Z media pointsbeside the point count.QA Instructions
All commands run from the worktree.
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):
label_clustersat 170k / 768 dimsNot 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_labelsnow 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.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:
visibleHash.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:
epswas 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.jsonand the legacyschema.tsare regenerated, because theepsparameter'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-checksandtypegen-checkscorrectly disagreed.)MAX_CLUSTERED_POINTS = 300_000raises 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
What's Newcopy (if doing a release after this PR)🤖 Generated with Claude Code