use anyhow::{Context, Result};
use fastembed::{
EmbeddingModel, ImageEmbedding, ImageEmbeddingModel, ImageInitOptions, TextEmbedding,
TextInitOptions,
};
use std::path::Path;
pub const EXTENSIONS: [&str; 5] = ["png", "jpg", "jpeg", "webp", "gif"];
pub const DIM: usize = 512;
pub const MODEL_NAME: &str = "Qdrant/clip-ViT-B-32";
pub const VISION_REPO: &str = "Qdrant/clip-ViT-B-32-vision";
pub const TEXT_REPO: &str = "Qdrant/clip-ViT-B-32-text";
pub const INDEX_DIR: &str = "images";
pub fn is_image(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.is_some_and(|e| EXTENSIONS.contains(&e.as_str()))
}
pub fn dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()
}
#[derive(Default)]
pub struct Clip {
vision: Option<ImageEmbedding>,
text: Option<TextEmbedding>,
}
impl Clip {
pub fn embed_image(&mut self, path: &Path, quiet: bool) -> Result<Vec<f32>> {
let vision = match self.vision.as_mut() {
Some(model) => model,
None => {
crate::embed::refuse_if_airgapped(VISION_REPO)?;
let options = ImageInitOptions::new(ImageEmbeddingModel::ClipVitB32)
.with_show_download_progress(!quiet)
.with_cache_dir(crate::model_cache_dir());
let model = ImageEmbedding::try_new(options)
.map_err(|e| anyhow::anyhow!("loading {MODEL_NAME}'s vision encoder: {e}"))?;
self.vision.insert(model)
}
};
let mut out = vision
.embed(vec![path], None)
.with_context(|| format!("embedding {}", path.display()))?;
Ok(out.remove(0))
}
pub fn embed_query(&mut self, query: &str, quiet: bool) -> Result<Vec<f32>> {
let text = match self.text.as_mut() {
Some(model) => model,
None => {
crate::embed::refuse_if_airgapped(TEXT_REPO)?;
let options = TextInitOptions::new(EmbeddingModel::ClipVitB32)
.with_show_download_progress(!quiet)
.with_intra_threads(crate::embed::embed_threads())
.with_cache_dir(crate::model_cache_dir());
let model = TextEmbedding::try_new(options)
.map_err(|e| anyhow::anyhow!("loading {MODEL_NAME}'s text encoder: {e}"))?;
self.text.insert(model)
}
};
let mut out = text
.embed(vec![query], None)
.map_err(|e| anyhow::anyhow!("embedding the query for the image index: {e}"))?;
Ok(out.remove(0))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_five_formats_are_recognised_by_extension_in_any_case() {
for name in ["a.png", "b.JPG", "c.jpeg", "d.webp", "e.GIF"] {
assert!(is_image(Path::new(name)), "{name} is not read as an image");
}
for name in ["a.svg", "b.tiff", "c.heic", "d.pdf", "e.rs", "noextension"] {
assert!(!is_image(Path::new(name)), "{name} is read as an image");
}
}
#[test]
fn dimensions_come_from_the_header() {
let mut png = Vec::new();
image::DynamicImage::ImageRgb8(image::RgbImage::new(2, 3))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
assert_eq!(dimensions(&png), Some((2, 3)));
assert_eq!(dimensions(b"not an image at all"), None);
}
}