Add tag cloud feature with k-means clustering and CUDA support

Introduces an Explore view with a tag cloud that clusters image embeddings
using cosine k-means and labels clusters via vocabulary-nearest-neighbour
CLIP matching. Vocabulary embeddings are disk-cached (FNV hash-keyed) to
avoid redundant inference. Enables CUDA for candle dependencies and adds a
build.rs check that surfaces a clear error when the toolkit is missing.
This commit is contained in:
2026-04-06 12:43:44 +01:00
parent 35c1dafd65
commit 6c3fd449ce
11 changed files with 1248 additions and 37 deletions
+157 -4
View File
@@ -1,10 +1,10 @@
use crate::db::{self, DbPool, Folder, FolderJobProgress, ImageRecord};
use crate::embedder::ClipImageEmbedder;
use crate::embedder;
use crate::indexer;
use crate::vector;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::{AppHandle, State};
use tauri::{AppHandle, Manager, State};
pub type DbState = DbPool;
@@ -204,8 +204,7 @@ pub async fn semantic_search_images(
db: State<'_, DbState>,
params: SemanticSearchParams,
) -> Result<Vec<ImageRecord>, String> {
let embedder = ClipImageEmbedder::new().map_err(|e| e.to_string())?;
let embedding = embedder.embed_text(&params.query).map_err(|e| e.to_string())?;
let embedding = embedder::embed_text_query(&params.query).map_err(|e| e.to_string())?;
let conn = db.get().map_err(|e| e.to_string())?;
let limit = params.limit.unwrap_or(64);
@@ -225,6 +224,160 @@ pub async fn semantic_search_images(
Ok(images)
}
#[derive(Serialize)]
pub struct TagCloudEntry {
pub label: String,
pub count: usize,
}
/// Clusters the library's image embeddings with k-means, then labels each cluster by
/// finding the closest word in the vocabulary. The vocabulary is loaded from
/// `{app_data_dir}/vocabulary.txt` if present, otherwise from the bundled default.
/// Vocabulary embeddings are cached to disk — only recomputed when the vocabulary changes.
#[tauri::command]
pub async fn get_tag_cloud(
app: AppHandle,
db: State<'_, DbState>,
folder_id: Option<i64>,
) -> Result<Vec<TagCloudEntry>, String> {
let app_data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let image_embeddings = {
let conn = db.get().map_err(|e| e.to_string())?;
vector::get_all_image_embeddings(&conn, folder_id).map_err(|e| e.to_string())?
};
let n = image_embeddings.len();
if n < 5 {
return Ok(vec![]);
}
// Load vocabulary (custom file > bundled default)
let vocab = embedder::load_vocabulary(&app_data_dir);
// Embed vocabulary — disk-cached, only recomputes when vocabulary changes
let vocab_refs: Vec<&str> = vocab.iter().map(|s| s.as_str()).collect();
let vocab_embeddings = embedder::embed_vocab_cached(&vocab, &app_data_dir)
.map_err(|e| e.to_string())?;
// Choose k proportional to library size, capped at 30
let k = (n / 20).clamp(5, 30);
// Cluster image embeddings
let (centroids, cluster_counts) = kmeans_cosine(&image_embeddings, k, 40);
// Label each cluster with the nearest vocabulary word
let mut entries: Vec<TagCloudEntry> = Vec::new();
let mut used_labels = std::collections::HashSet::new();
let mut order: Vec<usize> = (0..k).collect();
order.sort_unstable_by(|&a, &b| cluster_counts[b].cmp(&cluster_counts[a]));
for ci in order {
let count = cluster_counts[ci];
if count == 0 { continue; }
let centroid = &centroids[ci];
let label = vocab_refs
.iter()
.zip(vocab_embeddings.iter())
.map(|(&word, emb)| {
let sim: f32 = centroid.iter().zip(emb.iter()).map(|(a, b)| a * b).sum();
(word, sim)
})
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(word, _)| word.to_string())
.unwrap_or_else(|| "other".to_string());
if used_labels.insert(label.clone()) {
entries.push(TagCloudEntry { label, count });
}
}
entries.sort_unstable_by(|a, b| b.count.cmp(&a.count));
Ok(entries)
}
// ── k-means with cosine similarity (all vectors assumed to be unit-normalized) ──
fn dot(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
fn normalize(v: &mut Vec<f32>) {
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 1e-10 {
v.iter_mut().for_each(|x| *x /= norm);
}
}
fn kmeans_cosine(
points: &[Vec<f32>],
k: usize,
max_iter: usize,
) -> (Vec<Vec<f32>>, Vec<usize>) {
let n = points.len();
let dim = points[0].len();
// Deterministic k-means++ init: spread centroids as far apart as possible
let mut centroids: Vec<Vec<f32>> = Vec::with_capacity(k);
centroids.push(points[n / 2].clone());
for _ in 1..k {
let next = points
.iter()
.map(|p| {
let best_sim = centroids.iter().map(|c| dot(p, c)).fold(f32::NEG_INFINITY, f32::max);
1.0 - best_sim // distance = 1 - cosine_similarity
})
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
centroids.push(points[next].clone());
}
let mut assignments = vec![0usize; n];
for _ in 0..max_iter {
// Assignment step
let mut changed = false;
for (i, p) in points.iter().enumerate() {
let best = centroids
.iter()
.enumerate()
.map(|(j, c)| (j, dot(p, c)))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(j, _)| j)
.unwrap_or(0);
if assignments[i] != best {
assignments[i] = best;
changed = true;
}
}
if !changed { break; }
// Update step: mean of assigned points, then normalize
let mut sums = vec![vec![0.0f32; dim]; k];
let mut counts = vec![0usize; k];
for (p, &c) in points.iter().zip(assignments.iter()) {
sums[c].iter_mut().zip(p.iter()).for_each(|(s, v)| *s += v);
counts[c] += 1;
}
for (centroid, (sum, &count)) in centroids.iter_mut().zip(sums.iter_mut().zip(counts.iter())) {
if count > 0 {
sum.iter_mut().for_each(|v| *v /= count as f32);
normalize(sum);
*centroid = sum.clone();
}
}
}
let mut counts = vec![0usize; k];
for &a in &assignments { counts[a] += 1; }
(centroids, counts)
}
#[derive(Serialize)]
pub struct WorkerStates {
pub thumbnail_paused: bool,