Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
10 changes: 7 additions & 3 deletions photomap/backend/routers/cluster_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ async def get_cluster_labels(

Returns:
`{"labels": {"<cluster_id>": {"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
Expand Down Expand Up @@ -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"])
Expand Down
17 changes: 15 additions & 2 deletions photomap/backend/routers/umap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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})
16 changes: 16 additions & 0 deletions photomap/frontend/static/css/umap-floating-window.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 93 additions & 5 deletions photomap/frontend/static/javascript/umap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))];
Expand Down Expand Up @@ -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;
Expand All @@ -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`;
Expand Down
3 changes: 3 additions & 0 deletions photomap/frontend/templates/modules/umap-floating-window.html
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@
<!-- Shown while the strength is derived rather than chosen;
umap.js clears it as soon as the user edits the spinner. -->
<span id="umapEpsAutoBadge" class="umap-eps-auto-badge" hidden>auto</span>
<!-- Shown when the server clustered at less than the strength
asked for, because the album is too large to afford it. -->
<span id="umapEpsEffective" class="umap-eps-effective" hidden></span>
</div>
<div id="umapClickBehaviorContainer" style="display: flex; flex-direction: row; gap: 12px;">
<label style="display: flex; align-items: center; gap: 6px;">
Expand Down
78 changes: 78 additions & 0 deletions tests/backend/test_cluster_eps_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,81 @@ def _no_disk(*args, **kwargs):
)
assert response.status_code == 200
assert get_config_manager().get_album(key).umap_eps == pytest.approx(0.1)


# --- The eps the map was actually clustered with (#380) ---------------------
#
# The pair budget shrinks any eps an album cannot afford, typed or derived, and
# where that ceiling sits depends on the point cloud. The client cannot predict
# it, so the endpoints have to say what they used.


def _tiny_pair_budget(monkeypatch):
"""Make every album 'too large' for an eps spanning the whole map."""
from photomap.backend import cluster_eps

# Each point is its own neighbor, so n pairs is the floor: anything above
# it forces a shrink once eps covers more than the point itself.
monkeypatch.setattr(cluster_eps, "MAX_NEIGHBOR_PAIRS", 30)


def test_umap_data_reports_the_eps_it_clustered_with_when_the_budget_shrinks_it(
client, new_album, monkeypatch
):
build_index(client, new_album)
_tiny_pair_budget(monkeypatch)

from photomap.backend.routers import umap as umap_router

used = {}
real_dbscan = umap_router.DBSCAN

def recording_dbscan(*, eps, min_samples):
used["eps"] = eps
return real_dbscan(eps=eps, min_samples=min_samples)

monkeypatch.setattr(umap_router, "DBSCAN", recording_dbscan)

body = client.get(f"/umap_data/{new_album['key']}?cluster_eps=50").json()

assert body["requested_eps"] == 50
assert body["eps"] < 50
assert body["eps"] == pytest.approx(used["eps"])


def test_cluster_labels_reports_the_same_shrunk_eps_as_umap_data(client, new_album, monkeypatch):
build_index(client, new_album)
_tiny_pair_budget(monkeypatch)
monkeypatch.setattr(
"photomap.backend.routers.cluster_labels.get_or_build_cluster_labels",
lambda embeddings, *, cluster_eps, cluster_min_samples, top_k: {},
)

key = new_album["key"]
map_eps = client.get(f"/umap_data/{key}?cluster_eps=50").json()["eps"]
labels_eps = client.get(f"/cluster_labels/{key}?cluster_eps=50").json()["eps"]

assert map_eps < 50
assert labels_eps == pytest.approx(map_eps)


def test_umap_data_reports_a_stored_eps_as_the_request(client, new_album):
build_index(client, new_album)
key = new_album["key"]
client.post("/set_umap_eps/", json={"album": key, "eps": 0.37})

body = client.get(f"/umap_data/{key}").json()

assert body["requested_eps"] == pytest.approx(0.37)
assert body["eps"] == pytest.approx(0.37)


def test_umap_data_reports_a_derived_eps_with_no_request(client, new_album):
build_index(client, new_album)
_clear_stored_eps(new_album["key"])

body = client.get(f"/umap_data/{new_album['key']}").json()
derived = client.post("/get_umap_eps/", json={"album": new_album["key"]}).json()

assert body["requested_eps"] is None
assert body["eps"] == pytest.approx(derived["eps"])
4 changes: 3 additions & 1 deletion tests/backend/test_cluster_labels_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ def test_endpoint_handles_empty_result(client, new_album, monkeypatch):
)
response = client.get(f"/cluster_labels/{new_album['key']}")
assert response.status_code == 200
assert response.json() == {"labels": {}}
body = response.json()
assert body["labels"] == {}
assert isinstance(body["eps"], float)


def test_endpoint_404_for_missing_album(client):
Expand Down
6 changes: 3 additions & 3 deletions tests/backend/test_umap.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def test_umap_construction(client, new_album, monkeypatch):
album_key = new_album["key"]
response = client.get(f"umap_data/{new_album['key']}")
assert response.status_code == 200
umap_data = response.json()
umap_data = response.json()["points"]
# Should match the number of images in the album.
assert len(umap_data) == TEST_IMAGE_COUNT
slides = [fetch_filename(client, album_key, i) for i in range(TEST_IMAGE_COUNT)]
Expand Down Expand Up @@ -48,7 +48,7 @@ def test_umap_data_reports_media_type(client, new_media_album, monkeypatch):

response = client.get(f"umap_data/{new_media_album['key']}")
assert response.status_code == 200
points = response.json()
points = response.json()["points"]

assert all("media" in point for point in points)
media_counts = Counter(point["media"] for point in points)
Expand All @@ -60,7 +60,7 @@ def test_umap_data_reports_image_only_albums_as_all_images(client, new_album, mo
"""An index predating video support must report "image" throughout."""
build_index(client, new_album)

points = client.get(f"umap_data/{new_album['key']}").json()
points = client.get(f"umap_data/{new_album['key']}").json()["points"]

assert points
assert all(point["media"] == "image" for point in points)
Expand Down
2 changes: 1 addition & 1 deletion tests/backend/test_video_indexing.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ def test_thumbnails_render_for_indexed_videos(client, new_media_album):
def test_umap_includes_videos(client, new_media_album):
build_index(client, new_media_album)

points = client.get(f"umap_data/{new_media_album['key']}").json()
points = client.get(f"umap_data/{new_media_album['key']}").json()["points"]

assert len(points) == EXPECTED_TOTAL

Expand Down
Loading
Loading