diff --git a/docs/developer/api.md b/docs/developer/api.md index 085295c4..bfd94146 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -124,7 +124,12 @@ Get UMAP embedding data and DBSCAN cluster labels for an album. - `cluster_min_samples` (int, optional): DBSCAN min_samples **Response:** -- `200 OK`: List of points with `x`, `y`, `index`, `cluster`. +- `200 OK`: `{ "points": [...], "eps": float, "requested_eps": float | null }`. + Each point has `x`, `y`, `index`, `cluster` and `media` (`"image"` or `"video"`). + `eps` is the epsilon the points were actually clustered with; `requested_eps` is the one + asked for (the `cluster_eps` parameter, else the album's stored value), or `null` when it + was derived from the coordinates. `eps` is smaller than `requested_eps` when the album is + too large to cluster at the requested value within the server's memory budget. - `404 Not Found`: If album or embeddings not found. --- diff --git a/photomap/backend/routers/cluster_labels.py b/photomap/backend/routers/cluster_labels.py index c2661c38..ba695eb1 100644 --- a/photomap/backend/routers/cluster_labels.py +++ b/photomap/backend/routers/cluster_labels.py @@ -52,7 +52,9 @@ async def get_cluster_labels( Returns: `{"labels": {"": {"label": str, "alternates": [str, ...], - "score": float}, ...}}`. Cluster `-1` (DBSCAN noise) is omitted. + "score": float}, ...}, "eps": float}`. Cluster `-1` (DBSCAN noise) + is omitted. ``eps`` is the strength actually clustered with, as in + ``/umap_data``'s response. """ # Resolve through the same helper ``routers/umap.py`` uses, against the # same coordinates, so ``/cluster_labels`` and ``/umap_data`` agree for @@ -80,8 +82,10 @@ async def get_cluster_labels( cluster_min_samples=cluster_min_samples, top_k=top_k, ) - # FastAPI will stringify the int cluster IDs in the JSON keys. - return JSONResponse({"labels": labels}) + # FastAPI will stringify the int cluster IDs in the JSON keys. ``eps`` is + # the strength the labels were computed at, which — as for /umap_data — + # can be below the requested one on an album too large to afford it. + return JSONResponse({"labels": labels, "eps": float(cluster_eps)}) @cluster_labels_router.get("/image_label/{album_key}/{index}", tags=["UMAP"]) diff --git a/photomap/backend/routers/umap.py b/photomap/backend/routers/umap.py index 725f4f3e..82de2d9b 100644 --- a/photomap/backend/routers/umap.py +++ b/photomap/backend/routers/umap.py @@ -43,7 +43,14 @@ async def get_umap_data( cluster_min_samples: Min samples parameter for DBSCAN clustering. Returns: - JSONResponse containing a list of points with x, y, index, and cluster ID. + JSONResponse ``{"points": [...], "eps": float, "requested_eps": float | None}``. + Each point carries x, y, index, cluster ID and media type. ``eps`` is + the strength the points were actually clustered with; + ``requested_eps`` is the number that was asked for (the query + parameter, else the album's stored value), or ``None`` when the + strength was derived. The two differ when the album is too large to + cluster at the requested strength within the memory budget, and the + UI has no other way to find that out. """ # Load cached UMAP embeddings (will compute/cache if missing). Threaded # because "compute if missing" is a full UMAP fit, which is minutes on a @@ -59,6 +66,7 @@ async def get_umap_data( # to the wrong blobs. # Threaded: deriving an eps runs several DBSCAN fits, seconds of CPU on a # large album, and this endpoint is fetched while the map is opening. + requested_eps = cluster_eps if cluster_eps is not None else album_config.umap_eps cluster_eps = await asyncio.to_thread( resolve_album_cluster_eps, umap_embeddings, @@ -102,4 +110,9 @@ async def get_umap_data( zip(umap_embeddings[:, 0], umap_embeddings[:, 1], labels, strict=False) ) ] - return JSONResponse(points) + # The resolved eps goes back with the points because it can differ from + # the requested one: the pair budget shrinks any value an album cannot + # afford, typed or not, and that ceiling depends on the point cloud — the + # client cannot predict it, so without this the Cluster Strength control + # would go on showing a number the map was never clustered with. + return JSONResponse({"points": points, "eps": float(cluster_eps), "requested_eps": requested_eps}) diff --git a/photomap/frontend/static/css/umap-floating-window.css b/photomap/frontend/static/css/umap-floating-window.css index d49e8494..cfb8071b 100644 --- a/photomap/frontend/static/css/umap-floating-window.css +++ b/photomap/frontend/static/css/umap-floating-window.css @@ -237,6 +237,22 @@ display: none; } +/* The strength the map was actually clustered at, when the server had to + shrink the requested one to fit its memory budget. The details are in the + title; the text only has to signal that the spinner's number is not the + map's. */ +.umap-eps-effective { + margin-left: 4px; + color: #faea0e; + font-size: 0.75em; + cursor: help; + vertical-align: middle; +} + +.umap-eps-effective[hidden] { + display: none; +} + /* The Cluster Strength field holds something the album cannot be set to — a half-typed number, or one below the floor the server would silently raise. umap.js answers those by doing nothing, so without a mark a refused diff --git a/photomap/frontend/static/javascript/umap.js b/photomap/frontend/static/javascript/umap.js index 8bfbd5a0..24c205e5 100644 --- a/photomap/frontend/static/javascript/umap.js +++ b/photomap/frontend/static/javascript/umap.js @@ -170,6 +170,12 @@ export function setUmapClickCallback(callback) { let points = []; let clusters = []; let colors = []; +// The strength `points` was clustered with, as the server reported it — not +// what the spinner holds. The two differ mid-debounce, while the field holds +// text the spinner refuses, and on an album too large to cluster at the +// requested strength (the server shrinks it to fit a memory budget). Assigned +// alongside `points` so the two always describe the same clustering. +let drawnEps = null; // The points the map should currently draw, after the images/videos filter. // @@ -262,7 +268,12 @@ function hideUmapSpinner() { export function applyResolvedEps(data) { const epsSpinner = document.getElementById("umapEpsSpinner"); if (epsSpinner && typeof data?.eps === "number") { - epsSpinner.value = data.eps; + // A stored strength below the floor is shown unrounded: rounding 0.00996 + // gives "0.01", the floor itself, which would then sit there marked as + // unusable. + const belowFloor = !data.auto && !epsIsUsable(data.eps); + epsSpinner.value = belowFloor ? String(data.eps) : formatEps(data.eps); + spinnerExactEps = { shown: epsSpinner.value, eps: data.eps }; // Replacing the contents ends whatever edit was in there, guard included. // Half-typed text holds the guard even across a blur, on purpose — but it // cannot go on holding it once the text it was protecting is gone, or one @@ -282,6 +293,61 @@ export function applyResolvedEps(data) { setEpsAutoBadge(Boolean(data?.auto)); } +// Say so when the map was clustered at less than the strength asked for. The +// server shrinks any strength an album cannot afford within its memory budget, +// and where that ceiling sits depends on the point cloud — nothing the spinner +// can know in advance. Without this note a large album tuned above the ceiling +// shows one number while the map is drawn at another, with no hint why. +// +// The used value is not the ceiling itself: the server shrinks the *request* +// in 0.7x steps until it fits, so where it lands depends on what was asked +// for. Hence the tooltip says "reduced", not "everything above X is the same". +// +// The spinner keeps the requested number: it is what the album stores and what +// the next fetch will ask for. Only the note reports what was used. +function showEffectiveEps(data) { + const note = document.getElementById("umapEpsEffective"); + if (!note) { + return; + } + const used = data?.eps; + const requested = data?.requested_eps; + // A stored strength below the floor comes back *raised*; that case is + // already marked on the field itself by applyResolvedEps. + const shrunk = typeof used === "number" && typeof requested === "number" && used < requested * (1 - 1e-9); + note.hidden = !shrunk; + if (shrunk) { + const shown = formatEps(used); + note.textContent = `map: ${shown}`; + note.title = + `This album has too many images to cluster at ${formatEps(requested)} within the memory limit, ` + + `so the map is drawn at ${shown} instead. Any strength above the album's limit is reduced ` + + `the same way, so a larger number will not give a looser map.`; + } else { + note.textContent = ""; + note.removeAttribute("title"); + } +} + +// How a strength is printed anywhere in the map's UI: two decimals, matching +// the spinner's 0.01 step, so a derived 0.11944 reads as the 0.12 the arrows +// would have produced. Below 0.1 two significant figures are kept instead — +// a fixed two decimals would turn a large album's 0.0153 into 0.02, a third +// off, and a strength the budget shrank below 0.005 into "0.00". Shared by the +// spinner, the note and the cluster-info modal so no two of them print the +// same strength differently. +function formatEps(eps) { + return String(Number(eps >= 0.1 ? eps.toFixed(2) : eps.toPrecision(2))); +} + +// The exact strength behind a rounded number in the spinner. The field shows +// formatEps(eps), but the map must still be clustered at eps itself: a derived +// strength is the one /get_umap_eps reports and /cluster_labels caches, and a +// stored one is what the server would use anyway. Sending the rounded text +// would cluster at a number nobody chose. Dropped on the first keystroke — +// from then on the field holds the user's own number. +let spinnerExactEps = null; + function setEpsAutoBadge(isAuto) { const badge = document.getElementById("umapEpsAutoBadge"); if (badge) { @@ -349,6 +415,7 @@ document.getElementById("umapEpsSpinner").oninput = async () => { // the refused ones that have no timer to stand in for them. epsEditPending = true; epsEditSeq++; + spinnerExactEps = null; // An empty field means "go back to deriving it" — otherwise the only way // out of a value you typed once would be to edit the config file. null is // sent verbatim; a numeric fallback here is what used to pin every album @@ -497,7 +564,16 @@ function epsIsUsable(eps) { // is what tells a half-typed number from a cleared field. function readSpinnerEps() { const raw = document.getElementById("umapEpsSpinner").value.trim(); - return raw === "" ? null : parseFloat(raw); + if (raw === "") { + return null; + } + // Still the rounded number applyResolvedEps put there: mean what it stands + // for. Compared against the text rather than trusted on its own, so anything + // else that rewrites the field cannot inherit a stale exact value. + if (spinnerExactEps && raw === spinnerExactEps.shown) { + return spinnerExactEps.eps; + } + return parseFloat(raw); } // The `?cluster_eps=` both map-shaped endpoints should be asked for, or "" to @@ -753,7 +829,15 @@ export async function fetchUmapData() { // with the map's own state already half-replaced. throw new Error(`umap_data ${response.status}`); } - points = await response.json(); + const data = await response.json(); + // Checked before anything is assigned, for the same reason as the status + // check above. + if (!Array.isArray(data?.points)) { + throw new Error("umap_data: response has no points"); + } + points = data.points; + drawnEps = typeof data.eps === "number" ? data.eps : null; + showEffectiveEps(data); // Compute clusters and colors clusters = [...new Set(points.map((p) => p.cluster))]; @@ -2625,7 +2709,11 @@ function showClusterInfoModal() { } // Compute stats from the module-level points array - const eps = parseFloat(document.getElementById("umapEpsSpinner").value); + // The strength the drawn map was clustered with, for the same reason the + // counts below come from `points`: the spinner can hold an unsaved edit, a + // refused value or half-typed text, and on a large album a strength the + // server could not afford. + const eps = drawnEps; const clustered = points.filter((p) => p.cluster !== -1); const clusterIds = [...new Set(clustered.map((p) => p.cluster))]; const clusterCount = clusterIds.length; @@ -2647,7 +2735,7 @@ function showClusterInfoModal() { smallestSize = 0; } - document.getElementById("umapInfoEps").textContent = isNaN(eps) ? "—" : eps.toFixed(2); + document.getElementById("umapInfoEps").textContent = typeof eps === "number" ? formatEps(eps) : "—"; document.getElementById("umapInfoClusterCount").textContent = clusterCount === 1 ? "1 cluster" : `${clusterCount} clusters`; document.getElementById("umapInfoLargest").textContent = largestSize === 1 ? "1 image" : `${largestSize} images`; diff --git a/photomap/frontend/templates/modules/umap-floating-window.html b/photomap/frontend/templates/modules/umap-floating-window.html index 96aabcf4..96cc3a57 100644 --- a/photomap/frontend/templates/modules/umap-floating-window.html +++ b/photomap/frontend/templates/modules/umap-floating-window.html @@ -202,6 +202,9 @@ + +