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
14 changes: 14 additions & 0 deletions docs/user-guide/albums.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ When the indexing process is done, you will find the generated indexes stored in

When you add or remove image files from an album's image directory, you will need to reindex the album. Navigate to the album in the Album Manager list and press the blue <span class="blue-button-text">Update Index</span> button. The update operation will only reindex the files that have been added or removed and will be much faster than the first comprehensive indexing operation.

### Rebuilding an index from scratch

Underneath it sits a red <span class="red-button-text">Rebuild Index</span> button, which throws the existing index away and builds a new one. It asks for confirmation first, and only appears for albums that already have an index.

You need it because **Update Index cannot re-read a file it has already indexed**. An update compares the files on disk against the ones in the index and processes only what was added or removed; a file that is in both lists is left exactly as it was first recorded, whatever its modification time. So anything PhotoMapAI works out *while* indexing — an image's or video's embedded generation metadata, for instance — is fixed at that moment.

Rebuild when:

- an upgrade taught PhotoMapAI to read something it previously ignored, and you want existing files re-examined;
- files changed in place, keeping their names;
- an index looks wrong or incomplete and you would rather start clean.

Rebuilding costs a full pass over the album, the same as the first index. Nothing else is lost: the semantic map, cluster labels and thumbnails are derived from the index and are regenerated automatically.

### Skipping Small Images

During the traversal phase, PhotoMapAI inspects each candidate image and skips any whose width *or* height is below a minimum pixel threshold. The default is **256 pixels** in either dimension. This filter is meant to exclude thumbnails, favicons, contact-sheet previews, and other tiny images that don't carry enough visual content for semantic search to work well on them. A summary of how many images were skipped on the last scan is written to the server log.
Expand Down
16 changes: 15 additions & 1 deletion photomap/backend/routers/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@

from .. import invokeai_client
from ..config import get_config_manager
from ..embeddings import LAST_UPDATED_FILENAME, Embeddings, peek_encoder_spec
from ..embeddings import (
LAST_UPDATED_FILENAME,
Embeddings,
_open_npz_file,
peek_encoder_spec,
)
from ..media_types import is_video
from ..progress import IndexingCancelled, progress_tracker
from ..thumbnail_cache import discard as discard_tiles
Expand Down Expand Up @@ -174,6 +179,15 @@ async def remove_index(album_key: str) -> JSONResponse:

# Remove the index file
index_path.unlink()
# ``_open_npz_file`` is an lru_cache keyed on the path, so without
# this the just-deleted index stays live in memory and the app keeps
# serving an album whose index is no longer on disk. Every other
# clear sits in a *write* path in ``embeddings.py``, which was enough
# while this endpoint only ran as a prelude to re-indexing — the
# rebuild's own write cleared it. It is not enough now that a user
# can reach it from a button and the rebuild behind it can fail or
# be cancelled.
_open_npz_file.cache_clear()
logger.info(f"Removed index file: {index_path}")

return JSONResponse(
Expand Down
35 changes: 34 additions & 1 deletion photomap/frontend/static/css/album-manager.css
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,27 @@

.index-controls {
display: flex;
align-items: center;
/* flex-start, not center: the status text wraps to two or three lines,
and centring the buttons against it floated them down out of line with
Edit/Delete in the next grid cell (which sits at the top, per
.album-details' align-items: start). */
align-items: flex-start;
gap: 0.5em;
margin-bottom: 0.5em;
}

/* Mirrors .action-buttons.vertical — same direction, same gap, same
right-alignment — so Update Index lines up with Edit and Rebuild Index
with Delete. stretch rather than flex-end so the two buttons share the
column's width instead of ragging by the couple of pixels their labels
differ by. */
.index-buttons {
display: flex;
flex-direction: column;
gap: 0.5em;
align-items: stretch;
}

.index-status {
color: #aaa;
font-size: 0.9em;
Expand Down Expand Up @@ -450,6 +466,23 @@
white-space: nowrap;
}

/* Red, like .btn-delete: this discards work the user waited for. */
.btn-rebuild {
background: #f44336;
border: none;
color: #fff;
padding: 0.4em 0.8em;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
white-space: nowrap;
}

.btn-rebuild:disabled {
opacity: 0.5;
cursor: default;
}

.btn-cancel {
display: none;
background: #f44336;
Expand Down
14 changes: 13 additions & 1 deletion photomap/frontend/static/css/delete-modal.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,19 @@

#confirmModal.modal {
position: fixed;
z-index: 9999;
/* Above .modal-overlay (99999) and the file tree (100001), below the
spinner (110000) and toasts (120000).

A confirmation dialog has to outrank whatever opened it, and at 9999
this one did not: asked from inside Album Management it rendered
*behind* that overlay — the prompt was painted but its buttons were
not clickable, so the flow simply stopped. Raising it is safe for the
other callers, since a modal question should always be on top.

Note #deleteConfirmModal above still sits at 9999. It is only ever
opened from the main UI, never over an overlay, so it does not hit
this — but it is the same shape and would, if that changed. */
z-index: 100002;
left: 0;
top: 0;
width: 100vw;
Expand Down
60 changes: 56 additions & 4 deletions photomap/frontend/static/javascript/album-manager.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// album-management.js
import { createSimpleDirectoryPicker } from "./filetree.js"; // Add this import
import { getIndexMetadata, removeIndex, updateIndex } from "./index.js";
import { showConfirmModal } from "./modal-utils.js";
import {
collectSelectedBoardIds,
fetchInvokeAIBoards,
Expand Down Expand Up @@ -917,6 +918,8 @@ export class AlbumManager {
progressContainer.style.display = "none";
// Show the Update Index button
updateBtn.style.display = "inline-block";
// An index exists now, by definition, so Rebuild comes back too.
this.setRebuildButtonVisible(cardElement, true);
}

createCompletionMessage() {
Expand Down Expand Up @@ -1056,18 +1059,21 @@ export class AlbumManager {
status.textContent = `Index updated ${modDate} (${fileCount} images)`;
status.style.color = "green";
createBtn.textContent = "Update Index";
this.setRebuildButtonVisible(cardElement, true);
this._appendIndexWarningNote(status, album.key);
} else {
status.className = "index-status";
status.textContent = "No index present";
status.style.color = "red";
createBtn.textContent = "Create Index";
this.setRebuildButtonVisible(cardElement, false);
}
} catch {
status.className = "index-status";
status.textContent = "No index present";
status.style.color = "red";
createBtn.textContent = "Create Index";
this.setRebuildButtonVisible(cardElement, false);
}
}

Expand Down Expand Up @@ -1107,6 +1113,11 @@ export class AlbumManager {
card.querySelector(".cancel-index-btn").addEventListener("click", () => {
this.cancelIndexing(album.key, cardElement);
});

// Rebuild index button
card.querySelector(".rebuild-index-btn").addEventListener("click", () => {
this.rebuildIndex(album.key, cardElement);
});
}

async addAlbum() {
Expand Down Expand Up @@ -1608,8 +1619,43 @@ export class AlbumManager {
}
}

// Discard an album's index and build it again from scratch.
//
// Distinct from Update Index, which is a set difference on paths: it adds
// files that appeared and drops files that vanished, and never re-reads a
// file already in the index. So anything derived *while* indexing — a
// video's generation metadata, say — stays as it was first recorded until
// the index is thrown away. That is what this is for, and it is why the
// two cannot be the same button.
async rebuildIndex(albumKey, cardElement) {
const confirmed = await showConfirmModal(
"This will delete your previous index and rebuild it from scratch. Proceed?",
"Yes",
"Cancel"
);
if (!confirmed) {
return;
}
// Re-resolve the card. The confirmation above is an await of unbounded
// length — it sits there until the user decides — and loadAlbums()
// rebuilds the card list wholesale, so the element captured when the
// button was clicked may be detached by now. Painting progress onto a
// detached node leaves the on-screen card frozen while indexing runs.
await this.startIndexing(albumKey, this._liveCardFor(albumKey, cardElement), true);
}

// Whether this card offers Rebuild Index. There is nothing to rebuild
// before an index exists, and while one is being built the card shows
// Cancel instead.
setRebuildButtonVisible(cardElement, visible) {
const rebuildBtn = cardElement.querySelector(".rebuild-index-btn");
if (rebuildBtn) {
rebuildBtn.style.display = visible ? "inline-block" : "none";
}
}

// Indexing functionality
async startIndexing(albumKey, cardElement, isCorrupted = false) {
async startIndexing(albumKey, cardElement, removeExistingIndex = false) {
// Prevent duplicate indexing requests (local guard)
if (this.progressPollers.has(albumKey)) {
console.log(`Indexing already in progress for album: ${albumKey}`);
Expand Down Expand Up @@ -1639,14 +1685,17 @@ export class AlbumManager {
console.debug(`Could not check backend indexing status for album: ${albumKey}`);
}

if (isCorrupted) {
console.log(`Starting indexing for corrupted album: ${albumKey}`);
if (removeExistingIndex) {
// Two callers want this: automatic recovery from a corrupted index,
// and the user pressing Rebuild Index. Both mean "throw the existing
// index away first", so the wording here stays neutral between them.
console.log(`Removing the existing index before indexing: ${albumKey}`);
const response = await removeIndex(albumKey);
console.log(`Remove index response:`, response);
if (!response.success) {
const album = await this.getAlbum(albumKey);
alert(
`Failed to remove corrupted index for album: ${albumKey}.` +
`Failed to remove the existing index for album: ${albumKey}.` +
` Please remove the index file manually and try again.` +
` The path for the index file is: ${album.index}`
);
Expand Down Expand Up @@ -1776,6 +1825,9 @@ export class AlbumManager {
progressContainer.style.display = "block";
createBtn.style.display = "none";
cancelBtn.style.display = "inline-block";
// Rebuild follows Update Index: a second rebuild mid-run would delete
// the index the running job is about to write.
this.setRebuildButtonVisible(cardElement, false);

// Only set generic message if no progress data is provided
if (!progress) {
Expand Down
15 changes: 13 additions & 2 deletions photomap/frontend/templates/modules/album-manager.html
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,19 @@ <h4 class="album-name"></h4>
<!-- Index Status and Button -->
<div class="index-controls">
<div class="index-status">Ready to index</div>
<button class="create-index-btn btn-index">Update Index</button>
<button class="cancel-index-btn btn-cancel">Cancel</button>
<!-- A column, so the index buttons stack on the same two rows
as Edit and Delete in the next grid cell. Only one of
Update/Cancel is ever shown, so this is two buttons deep in
practice, matching that column exactly. Rebuild Index is
hidden until the card learns an index exists, since there
is nothing to rebuild before that. -->
<div class="index-buttons">
<button class="create-index-btn btn-index">Update Index</button>
<button class="cancel-index-btn btn-cancel">Cancel</button>
<button class="rebuild-index-btn btn-rebuild" style="display: none">
Rebuild Index
</button>
</div>
</div>
<!-- Progress Container -->
<div class="progress-container">
Expand Down
109 changes: 109 additions & 0 deletions tests/backend/test_remove_index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""``DELETE /remove_index/{album_key}``, which Rebuild Index runs first.

The endpoint predates the button, but only ever ran as a prelude to
re-indexing, so the one thing it got away with not doing — dropping the
deleted index out of the process-wide ``lru_cache`` — was covered by the
rebuild's own write clearing it moments later. A user-facing button removes
that cover: the rebuild behind it can fail, be cancelled, or simply not be
reached, and the app would go on serving an album whose index is no longer
on disk.
"""

from __future__ import annotations

import shutil
from pathlib import Path

import numpy as np
import pytest
from fixtures import (
ENCODER_SPEC,
_write_synthetic_index,
client, # noqa: F401
media_fixture_path,
)

from photomap.backend.embeddings import _open_npz_file


@pytest.fixture
def indexed_album(client, tmp_path): # noqa: F811
"""An album with a real index file on disk."""
media_dir = tmp_path / "pics"
media_dir.mkdir()
photo = media_dir / "building1.jpeg"
shutil.copy(
media_fixture_path("../test_images/building1.jpeg").resolve(), photo
)

index_path = media_dir / "photomap_index" / "embeddings.npz"
_write_synthetic_index(index_path, [photo], [{"Make": "TestCam"}])

album = {
"key": "removable_album",
"name": "Removable",
"image_paths": [media_dir.as_posix()],
"index": index_path.as_posix(),
"umap_eps": 0.1,
"description": "",
"encoder_spec": ENCODER_SPEC,
}
try:
assert client.post("/add_album/", json=album).status_code == 201
yield {**album, "index_path": index_path}
finally:
client.delete(f"/delete_album/{album['key']}")


def test_removing_an_index_deletes_the_file(client, indexed_album): # noqa: F811
response = client.delete(f"/remove_index/{indexed_album['key']}")

assert response.status_code == 200
assert response.json()["success"] is True
assert not Path(indexed_album["index_path"]).exists()


def test_removing_an_index_drops_it_from_the_cache(client, indexed_album): # noqa: F811
"""Otherwise the app serves an index that is no longer on disk.

The read below is what puts it in the cache — exactly as any request
touching the album would have.
"""
index_path = Path(indexed_album["index_path"])
assert len(_open_npz_file(index_path)["filenames"]) == 1

client.delete(f"/remove_index/{indexed_album['key']}")

with pytest.raises(FileNotFoundError):
_open_npz_file(index_path)


def test_removing_a_missing_index_is_a_404(client, indexed_album): # noqa: F811
Path(indexed_album["index_path"]).unlink()

response = client.delete(f"/remove_index/{indexed_album['key']}")

assert response.status_code == 404


def test_removing_the_index_of_an_unknown_album_is_a_404(client): # noqa: F811
assert client.delete("/remove_index/no-such-album").status_code == 404


def test_only_the_index_file_is_removed(client, indexed_album): # noqa: F811
"""The semantic map, cluster labels and thumbnails are derived and
self-invalidate by mtime against the index, so a rebuild regenerates
them — deleting them here would only throw away reusable work.
"""
index_path = Path(indexed_album["index_path"])
umap = index_path.parent / "umap.npz"
np.savez(umap, umap_embeddings=np.zeros((1, 2), dtype=np.float32))
thumbs = index_path.parent / "thumbnails"
thumbs.mkdir()
(thumbs / "0.webp").write_bytes(b"not really a webp")

client.delete(f"/remove_index/{indexed_album['key']}")

assert not index_path.exists()
assert umap.exists()
assert (thumbs / "0.webp").exists()
Loading
Loading