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:
+157
-4
@@ -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(¶ms.query).map_err(|e| e.to_string())?;
|
||||
let embedding = embedder::embed_text_query(¶ms.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 = ¢roids[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,
|
||||
|
||||
Reference in New Issue
Block a user