Derive a Cluster Strength for albums that have not set one - #370
Open
lstein wants to merge 3 commits into
Open
Conversation
eps had no album-independent right answer and one fixed number for all of them. UMAP's coordinates have no fixed scale — their span grows as the point count shrinks — so the value that carves a large library into useful clusters labels a small album as entirely unclustered. Measured on a real 240-image album: 0 clusters, 100% unclustered, which reads as a broken semantic map. `umap_eps` becomes nullable, meaning "nobody has chosen one". Those albums now resolve to a value derived from their own coordinates, marked `auto` beside the Cluster Strength field; typing a number stores it and takes over, and clearing the field hands it back — previously there was no way back to a default at all. The rule (backend/cluster_eps.py) is a refinement of the median k-distance heuristic InvokeAI's image map uses. Candidates are quantiles of the k-distance distribution, and the scan takes the largest whose biggest cluster still holds under a quarter of the album. The median alone makes half the points core points by construction, which lands at ~30% unclustered on a large library — much worse than a hand-tuned eps. Walking up to the knee instead reproduced hand-tuned values on real albums (0.119 against a hand-set 0.12 on 38k images, 0.051 against 0.05 on 86k) while fixing the small ones. Also borrowed from that implementation, and worth keeping separate: * The neighbour-pair budget shrinks eps until sklearn's DBSCAN will fit in memory. It applies to every value, typed or derived, because the Cluster Strength control could always ask for a radius a six-figure album cannot afford — an out-of-memory crash, not a slow response. * The span clamp applies to derived values only. A number the user typed is theirs to keep; retuning it silently would make the control lie. Its 0.05 fraction was copied at first and had to be raised: that figure suits a median, and against a scan that deliberately climbs higher it overrode correct answers outright. Deriving costs a k-distance pass plus a handful of DBSCAN fits — 2.4s on 122k points — so it runs in a thread and is memoized beside umap.npz, keyed by a fingerprint of the coordinates so a re-index invalidates it. Verified through the real app on the 240-image album: 0 clusters/240 unclustered at the old fixed 0.07, 9 clusters/41 unclustered derived, with the second request served from the memo in 1ms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…it null A PhotoMapAI predating the nullable umap_eps parses `umap_eps: null` into a non-nullable float field and refuses to load the config at all. The two versions share one config file, so writing a null would stop the older one from starting — an unpleasant surprise for anyone testing this branch beside their normal install, or downgrading after it. Absent and null mean the same thing to this codebase, and absent is what the older one already handles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Adapts the adaptive-eps idea from InvokeAI's image map (
image_index/projection.py), with the rule re-tuned against real PhotoMapAI albums.The problem
epshad one fixed default for every album. UMAP's coordinates have no fixed scale — their span grows as the point count shrinks — so the value that carves a large library into useful clusters labels a small album as entirely unclustered. On a real 240-image album the stored 0.07 produced 0 clusters and 100% unclustered, which reads as a broken semantic map rather than a setting to adjust.What changed
umap_epsbecomes nullable, meaning nobody has chosen one. Those albums resolve to a value derived from their own coordinates, marked auto beside the Cluster Strength field. Typing a number stores it and takes over; clearing the field hands it back — previously a typed value was a one-way door with no way back to a default./get_umap_eps,/umap_dataand/cluster_labelsall resolve through one helper, so the cluster ids the last two return can't refer to different clusterings (the UI joins on them for hover labels).The rule, and why not the median
backend/cluster_eps.pydraws candidates from the k-distance distribution and takes the largest whose biggest cluster still holds under a quarter of the album.The plain median k-distance — the standard heuristic, and what InvokeAI uses — makes half the points core points by construction, which lands at ~30% unclustered on a large library, materially worse than a hand-tuned eps. Walking up to the knee instead reproduces hand-tuning where it exists and fixes it where it is broken:
(clusters / unclustered% / biggest-cluster%)
Two clamps, applied asymmetrically on purpose
The span fraction was copied as 0.05 from InvokeAI and had to be raised to 0.25: that figure suits a median-based rule, and against a scan that deliberately climbs higher it overrode correct answers outright — it clamped a map of five well-separated blobs, and would have tripled the unclustered share on the smallest real album. Tests pin both halves.
Cost
A k-distance pass plus at most six DBSCAN fits: 2.4s on 122k points, 0.6s on 38k. It runs in a thread and is memoized beside
umap.npz, keyed by a fingerprint of the coordinates so a re-index invalidates it. Second request is ~1ms.Downgrade safety
A derived strength is written as an absent key rather than
umap_eps: null. A PhotoMapAI predating the nullable field parses an explicit null into a non-nullable float and refuses to load the whole config — i.e. writing one would stop the older version from starting, on a config file the two share. Absent is what it already handles.Verification
🤖 Generated with Claude Code