Skip to content

Derive a Cluster Strength for albums that have not set one - #370

Open
lstein wants to merge 3 commits into
masterfrom
lstein/feature/adaptive-cluster-eps
Open

Derive a Cluster Strength for albums that have not set one#370
lstein wants to merge 3 commits into
masterfrom
lstein/feature/adaptive-cluster-eps

Conversation

@lstein

@lstein lstein commented Aug 18, 2026

Copy link
Copy Markdown
Owner

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

eps had 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_eps becomes 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_data and /cluster_labels all 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.py draws 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:

album N stored result derived result
public 240 0.07 0 / 100% / 0% 0.491 9 / 17% / 18%
invoke-boards 199 0.40 6 / 49% / 12% 0.530 6 / 27% / 26%
keepers 1658 0.20 44 / 27% / 10% 0.239 27 / 14% / 16%
recent_invoke 19018 0.07 518 / 23% / 1% 0.105 376 / 7% / 11%
family 38202 0.12 458 / 4% / 9% 0.119 460 / 4% / 9%
invoke 85578 0.05 1059 / 14% / 14% 0.051 1034 / 13% / 14%

(clusters / unclustered% / biggest-cluster%)

Two clamps, applied asymmetrically on purpose

  • Neighbour-pair budget (KD-tree, ≤50M pairs) applies to every eps, typed or derived. It is a memory bound: the Cluster Strength control can always ask a six-figure album for a radius it cannot afford, which is an out-of-memory crash rather than a slow response. PhotoMapAI had no equivalent guard before.
  • Span clamp applies to derived values only. A number the user typed is theirs to keep; retuning it silently would make the control lie about what it does.

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

  • 695 backend tests, 584 frontend tests, ruff + eslint + prettier clean.
  • Driven end-to-end through the real app on the 240-image album: 0 clusters / 240 unclustered at the old fixed 0.07 → 9 clusters / 41 unclustered derived, second request served from the memo in 1ms.

🤖 Generated with Claude Code

lstein and others added 3 commits August 17, 2026 23:54
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant