0ca4d142d8
Adds a full discovery and organisation layer to the gallery.
## AI Tagger
- WD tagger (ONNX via ort) with DirectML/CPU acceleration
- Per-image and per-folder tag queuing; configurable batch size and
confidence threshold; model downloaded on first use via ureq/zip
- Tags stored with source ('ai' | 'user'); user tags are never
overwritten by AI; AI tags protected from accidental promotion
- Tagger pause/resume per folder; in-flight batches discarded cleanly
on pause or cancellation without leaving jobs stuck in processing
## Semantic & Tag Search
- CLIP text-query embedding via candle (HuggingFace hub model)
- Progressive candidate doubling with filter post-processing so
folder/rating/media-kind filters do not silently under-return results
- Tag search with DB-backed pagination (offset + COUNT total)
- Filename search unchanged; prefix syntax: s:, t:, f: in search bar
## Similarity Search
- HNSW in-memory index (hnsw_rs) with monotonic embedding_revision
counter for cache invalidation; build_index retries if a concurrent
write advances the revision during construction
- Folder-scoped similar search uses brute-force cosine scan (no k limit)
- Region-based similarity: crop an arbitrary rectangle in the lightbox,
embed it with CLIP, find the nearest images globally or within folder
- Pagination for both similar and region results; scope toggle
(all media / current folder) re-runs the query without reopening image
## Duplicate Finder
- Three-phase detection: stat (size) → sample hash (4×16 KB windows)
→ full-file hash (xxh3, large files only) to eliminate false positives
- Filesystem-first deletion: only removes DB rows for files successfully
deleted from disk; failed files remain visible for retry
- Persisted scan cache (SQLite) with invalidation on reindex and deletion;
both global and per-folder scopes invalidated after any deletion
## Tag Cloud & Explore
- Visual cluster explore: k-means over CLIP embeddings, configurable k,
representative thumbnail per cluster; cache keyed on xxh3 of embedding set
- Tag explore: ranked tag list with image counts and representative
thumbnail per tag; invalidated when AI tagging completes or folder removed
- Tag autocomplete for search bar
## Folder Management
- Inline rename (display name only, not OS rename)
- Relocate: file-picker to update folder path; all image paths rewritten
using SUBSTR prefix replacement to avoid corrupting paths that contain
the folder name as a substring
- Missing-folder recovery banner: Locate or Remove, never silent deletion
- Right-click context menu on sidebar items: Reindex, Rename, Locate,
Remove; hover buttons preserved alongside context menu
## Background Tasks
- Per-folder progress panel with embedding, tagging, caption, and
thumbnail stage breakdown
- Retry button per task for failed embeddings and tagging jobs
- Completed-task notifications (system tray via tauri-plugin-notification)
- Scan errors shown inline; WalkDir permission errors protect existing
records from deletion rather than cascading data loss
## Backend correctness
- sqlite-vec DML performed outside transactions throughout (virtual-table
limitation); embedding revision incremented on delete as well as insert
- Tagging job discard checks status = 'processing' so paused jobs reset
to 'pending' are also skipped, not just cancelled ones
- Stale async responses in Lightbox guarded with cancellation flags and
currentImageIdRef for both tag-load and tag-add callbacks
- Duplicate cache cleared on reindex; folder-removal clears vector rows
outside transaction before deleting the folder row
155 lines
5.1 KiB
Rust
155 lines
5.1 KiB
Rust
use crate::vector;
|
|
use anyhow::Result;
|
|
use hnsw_rs::prelude::{DistCosine, Hnsw, Neighbour};
|
|
use rusqlite::Connection;
|
|
use std::collections::HashMap;
|
|
use std::sync::{OnceLock, RwLock};
|
|
|
|
const HNSW_MAX_CONNECTIONS: usize = 24;
|
|
const HNSW_EF_CONSTRUCTION: usize = 300;
|
|
const HNSW_EF_SEARCH: usize = 96;
|
|
|
|
struct CachedHnswIndex {
|
|
revision: String,
|
|
image_ids_by_external: Vec<i64>,
|
|
external_by_image_id: HashMap<i64, usize>,
|
|
hnsw: Hnsw<'static, f32, DistCosine>,
|
|
}
|
|
|
|
static IMAGE_HNSW_INDEX: OnceLock<RwLock<Option<CachedHnswIndex>>> = OnceLock::new();
|
|
|
|
fn cache() -> &'static RwLock<Option<CachedHnswIndex>> {
|
|
IMAGE_HNSW_INDEX.get_or_init(|| RwLock::new(None))
|
|
}
|
|
|
|
fn build_index(conn: &Connection) -> Result<CachedHnswIndex> {
|
|
// Read the revision *before* fetching embeddings so we can detect any write
|
|
// that races with the build. If the revision advances while we are building,
|
|
// the resulting index would be stale — retry until it is stable.
|
|
loop {
|
|
let revision_before = vector::get_embedding_revision(conn)?;
|
|
|
|
let embeddings = vector::get_all_image_embeddings_with_ids(conn, None)?;
|
|
let max_elements = embeddings.len().max(1);
|
|
let max_layer = 16.min((max_elements as f32).ln().trunc() as usize).max(1);
|
|
let mut hnsw = Hnsw::<f32, DistCosine>::new(
|
|
HNSW_MAX_CONNECTIONS,
|
|
max_elements,
|
|
max_layer,
|
|
HNSW_EF_CONSTRUCTION,
|
|
DistCosine {},
|
|
);
|
|
|
|
let image_ids_by_external = embeddings
|
|
.iter()
|
|
.map(|(image_id, _)| *image_id)
|
|
.collect::<Vec<_>>();
|
|
let external_by_image_id = image_ids_by_external
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(external_id, image_id)| (*image_id, external_id))
|
|
.collect::<HashMap<_, _>>();
|
|
let data_with_id = embeddings
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(external_id, (_, embedding))| (embedding, external_id))
|
|
.collect::<Vec<_>>();
|
|
|
|
hnsw.parallel_insert(&data_with_id);
|
|
hnsw.set_searching_mode(true);
|
|
|
|
// If the revision is unchanged the index reflects a consistent snapshot.
|
|
let revision_after = vector::get_embedding_revision(conn)?;
|
|
if revision_before == revision_after {
|
|
return Ok(CachedHnswIndex {
|
|
revision: revision_after,
|
|
image_ids_by_external,
|
|
external_by_image_id,
|
|
hnsw,
|
|
});
|
|
}
|
|
// A concurrent write advanced the revision — discard this build and retry.
|
|
}
|
|
}
|
|
|
|
fn ensure_index(conn: &Connection) -> Result<()> {
|
|
let revision = vector::get_embedding_revision(conn)?;
|
|
|
|
{
|
|
let guard = cache().read().expect("hnsw cache poisoned");
|
|
if guard.as_ref().map(|cached| cached.revision.as_str()) == Some(revision.as_str()) {
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
let next = build_index(conn)?;
|
|
let mut guard = cache().write().expect("hnsw cache poisoned");
|
|
*guard = Some(next);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn find_similar_image_matches(
|
|
conn: &Connection,
|
|
image_id: i64,
|
|
folder_id: Option<i64>,
|
|
threshold: f32,
|
|
offset: usize,
|
|
limit: usize,
|
|
) -> Result<Vec<(i64, f32)>> {
|
|
ensure_index(conn)?;
|
|
|
|
let query_embedding = match vector::get_image_embedding(conn, image_id)? {
|
|
Some(embedding) => embedding,
|
|
None => return Ok(Vec::new()),
|
|
};
|
|
|
|
// Fetch folder image IDs *before* acquiring the read lock so we don't hold
|
|
// the lock across a potentially slow SQLite query, which would delay any
|
|
// concurrent ensure_index call waiting for a write lock.
|
|
let folder_image_ids: Option<Vec<i64>> = if let Some(folder_id) = folder_id {
|
|
let ids = vector::get_all_image_embeddings_with_ids(conn, Some(folder_id))?
|
|
.into_iter()
|
|
.map(|(id, _)| id)
|
|
.collect();
|
|
Some(ids)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let guard = cache().read().expect("hnsw cache poisoned");
|
|
let Some(cached) = guard.as_ref() else {
|
|
return Ok(Vec::new());
|
|
};
|
|
|
|
let knbn = (offset + limit).max(limit).saturating_add(32);
|
|
let neighbours: Vec<Neighbour> = if let Some(image_ids) = folder_image_ids {
|
|
let mut allowed_ids = image_ids
|
|
.into_iter()
|
|
.filter_map(|allowed_image_id| {
|
|
cached.external_by_image_id.get(&allowed_image_id).copied()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
allowed_ids.sort_unstable();
|
|
cached
|
|
.hnsw
|
|
.search_filter(&query_embedding, knbn, HNSW_EF_SEARCH, Some(&allowed_ids))
|
|
} else {
|
|
cached.hnsw.search(&query_embedding, knbn, HNSW_EF_SEARCH)
|
|
};
|
|
|
|
let matches = neighbours
|
|
.into_iter()
|
|
.filter_map(|neighbour| {
|
|
let image_id_match = cached.image_ids_by_external.get(neighbour.d_id).copied()?;
|
|
if image_id_match == image_id || neighbour.distance > threshold {
|
|
return None;
|
|
}
|
|
Some((image_id_match, neighbour.distance))
|
|
})
|
|
.skip(offset)
|
|
.take(limit)
|
|
.collect::<Vec<_>>();
|
|
|
|
Ok(matches)
|
|
}
|