memvid--memvid
1688 行
57 KiB
Rust
1688 行
57 KiB
Rust
// Safe expect: Static CLIP model lookup with guaranteed default.
|
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
|
//! CLIP (Contrastive Language-Image Pre-training) visual embeddings module.
|
|
//!
|
|
//! This module provides visual understanding capabilities using MobileCLIP-S2,
|
|
//! enabling semantic search across images and PDF pages with natural language queries.
|
|
//!
|
|
//! # Design Philosophy
|
|
//!
|
|
//! - **Synchronous with Parallelism**: CLIP runs in parallel with text embedding via rayon.
|
|
//! Since CLIP (~25ms) is faster than text embedding (~200-500ms), it adds zero latency.
|
|
//! - **Separate Index**: CLIP embeddings (512 dims) are stored in a separate index from
|
|
//! text embeddings (384/768/1536 dims) because dimensions must match within an index.
|
|
//! - **Auto-detection**: Images and PDFs with images are automatically processed without flags.
|
|
//! - **Graceful Degradation**: Works without CLIP, just loses visual search capability.
|
|
|
|
use blake3::hash;
|
|
#[cfg(feature = "clip")]
|
|
use image::DynamicImage;
|
|
#[cfg(all(feature = "clip", not(feature = "pdfium")))]
|
|
use image::{ImageBuffer, Luma, Rgb};
|
|
#[cfg(all(feature = "clip", not(feature = "pdfium")))]
|
|
use lopdf::{Dictionary, Document, Object, ObjectId};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::borrow::Cow;
|
|
#[cfg(all(feature = "clip", not(feature = "pdfium")))]
|
|
use std::collections::HashSet;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
|
|
use crate::{MemvidError, Result, types::FrameId};
|
|
|
|
// ============================================================================
|
|
// Configuration Constants
|
|
// ============================================================================
|
|
|
|
/// CLIP index decode limit (512MB max)
|
|
#[allow(clippy::cast_possible_truncation)]
|
|
const CLIP_DECODE_LIMIT: usize = crate::MAX_INDEX_BYTES as usize;
|
|
|
|
/// MobileCLIP-S2 embedding dimensions
|
|
pub const MOBILECLIP_DIMS: u32 = 512;
|
|
|
|
/// SigLIP-base embedding dimensions
|
|
pub const SIGLIP_DIMS: u32 = 768;
|
|
|
|
/// Default input resolution for MobileCLIP-S2
|
|
pub const MOBILECLIP_INPUT_SIZE: u32 = 256;
|
|
|
|
/// Default input resolution for `SigLIP`
|
|
pub const SIGLIP_INPUT_SIZE: u32 = 224;
|
|
|
|
/// Minimum image dimension to process (skip icons, bullets)
|
|
pub const MIN_IMAGE_DIM: u32 = 64;
|
|
|
|
/// Maximum aspect ratio deviation from 1:1 (skip dividers, lines)
|
|
pub const MAX_ASPECT_RATIO: f32 = 10.0;
|
|
|
|
/// Minimum color variance threshold (skip solid backgrounds)
|
|
pub const MIN_COLOR_VARIANCE: f32 = 0.01;
|
|
|
|
/// Model unload timeout (5 minutes idle)
|
|
pub const MODEL_UNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
|
|
|
|
// ============================================================================
|
|
// Bincode Configuration
|
|
// ============================================================================
|
|
|
|
fn clip_config() -> impl bincode::config::Config {
|
|
bincode::config::standard()
|
|
.with_fixed_int_encoding()
|
|
.with_little_endian()
|
|
}
|
|
|
|
// ============================================================================
|
|
// Model Registry
|
|
// ============================================================================
|
|
|
|
/// Available CLIP models with verified `HuggingFace` URLs
|
|
#[derive(Debug, Clone)]
|
|
pub struct ClipModelInfo {
|
|
/// Model identifier
|
|
pub name: &'static str,
|
|
/// URL for vision encoder ONNX model
|
|
pub vision_url: &'static str,
|
|
/// URL for text encoder ONNX model
|
|
pub text_url: &'static str,
|
|
/// URL for tokenizer JSON (BPE)
|
|
pub tokenizer_url: &'static str,
|
|
/// Vision model size in MB
|
|
pub vision_size_mb: f32,
|
|
/// Text model size in MB
|
|
pub text_size_mb: f32,
|
|
/// Output embedding dimensions
|
|
pub dims: u32,
|
|
/// Input image resolution
|
|
pub input_resolution: u32,
|
|
/// Whether this is the default model
|
|
pub is_default: bool,
|
|
}
|
|
|
|
/// Available CLIP models registry
|
|
pub static CLIP_MODELS: &[ClipModelInfo] = &[
|
|
// MobileCLIP-S2 int8 quantized (smallest, but requires INT8 ONNX support)
|
|
// Note: INT8 quantized models don't work on all platforms (ConvInteger not supported)
|
|
ClipModelInfo {
|
|
name: "mobileclip-s2-int8",
|
|
vision_url: "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/vision_model_int8.onnx",
|
|
text_url: "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/text_model_int8.onnx",
|
|
tokenizer_url: "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/tokenizer.json",
|
|
vision_size_mb: 36.7,
|
|
text_size_mb: 64.1,
|
|
dims: MOBILECLIP_DIMS,
|
|
input_resolution: MOBILECLIP_INPUT_SIZE,
|
|
is_default: false,
|
|
},
|
|
// Alternative: SigLIP-base quantized (higher quality, but may have INT8 issues)
|
|
ClipModelInfo {
|
|
name: "siglip-base",
|
|
vision_url: "https://huggingface.co/Xenova/siglip-base-patch16-224/resolve/main/onnx/vision_model_quantized.onnx",
|
|
text_url: "https://huggingface.co/Xenova/siglip-base-patch16-224/resolve/main/onnx/text_model_quantized.onnx",
|
|
tokenizer_url: "https://huggingface.co/Xenova/siglip-base-patch16-224/resolve/main/tokenizer.json",
|
|
vision_size_mb: 99.5,
|
|
text_size_mb: 111.0,
|
|
dims: SIGLIP_DIMS,
|
|
input_resolution: SIGLIP_INPUT_SIZE,
|
|
is_default: false,
|
|
},
|
|
// Default: MobileCLIP-S2 fp16 (works on all platforms, good balance of size/quality)
|
|
ClipModelInfo {
|
|
name: "mobileclip-s2",
|
|
vision_url: "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/vision_model_fp16.onnx",
|
|
text_url: "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/text_model_fp16.onnx",
|
|
tokenizer_url: "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/tokenizer.json",
|
|
vision_size_mb: 71.7,
|
|
text_size_mb: 127.0,
|
|
dims: MOBILECLIP_DIMS,
|
|
input_resolution: MOBILECLIP_INPUT_SIZE,
|
|
is_default: true,
|
|
},
|
|
];
|
|
|
|
/// Get model info by name, defaults to mobileclip-s2
|
|
#[must_use]
|
|
pub fn get_model_info(name: &str) -> &'static ClipModelInfo {
|
|
CLIP_MODELS
|
|
.iter()
|
|
.find(|m| m.name == name)
|
|
.unwrap_or_else(|| {
|
|
CLIP_MODELS
|
|
.iter()
|
|
.find(|m| m.is_default)
|
|
.expect("default model")
|
|
})
|
|
}
|
|
|
|
/// Get the default model info
|
|
#[must_use]
|
|
pub fn default_model_info() -> &'static ClipModelInfo {
|
|
CLIP_MODELS
|
|
.iter()
|
|
.find(|m| m.is_default)
|
|
.expect("default model exists")
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLIP Document and Index Types (mirrors vec.rs pattern)
|
|
// ============================================================================
|
|
|
|
/// A document with CLIP embedding stored in the index
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ClipDocument {
|
|
/// Frame ID this embedding belongs to
|
|
pub frame_id: FrameId,
|
|
/// CLIP embedding vector (512 or 768 dims depending on model)
|
|
pub embedding: Vec<f32>,
|
|
/// Optional page number (for PDFs)
|
|
#[serde(default)]
|
|
pub page: Option<u32>,
|
|
}
|
|
|
|
/// Builder for constructing CLIP index artifacts
|
|
#[derive(Default)]
|
|
pub struct ClipIndexBuilder {
|
|
documents: Vec<ClipDocument>,
|
|
}
|
|
|
|
impl ClipIndexBuilder {
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Add a document with its CLIP embedding
|
|
pub fn add_document<I>(&mut self, frame_id: FrameId, page: Option<u32>, embedding: I)
|
|
where
|
|
I: Into<Vec<f32>>,
|
|
{
|
|
self.documents.push(ClipDocument {
|
|
frame_id,
|
|
embedding: embedding.into(),
|
|
page,
|
|
});
|
|
}
|
|
|
|
/// Finish building and produce the index artifact
|
|
pub fn finish(self) -> Result<ClipIndexArtifact> {
|
|
let bytes = bincode::serde::encode_to_vec(&self.documents, clip_config())?;
|
|
|
|
let checksum = *hash(&bytes).as_bytes();
|
|
let dimension = self
|
|
.documents
|
|
.first()
|
|
.map_or(0, |doc| u32::try_from(doc.embedding.len()).unwrap_or(0));
|
|
|
|
Ok(ClipIndexArtifact {
|
|
bytes,
|
|
vector_count: self.documents.len() as u64,
|
|
dimension,
|
|
checksum,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Artifact produced by the CLIP index builder
|
|
#[derive(Debug, Clone)]
|
|
pub struct ClipIndexArtifact {
|
|
/// Serialized index bytes
|
|
pub bytes: Vec<u8>,
|
|
/// Number of vectors in the index
|
|
pub vector_count: u64,
|
|
/// Embedding dimension (512 for `MobileCLIP`, 768 for `SigLIP`)
|
|
pub dimension: u32,
|
|
/// Blake3 checksum of the bytes
|
|
pub checksum: [u8; 32],
|
|
}
|
|
|
|
/// In-memory CLIP index for similarity search
|
|
#[derive(Debug, Clone)]
|
|
pub struct ClipIndex {
|
|
documents: Vec<ClipDocument>,
|
|
}
|
|
|
|
impl Default for ClipIndex {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl ClipIndex {
|
|
/// Create a new empty CLIP index
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
documents: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Add a document with its CLIP embedding
|
|
pub fn add_document<I>(&mut self, frame_id: FrameId, page: Option<u32>, embedding: I)
|
|
where
|
|
I: Into<Vec<f32>>,
|
|
{
|
|
self.documents.push(ClipDocument {
|
|
frame_id,
|
|
embedding: embedding.into(),
|
|
page,
|
|
});
|
|
}
|
|
|
|
/// Decode CLIP index from bytes
|
|
pub fn decode(bytes: &[u8]) -> Result<Self> {
|
|
let (documents, read) = bincode::serde::decode_from_slice::<Vec<ClipDocument>, _>(
|
|
bytes,
|
|
bincode::config::standard()
|
|
.with_fixed_int_encoding()
|
|
.with_little_endian()
|
|
.with_limit::<CLIP_DECODE_LIMIT>(),
|
|
)?;
|
|
|
|
if read != bytes.len() {
|
|
return Err(MemvidError::InvalidToc {
|
|
reason: Cow::Owned(format!(
|
|
"CLIP index decode: expected {} bytes, read {}",
|
|
bytes.len(),
|
|
read
|
|
)),
|
|
});
|
|
}
|
|
|
|
tracing::debug!(
|
|
bytes_len = bytes.len(),
|
|
docs_count = documents.len(),
|
|
"decoded CLIP index"
|
|
);
|
|
|
|
Ok(Self { documents })
|
|
}
|
|
|
|
/// Search for similar embeddings using L2 distance
|
|
#[must_use]
|
|
pub fn search(&self, query: &[f32], limit: usize) -> Vec<ClipSearchHit> {
|
|
if query.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut hits: Vec<ClipSearchHit> = self
|
|
.documents
|
|
.iter()
|
|
.map(|doc| {
|
|
let distance = l2_distance(query, &doc.embedding);
|
|
ClipSearchHit {
|
|
frame_id: doc.frame_id,
|
|
page: doc.page,
|
|
distance,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
hits.sort_by(|a, b| {
|
|
a.distance
|
|
.partial_cmp(&b.distance)
|
|
.unwrap_or(std::cmp::Ordering::Equal)
|
|
});
|
|
hits.truncate(limit);
|
|
hits
|
|
}
|
|
|
|
/// Get all entries in the index
|
|
pub fn entries(&self) -> impl Iterator<Item = (FrameId, Option<u32>, &[f32])> + '_ {
|
|
self.documents
|
|
.iter()
|
|
.map(|doc| (doc.frame_id, doc.page, doc.embedding.as_slice()))
|
|
}
|
|
|
|
/// Get embedding for a specific frame
|
|
#[must_use]
|
|
pub fn embedding_for(&self, frame_id: FrameId) -> Option<&[f32]> {
|
|
self.documents
|
|
.iter()
|
|
.find(|doc| doc.frame_id == frame_id)
|
|
.map(|doc| doc.embedding.as_slice())
|
|
}
|
|
|
|
/// Remove a document from the index
|
|
pub fn remove(&mut self, frame_id: FrameId) {
|
|
self.documents.retain(|doc| doc.frame_id != frame_id);
|
|
}
|
|
|
|
/// Number of documents in the index
|
|
#[must_use]
|
|
pub fn len(&self) -> usize {
|
|
self.documents.len()
|
|
}
|
|
|
|
/// Check if index is empty
|
|
#[must_use]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.documents.is_empty()
|
|
}
|
|
|
|
/// Encode the CLIP index to bytes and produce an artifact for persistence
|
|
pub fn encode(&self) -> Result<ClipIndexArtifact> {
|
|
let bytes = bincode::serde::encode_to_vec(&self.documents, clip_config())?;
|
|
|
|
let checksum = *hash(&bytes).as_bytes();
|
|
let dimension = self
|
|
.documents
|
|
.first()
|
|
.map_or(0, |doc| u32::try_from(doc.embedding.len()).unwrap_or(0));
|
|
|
|
Ok(ClipIndexArtifact {
|
|
bytes,
|
|
vector_count: self.documents.len() as u64,
|
|
dimension,
|
|
checksum,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Search result from CLIP index
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct ClipSearchHit {
|
|
/// Frame ID of the matched document
|
|
pub frame_id: FrameId,
|
|
/// Optional page number (for PDFs)
|
|
pub page: Option<u32>,
|
|
/// L2 distance to query (lower is more similar)
|
|
pub distance: f32,
|
|
}
|
|
|
|
/// L2 (Euclidean) distance between two vectors
|
|
fn l2_distance(a: &[f32], b: &[f32]) -> f32 {
|
|
a.iter()
|
|
.zip(b.iter())
|
|
.map(|(x, y)| (x - y).powi(2))
|
|
.sum::<f32>()
|
|
.sqrt()
|
|
}
|
|
|
|
// ============================================================================
|
|
// Image Filtering (Junk Detection)
|
|
// ============================================================================
|
|
|
|
/// Metadata about an image for filtering
|
|
#[derive(Debug, Clone)]
|
|
pub struct ImageInfo {
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub color_variance: f32,
|
|
}
|
|
|
|
impl ImageInfo {
|
|
/// Check if this image should be processed for CLIP embedding
|
|
#[must_use]
|
|
pub fn should_embed(&self) -> bool {
|
|
// Skip tiny images (icons, bullets)
|
|
if self.width < MIN_IMAGE_DIM || self.height < MIN_IMAGE_DIM {
|
|
return false;
|
|
}
|
|
|
|
// Skip extreme aspect ratios (dividers, lines)
|
|
let aspect = self.width as f32 / self.height as f32;
|
|
if !((1.0 / MAX_ASPECT_RATIO)..=MAX_ASPECT_RATIO).contains(&aspect) {
|
|
return false;
|
|
}
|
|
|
|
// Skip near-solid colors (backgrounds, spacers)
|
|
if self.color_variance < MIN_COLOR_VARIANCE {
|
|
return false;
|
|
}
|
|
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Filter a list of images, keeping only those worth embedding
|
|
pub fn filter_junk_images<T, F>(images: Vec<T>, get_info: F) -> Vec<T>
|
|
where
|
|
F: Fn(&T) -> ImageInfo,
|
|
{
|
|
images
|
|
.into_iter()
|
|
.filter(|img| get_info(img).should_embed())
|
|
.collect()
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLIP Model Configuration
|
|
// ============================================================================
|
|
|
|
/// Configuration for CLIP model initialization
|
|
#[derive(Debug, Clone)]
|
|
pub struct ClipConfig {
|
|
/// Model name (e.g., "mobileclip-s2", "siglip-base")
|
|
pub model_name: String,
|
|
/// Directory where models are cached
|
|
pub models_dir: PathBuf,
|
|
/// Whether to run in offline mode (no downloads)
|
|
pub offline: bool,
|
|
}
|
|
|
|
impl Default for ClipConfig {
|
|
fn default() -> Self {
|
|
// Use ~/.memvid/models as default, consistent with CLI's model installation
|
|
let models_dir = std::env::var("MEMVID_MODELS_DIR")
|
|
.ok()
|
|
.map(PathBuf::from)
|
|
.or_else(|| dirs_next::home_dir().map(|d| d.join(".memvid/models")))
|
|
.unwrap_or_else(|| PathBuf::from(".memvid/models"));
|
|
|
|
let model_name =
|
|
std::env::var("MEMVID_CLIP_MODEL").unwrap_or_else(|_| "mobileclip-s2".to_string());
|
|
|
|
let offline = std::env::var("MEMVID_OFFLINE").is_ok();
|
|
|
|
Self {
|
|
model_name,
|
|
models_dir,
|
|
offline,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLIP Error Types
|
|
// ============================================================================
|
|
|
|
/// CLIP-specific errors
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ClipError {
|
|
/// Model not found and offline mode enabled
|
|
#[error("CLIP model '{model}' not found. {hint}")]
|
|
ModelNotFound { model: String, hint: String },
|
|
|
|
/// Image decode failed
|
|
#[error("Failed to decode image at {path:?}: {cause}")]
|
|
ImageDecodeError { path: PathBuf, cause: String },
|
|
|
|
/// Image bytes decode failed
|
|
#[error("Failed to decode image bytes: {cause}")]
|
|
ImageBytesDecodeError { cause: String },
|
|
|
|
/// ONNX runtime error
|
|
#[error("CLIP inference error: {cause}")]
|
|
InferenceError { cause: String },
|
|
|
|
/// Model download failed
|
|
#[error("Failed to download CLIP model: {cause}")]
|
|
DownloadError { cause: String },
|
|
|
|
/// Model file corrupted or invalid
|
|
#[error("CLIP model file is corrupted: {cause}")]
|
|
ModelCorrupted { cause: String },
|
|
}
|
|
|
|
impl From<ClipError> for MemvidError {
|
|
fn from(err: ClipError) -> Self {
|
|
MemvidError::EmbeddingFailed {
|
|
reason: err.to_string().into_boxed_str(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLIP Model (Feature-gated implementation)
|
|
// ============================================================================
|
|
|
|
#[cfg(feature = "clip")]
|
|
mod model {
|
|
use super::*;
|
|
use image::{DynamicImage, GenericImageView, imageops::FilterType};
|
|
use ndarray::{Array, Array4};
|
|
use ort::session::{Session, builder::GraphOptimizationLevel};
|
|
use ort::value::Tensor;
|
|
use std::sync::Mutex;
|
|
use std::time::Instant;
|
|
use tokenizers::{
|
|
PaddingDirection, PaddingParams, PaddingStrategy, Tokenizer, TruncationDirection,
|
|
TruncationParams, TruncationStrategy,
|
|
};
|
|
|
|
/// CLIP model with lazy-loaded vision and text encoders
|
|
pub struct ClipModel {
|
|
config: ClipConfig,
|
|
model_info: &'static ClipModelInfo,
|
|
/// Lazy-loaded vision encoder session
|
|
vision_session: Mutex<Option<Session>>,
|
|
/// Lazy-loaded text encoder session
|
|
text_session: Mutex<Option<Session>>,
|
|
/// Lazy-loaded tokenizer matching the text encoder
|
|
tokenizer: Mutex<Option<Tokenizer>>,
|
|
/// Last time the model was used (for idle unloading)
|
|
last_used: Mutex<Instant>,
|
|
}
|
|
|
|
impl ClipModel {
|
|
/// Create a new CLIP model with the given configuration
|
|
pub fn new(config: ClipConfig) -> Result<Self> {
|
|
let model_info = get_model_info(&config.model_name);
|
|
|
|
Ok(Self {
|
|
config,
|
|
model_info,
|
|
vision_session: Mutex::new(None),
|
|
text_session: Mutex::new(None),
|
|
tokenizer: Mutex::new(None),
|
|
last_used: Mutex::new(Instant::now()),
|
|
})
|
|
}
|
|
|
|
/// Create with default configuration
|
|
pub fn default_model() -> Result<Self> {
|
|
Self::new(ClipConfig::default())
|
|
}
|
|
|
|
/// Get model info
|
|
pub fn model_info(&self) -> &'static ClipModelInfo {
|
|
self.model_info
|
|
}
|
|
|
|
/// Get embedding dimensions
|
|
pub fn dims(&self) -> u32 {
|
|
self.model_info.dims
|
|
}
|
|
|
|
/// Ensure model file exists, downloading if necessary
|
|
fn ensure_model_file(&self, kind: &str) -> Result<PathBuf> {
|
|
let filename = format!("{}_{}.onnx", self.model_info.name, kind);
|
|
let path = self.config.models_dir.join(&filename);
|
|
|
|
if path.exists() {
|
|
return Ok(path);
|
|
}
|
|
|
|
if self.config.offline {
|
|
return Err(ClipError::ModelNotFound {
|
|
model: self.model_info.name.to_string(),
|
|
hint: format!(
|
|
"Run: memvid model download {} (or disable MEMVID_OFFLINE)",
|
|
self.model_info.name
|
|
),
|
|
}
|
|
.into());
|
|
}
|
|
|
|
// Create models directory if needed
|
|
std::fs::create_dir_all(&self.config.models_dir).map_err(|e| {
|
|
ClipError::DownloadError {
|
|
cause: format!("Failed to create models directory: {}", e),
|
|
}
|
|
})?;
|
|
|
|
// Provide manual download instructions
|
|
Err(ClipError::DownloadError {
|
|
cause: format!(
|
|
"Automatic download not yet implemented. Please download manually:\n\
|
|
curl -L '{}' -o '{}'",
|
|
if kind == "vision" {
|
|
self.model_info.vision_url
|
|
} else {
|
|
self.model_info.text_url
|
|
},
|
|
path.display()
|
|
),
|
|
}
|
|
.into())
|
|
}
|
|
|
|
/// Ensure tokenizer file exists, downloading if necessary
|
|
fn ensure_tokenizer_file(&self) -> Result<PathBuf> {
|
|
let filename = format!("{}_tokenizer.json", self.model_info.name);
|
|
let path = self.config.models_dir.join(&filename);
|
|
|
|
if path.exists() {
|
|
return Ok(path);
|
|
}
|
|
|
|
if self.config.offline {
|
|
return Err(ClipError::ModelNotFound {
|
|
model: self.model_info.name.to_string(),
|
|
hint: format!(
|
|
"Tokenizer missing at {}. Copy tokenizer.json from {}",
|
|
path.display(),
|
|
self.model_info.tokenizer_url
|
|
),
|
|
}
|
|
.into());
|
|
}
|
|
|
|
std::fs::create_dir_all(&self.config.models_dir).map_err(|e| {
|
|
ClipError::DownloadError {
|
|
cause: format!("Failed to create models directory: {}", e),
|
|
}
|
|
})?;
|
|
|
|
Err(ClipError::DownloadError {
|
|
cause: format!(
|
|
"Automatic download not yet implemented. Please download manually:\n\
|
|
curl -L '{}' -o '{}'",
|
|
self.model_info.tokenizer_url,
|
|
path.display()
|
|
),
|
|
}
|
|
.into())
|
|
}
|
|
|
|
/// Load vision session lazily
|
|
fn load_vision_session(&self) -> Result<()> {
|
|
let mut session_guard = self
|
|
.vision_session
|
|
.lock()
|
|
.map_err(|_| MemvidError::Lock("Failed to lock vision session".into()))?;
|
|
|
|
if session_guard.is_some() {
|
|
return Ok(());
|
|
}
|
|
|
|
let vision_path = self.ensure_model_file("vision")?;
|
|
|
|
tracing::debug!(path = %vision_path.display(), "Loading CLIP vision model");
|
|
|
|
let session = Session::builder()
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: e.to_string(),
|
|
})?
|
|
.with_optimization_level(GraphOptimizationLevel::Level3)
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: e.to_string(),
|
|
})?
|
|
.with_intra_threads(4)
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: e.to_string(),
|
|
})?
|
|
.commit_from_file(&vision_path)
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Failed to load vision model: {}", e),
|
|
})?;
|
|
|
|
*session_guard = Some(session);
|
|
tracing::info!(model = %self.model_info.name, "CLIP vision model loaded");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Load text session lazily
|
|
fn load_text_session(&self) -> Result<()> {
|
|
let mut session_guard = self
|
|
.text_session
|
|
.lock()
|
|
.map_err(|_| MemvidError::Lock("Failed to lock text session".into()))?;
|
|
|
|
if session_guard.is_some() {
|
|
return Ok(());
|
|
}
|
|
|
|
let text_path = self.ensure_model_file("text")?;
|
|
|
|
tracing::debug!(path = %text_path.display(), "Loading CLIP text model");
|
|
|
|
let session = Session::builder()
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: e.to_string(),
|
|
})?
|
|
.with_optimization_level(GraphOptimizationLevel::Level3)
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: e.to_string(),
|
|
})?
|
|
.with_intra_threads(4)
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: e.to_string(),
|
|
})?
|
|
.commit_from_file(&text_path)
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Failed to load text model: {}", e),
|
|
})?;
|
|
|
|
*session_guard = Some(session);
|
|
tracing::info!(model = %self.model_info.name, "CLIP text model loaded");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Load tokenizer lazily (matches the text model vocab/BPE)
|
|
fn load_tokenizer(&self) -> Result<()> {
|
|
let mut tokenizer_guard = self
|
|
.tokenizer
|
|
.lock()
|
|
.map_err(|_| MemvidError::Lock("Failed to lock CLIP tokenizer".into()))?;
|
|
|
|
if tokenizer_guard.is_some() {
|
|
return Ok(());
|
|
}
|
|
|
|
let tokenizer_path = self.ensure_tokenizer_file()?;
|
|
|
|
tracing::debug!(path = %tokenizer_path.display(), "Loading CLIP tokenizer");
|
|
|
|
let mut tokenizer =
|
|
Tokenizer::from_file(&tokenizer_path).map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Failed to load tokenizer: {}", e),
|
|
})?;
|
|
|
|
tokenizer.with_padding(Some(PaddingParams {
|
|
strategy: PaddingStrategy::Fixed(77),
|
|
direction: PaddingDirection::Right,
|
|
pad_to_multiple_of: None,
|
|
pad_id: 0,
|
|
pad_type_id: 0,
|
|
pad_token: "[PAD]".to_string(),
|
|
}));
|
|
|
|
tokenizer
|
|
.with_truncation(Some(TruncationParams {
|
|
max_length: 77,
|
|
strategy: TruncationStrategy::LongestFirst,
|
|
stride: 0,
|
|
direction: TruncationDirection::Right,
|
|
}))
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Failed to apply truncation config: {}", e),
|
|
})?;
|
|
|
|
*tokenizer_guard = Some(tokenizer);
|
|
tracing::info!(model = %self.model_info.name, "CLIP tokenizer loaded");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Preprocess image for CLIP inference
|
|
///
|
|
/// MobileCLIP-S2 uses:
|
|
/// - Input size: 256x256
|
|
/// - Resize: shortest edge to 256, preserve aspect, center-crop
|
|
/// - Normalization: scale to [0, 1] (no mean/std shift per preprocessor_config)
|
|
/// - Format: NCHW (batch, channels, height, width)
|
|
fn preprocess_image(&self, image: &DynamicImage) -> Array4<f32> {
|
|
let size = self.model_info.input_resolution;
|
|
let rgb_input = image.to_rgb8();
|
|
let (w, h) = rgb_input.dimensions();
|
|
|
|
// Resize shortest edge to target while preserving aspect ratio
|
|
let scale = size as f32 / w.min(h) as f32;
|
|
let new_w = ((w as f32) * scale).round().max(1.0) as u32;
|
|
let new_h = ((h as f32) * scale).round().max(1.0) as u32;
|
|
let resized = image.resize_exact(new_w, new_h, FilterType::Triangle);
|
|
|
|
// Center crop to (size, size)
|
|
let start_x = (resized.width().saturating_sub(size)) / 2;
|
|
let start_y = (resized.height().saturating_sub(size)) / 2;
|
|
|
|
// Create array in NCHW format: [1, 3, H, W]
|
|
let mut array = Array4::<f32>::zeros((1, 3, size as usize, size as usize));
|
|
|
|
for y in 0..size as usize {
|
|
for x in 0..size as usize {
|
|
let pixel = resized.get_pixel(start_x + x as u32, start_y + y as u32);
|
|
array[[0, 0, y, x]] = pixel[0] as f32 / 255.0;
|
|
array[[0, 1, y, x]] = pixel[1] as f32 / 255.0;
|
|
array[[0, 2, y, x]] = pixel[2] as f32 / 255.0;
|
|
}
|
|
}
|
|
|
|
array
|
|
}
|
|
|
|
/// Encode an image to CLIP embedding
|
|
pub fn encode_image(&self, image: &DynamicImage) -> Result<Vec<f32>> {
|
|
// Ensure vision session is loaded
|
|
self.load_vision_session()?;
|
|
|
|
// Preprocess the image
|
|
let pixel_values = self.preprocess_image(image);
|
|
|
|
// Update last used timestamp
|
|
if let Ok(mut last) = self.last_used.lock() {
|
|
*last = Instant::now();
|
|
}
|
|
|
|
// Run inference
|
|
let mut session_guard = self
|
|
.vision_session
|
|
.lock()
|
|
.map_err(|_| MemvidError::Lock("Failed to lock vision session".into()))?;
|
|
|
|
let session = session_guard
|
|
.as_mut()
|
|
.ok_or_else(|| ClipError::InferenceError {
|
|
cause: "Vision session not loaded".to_string(),
|
|
})?;
|
|
|
|
// Get input and output names from session before running
|
|
let input_name = session
|
|
.inputs
|
|
.first()
|
|
.map(|i| i.name.clone())
|
|
.unwrap_or_else(|| "pixel_values".into());
|
|
let output_name = session
|
|
.outputs
|
|
.first()
|
|
.map(|o| o.name.clone())
|
|
.unwrap_or_else(|| "image_embeds".into());
|
|
|
|
// Create tensor from ndarray
|
|
let input_tensor =
|
|
Tensor::from_array(pixel_values).map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Failed to create input tensor: {}", e),
|
|
})?;
|
|
|
|
// Run the model
|
|
let outputs = session
|
|
.run(ort::inputs![input_name => input_tensor])
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Vision inference failed: {}", e),
|
|
})?;
|
|
|
|
// Extract embeddings from first output
|
|
let output = outputs
|
|
.get(&output_name)
|
|
.ok_or_else(|| ClipError::InferenceError {
|
|
cause: format!("No output '{}' from vision model", output_name),
|
|
})?;
|
|
|
|
let (_shape, data) =
|
|
output
|
|
.try_extract_tensor::<f32>()
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Failed to extract embeddings: {}", e),
|
|
})?;
|
|
|
|
// Get the embedding from the raw data
|
|
let embedding: Vec<f32> = data.to_vec();
|
|
if embedding.iter().any(|v| !v.is_finite()) {
|
|
return Err(ClipError::InferenceError {
|
|
cause: "Vision embedding contains non-finite values".to_string(),
|
|
}
|
|
.into());
|
|
}
|
|
let normalized = l2_normalize(&embedding);
|
|
|
|
tracing::debug!(dims = normalized.len(), "Generated CLIP image embedding");
|
|
|
|
Ok(normalized)
|
|
}
|
|
|
|
/// Encode image bytes to CLIP embedding
|
|
pub fn encode_image_bytes(&self, bytes: &[u8]) -> Result<Vec<f32>> {
|
|
let image =
|
|
image::load_from_memory(bytes).map_err(|e| ClipError::ImageBytesDecodeError {
|
|
cause: e.to_string(),
|
|
})?;
|
|
self.encode_image(&image)
|
|
}
|
|
|
|
/// Encode an image file to CLIP embedding
|
|
pub fn encode_image_file(&self, path: &Path) -> Result<Vec<f32>> {
|
|
let image = image::open(path).map_err(|e| ClipError::ImageDecodeError {
|
|
path: path.to_path_buf(),
|
|
cause: e.to_string(),
|
|
})?;
|
|
self.encode_image(&image)
|
|
}
|
|
|
|
/// Encode text to CLIP embedding (for query)
|
|
pub fn encode_text(&self, text: &str) -> Result<Vec<f32>> {
|
|
// Ensure text session is loaded
|
|
self.load_text_session()?;
|
|
self.load_tokenizer()?;
|
|
|
|
// Tokenize the text using the model's tokenizer
|
|
let encoding = {
|
|
let tokenizer_guard = self
|
|
.tokenizer
|
|
.lock()
|
|
.map_err(|_| MemvidError::Lock("Failed to lock CLIP tokenizer".into()))?;
|
|
let tokenizer =
|
|
tokenizer_guard
|
|
.as_ref()
|
|
.ok_or_else(|| ClipError::InferenceError {
|
|
cause: "Tokenizer not loaded".to_string(),
|
|
})?;
|
|
|
|
tokenizer
|
|
.encode(text, true)
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Text tokenization failed: {}", e),
|
|
})?
|
|
};
|
|
|
|
let input_ids: Vec<i64> = encoding.get_ids().iter().map(|id| *id as i64).collect();
|
|
let attention_mask: Vec<i64> = encoding
|
|
.get_attention_mask()
|
|
.iter()
|
|
.map(|id| *id as i64)
|
|
.collect();
|
|
let max_length = input_ids.len();
|
|
|
|
// Create input arrays
|
|
let input_ids_array =
|
|
Array::from_shape_vec((1, max_length), input_ids).map_err(|e| {
|
|
ClipError::InferenceError {
|
|
cause: e.to_string(),
|
|
}
|
|
})?;
|
|
let attention_mask_array = Array::from_shape_vec((1, max_length), attention_mask)
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: e.to_string(),
|
|
})?;
|
|
|
|
// Update last used timestamp
|
|
if let Ok(mut last) = self.last_used.lock() {
|
|
*last = Instant::now();
|
|
}
|
|
|
|
// Run inference
|
|
let mut session_guard = self
|
|
.text_session
|
|
.lock()
|
|
.map_err(|_| MemvidError::Lock("Failed to lock text session".into()))?;
|
|
|
|
let session = session_guard
|
|
.as_mut()
|
|
.ok_or_else(|| ClipError::InferenceError {
|
|
cause: "Text session not loaded".to_string(),
|
|
})?;
|
|
|
|
// Get input and output names from session before running
|
|
let input_names: Vec<String> = session.inputs.iter().map(|i| i.name.clone()).collect();
|
|
let output_name = session
|
|
.outputs
|
|
.first()
|
|
.map(|o| o.name.clone())
|
|
.unwrap_or_else(|| "text_embeds".into());
|
|
|
|
// Create tensors from ndarray
|
|
let input_ids_tensor =
|
|
Tensor::from_array(input_ids_array).map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Failed to create input_ids tensor: {}", e),
|
|
})?;
|
|
let attention_mask_tensor = Tensor::from_array(attention_mask_array).map_err(|e| {
|
|
ClipError::InferenceError {
|
|
cause: format!("Failed to create attention_mask tensor: {}", e),
|
|
}
|
|
})?;
|
|
|
|
// Build inputs based on what the model expects
|
|
let outputs = if input_names.len() >= 2 {
|
|
session
|
|
.run(ort::inputs![
|
|
input_names[0].clone() => input_ids_tensor,
|
|
input_names[1].clone() => attention_mask_tensor
|
|
])
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Text inference failed: {}", e),
|
|
})?
|
|
} else {
|
|
// Single input model
|
|
let name = input_names
|
|
.first()
|
|
.cloned()
|
|
.unwrap_or_else(|| "input_ids".to_string());
|
|
session
|
|
.run(ort::inputs![name => input_ids_tensor])
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Text inference failed: {}", e),
|
|
})?
|
|
};
|
|
|
|
// Extract embeddings from output
|
|
let output = outputs
|
|
.get(&output_name)
|
|
.ok_or_else(|| ClipError::InferenceError {
|
|
cause: format!("No output '{}' from text model", output_name),
|
|
})?;
|
|
|
|
let (_shape, data) =
|
|
output
|
|
.try_extract_tensor::<f32>()
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Failed to extract text embeddings: {}", e),
|
|
})?;
|
|
|
|
// Flatten and normalize the embedding
|
|
let embedding: Vec<f32> = data.to_vec();
|
|
if embedding.iter().any(|v| !v.is_finite()) {
|
|
return Err(ClipError::InferenceError {
|
|
cause: "Text embedding contains non-finite values".to_string(),
|
|
}
|
|
.into());
|
|
}
|
|
let normalized = l2_normalize(&embedding);
|
|
|
|
tracing::debug!(
|
|
text_len = text.len(),
|
|
dims = normalized.len(),
|
|
"Generated CLIP text embedding"
|
|
);
|
|
|
|
Ok(normalized)
|
|
}
|
|
|
|
/// Maybe unload model if unused for too long (memory management)
|
|
pub fn maybe_unload(&self) -> Result<()> {
|
|
let last_used = self
|
|
.last_used
|
|
.lock()
|
|
.map_err(|_| MemvidError::Lock("Failed to check last_used".into()))?;
|
|
|
|
if last_used.elapsed() > MODEL_UNLOAD_TIMEOUT {
|
|
tracing::debug!(model = %self.model_info.name, "Model idle, unloading sessions");
|
|
|
|
// Unload vision session
|
|
if let Ok(mut guard) = self.vision_session.lock() {
|
|
*guard = None;
|
|
}
|
|
|
|
// Unload text session
|
|
if let Ok(mut guard) = self.text_session.lock() {
|
|
*guard = None;
|
|
}
|
|
|
|
// Unload tokenizer
|
|
if let Ok(mut guard) = self.tokenizer.lock() {
|
|
*guard = None;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Force unload all sessions
|
|
pub fn unload(&self) -> Result<()> {
|
|
if let Ok(mut guard) = self.vision_session.lock() {
|
|
*guard = None;
|
|
}
|
|
if let Ok(mut guard) = self.text_session.lock() {
|
|
*guard = None;
|
|
}
|
|
if let Ok(mut guard) = self.tokenizer.lock() {
|
|
*guard = None;
|
|
}
|
|
tracing::debug!(model = %self.model_info.name, "CLIP sessions unloaded");
|
|
Ok(())
|
|
}
|
|
|
|
/// Check if vision model is loaded
|
|
pub fn is_vision_loaded(&self) -> bool {
|
|
self.vision_session
|
|
.lock()
|
|
.map(|g| g.is_some())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Check if text model is loaded
|
|
pub fn is_text_loaded(&self) -> bool {
|
|
self.text_session
|
|
.lock()
|
|
.map(|g| g.is_some())
|
|
.unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
/// L2 normalize a vector (unit length)
|
|
fn l2_normalize(v: &[f32]) -> Vec<f32> {
|
|
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
|
if norm.is_finite() && norm > 1e-10 {
|
|
v.iter().map(|x| x / norm).collect()
|
|
} else {
|
|
// Fall back to zeros to avoid NaNs propagating through distances
|
|
vec![0.0; v.len()]
|
|
}
|
|
}
|
|
|
|
/// Calculate color variance of an image
|
|
pub fn calculate_color_variance(image: &DynamicImage) -> f32 {
|
|
let rgb = image.to_rgb8();
|
|
let (width, height) = rgb.dimensions();
|
|
let total_pixels = (width * height) as f32;
|
|
|
|
if total_pixels == 0.0 {
|
|
return 0.0;
|
|
}
|
|
|
|
// Calculate mean
|
|
let mut sum_r = 0.0f32;
|
|
let mut sum_g = 0.0f32;
|
|
let mut sum_b = 0.0f32;
|
|
|
|
for pixel in rgb.pixels() {
|
|
sum_r += pixel[0] as f32;
|
|
sum_g += pixel[1] as f32;
|
|
sum_b += pixel[2] as f32;
|
|
}
|
|
|
|
let mean_r = sum_r / total_pixels;
|
|
let mean_g = sum_g / total_pixels;
|
|
let mean_b = sum_b / total_pixels;
|
|
|
|
// Calculate variance
|
|
let mut var_r = 0.0f32;
|
|
let mut var_g = 0.0f32;
|
|
let mut var_b = 0.0f32;
|
|
|
|
for pixel in rgb.pixels() {
|
|
var_r += (pixel[0] as f32 - mean_r).powi(2);
|
|
var_g += (pixel[1] as f32 - mean_g).powi(2);
|
|
var_b += (pixel[2] as f32 - mean_b).powi(2);
|
|
}
|
|
|
|
// Average variance across channels, normalized to 0-1
|
|
((var_r + var_g + var_b) / (3.0 * total_pixels)) / (255.0 * 255.0)
|
|
}
|
|
|
|
/// Get ImageInfo from a DynamicImage
|
|
pub fn get_image_info(image: &DynamicImage) -> ImageInfo {
|
|
let (width, height) = image.dimensions();
|
|
let color_variance = calculate_color_variance(image);
|
|
|
|
ImageInfo {
|
|
width,
|
|
height,
|
|
color_variance,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "clip")]
|
|
pub use model::*;
|
|
|
|
#[cfg(all(feature = "clip", feature = "pdfium"))]
|
|
use pdfium_render::prelude::{PdfPageRenderRotation, PdfRenderConfig, Pdfium};
|
|
|
|
/// Render PDF pages to images suitable for CLIP embedding (feature-gated).
|
|
#[cfg(all(feature = "clip", feature = "pdfium"))]
|
|
pub fn render_pdf_pages_for_clip(
|
|
path: &Path,
|
|
max_pages: usize,
|
|
target_px: u32,
|
|
) -> Result<Vec<(u32, DynamicImage)>> {
|
|
let bindings = Pdfium::bind_to_system_library().map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Failed to bind pdfium: {}", e),
|
|
})?;
|
|
let pdfium = Pdfium::new(bindings);
|
|
let document =
|
|
pdfium
|
|
.load_pdf_from_file(path, None)
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Failed to load PDF for CLIP rendering: {}", e),
|
|
})?;
|
|
|
|
let render_config = PdfRenderConfig::new()
|
|
.set_target_width(target_px as i32)
|
|
.set_maximum_height(target_px as i32)
|
|
.set_maximum_width(target_px as i32)
|
|
.rotate_if_landscape(PdfPageRenderRotation::None, false);
|
|
|
|
let mut pages = Vec::new();
|
|
for (index, page) in document.pages().iter().enumerate() {
|
|
if index >= max_pages {
|
|
break;
|
|
}
|
|
let rendered = page
|
|
.render_with_config(&render_config)
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Failed to render PDF page {}: {}", index + 1, e),
|
|
})?
|
|
.as_image();
|
|
pages.push(((index + 1) as u32, rendered));
|
|
}
|
|
|
|
Ok(pages)
|
|
}
|
|
|
|
#[cfg(all(feature = "clip", not(feature = "pdfium")))]
|
|
pub fn render_pdf_pages_for_clip(
|
|
path: &Path,
|
|
max_pages: usize,
|
|
_target_px: u32,
|
|
) -> Result<Vec<(u32, DynamicImage)>> {
|
|
fn extract_images_from_page(
|
|
doc: &Document,
|
|
page_id: ObjectId,
|
|
remaining: &mut usize,
|
|
out: &mut Vec<(u32, DynamicImage)>,
|
|
) -> Result<()> {
|
|
if *remaining == 0 {
|
|
return Ok(());
|
|
}
|
|
|
|
let (resources_opt, resource_ids) =
|
|
doc.get_page_resources(page_id)
|
|
.map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Failed to read PDF resources: {}", e),
|
|
})?;
|
|
|
|
let mut seen = HashSet::new();
|
|
let mut resource_dicts: Vec<Dictionary> = Vec::new();
|
|
|
|
if let Some(dict) = resources_opt {
|
|
resource_dicts.push(dict.clone());
|
|
}
|
|
for res_id in resource_ids {
|
|
if seen.insert(res_id) {
|
|
if let Ok(dict) = doc.get_dictionary(res_id) {
|
|
resource_dicts.push(dict.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
for dict in resource_dicts {
|
|
if let Ok(xobjects) = dict.get(b"XObject") {
|
|
let xobj_dict = match xobjects {
|
|
Object::Dictionary(d) => Some(d),
|
|
Object::Reference(id) => doc.get_dictionary(*id).ok(),
|
|
_ => None,
|
|
};
|
|
if let Some(xobj_dict) = xobj_dict {
|
|
for (_, obj) in xobj_dict.iter() {
|
|
let id = match obj {
|
|
Object::Reference(id) => *id,
|
|
_ => continue,
|
|
};
|
|
let stream = match doc.get_object(id).and_then(Object::as_stream) {
|
|
Ok(s) => s,
|
|
Err(_) => continue,
|
|
};
|
|
let subtype = stream.dict.get(b"Subtype").ok();
|
|
let is_image = matches!(subtype, Some(Object::Name(n)) if n == b"Image");
|
|
if !is_image {
|
|
continue;
|
|
}
|
|
|
|
let width = stream
|
|
.dict
|
|
.get(b"Width")
|
|
.ok()
|
|
.and_then(|o| o.as_i64().ok())
|
|
.unwrap_or(0);
|
|
let height = stream
|
|
.dict
|
|
.get(b"Height")
|
|
.ok()
|
|
.and_then(|o| o.as_i64().ok())
|
|
.unwrap_or(0);
|
|
if width <= 0 || height <= 0 {
|
|
continue;
|
|
}
|
|
|
|
let filters = stream
|
|
.dict
|
|
.get(b"Filter")
|
|
.ok()
|
|
.and_then(|f| match f {
|
|
Object::Name(n) => Some(vec![n.clone()]),
|
|
Object::Array(arr) => Some(
|
|
arr.iter()
|
|
.filter_map(|o| o.as_name().ok().map(|n| n.to_vec()))
|
|
.collect(),
|
|
),
|
|
_ => None,
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let data = stream
|
|
.decompressed_content()
|
|
.unwrap_or_else(|_| stream.content.clone());
|
|
|
|
// If DCT/JPX, hand to image crate directly
|
|
if filters
|
|
.iter()
|
|
.any(|f| f == b"DCTDecode" || f == b"JPXDecode")
|
|
{
|
|
if let Ok(img) = image::load_from_memory(&data) {
|
|
out.push((1, img));
|
|
if out.len() >= *remaining {
|
|
*remaining = 0;
|
|
return Ok(());
|
|
}
|
|
*remaining -= 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
let color_space = stream
|
|
.dict
|
|
.get(b"ColorSpace")
|
|
.ok()
|
|
.and_then(|o| o.as_name().ok())
|
|
.unwrap_or(b"DeviceRGB");
|
|
let channels = if color_space == b"DeviceGray" { 1 } else { 3 };
|
|
|
|
let expected = width as usize * height as usize * channels;
|
|
if data.len() >= expected && channels == 3 {
|
|
if let Some(buf) = ImageBuffer::<Rgb<u8>, _>::from_raw(
|
|
width as u32,
|
|
height as u32,
|
|
data.clone(),
|
|
) {
|
|
out.push((1, DynamicImage::ImageRgb8(buf)));
|
|
if out.len() >= *remaining {
|
|
*remaining = 0;
|
|
return Ok(());
|
|
}
|
|
*remaining -= 1;
|
|
continue;
|
|
}
|
|
} else if data.len() >= expected && channels == 1 {
|
|
if let Some(buf) = ImageBuffer::<Luma<u8>, _>::from_raw(
|
|
width as u32,
|
|
height as u32,
|
|
data.clone(),
|
|
) {
|
|
out.push((1, DynamicImage::ImageLuma8(buf)));
|
|
if out.len() >= *remaining {
|
|
*remaining = 0;
|
|
return Ok(());
|
|
}
|
|
*remaining -= 1;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
let doc = Document::load(path).map_err(|e| ClipError::InferenceError {
|
|
cause: format!("Failed to load PDF for image extraction: {}", e),
|
|
})?;
|
|
|
|
let mut remaining = max_pages;
|
|
let mut pages: Vec<(u32, DynamicImage)> = Vec::new();
|
|
|
|
for (page_num, page_id) in doc.get_pages() {
|
|
if remaining == 0 {
|
|
break;
|
|
}
|
|
let start_len = pages.len();
|
|
extract_images_from_page(&doc, page_id, &mut remaining, &mut pages)?;
|
|
if pages.len() > start_len {
|
|
for entry in pages.iter_mut().skip(start_len) {
|
|
entry.0 = page_num as u32;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(pages)
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLIP Embedding Provider Trait
|
|
// ============================================================================
|
|
|
|
/// Trait for CLIP visual embedding providers.
|
|
///
|
|
/// Unlike text `EmbeddingProvider`, CLIP providers handle both:
|
|
/// - **Image encoding**: Generate embeddings from images (for indexing)
|
|
/// - **Text encoding**: Generate embeddings from text (for queries)
|
|
///
|
|
/// This allows natural language queries against visual content.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```ignore
|
|
/// use memvid_core::clip::{ClipEmbeddingProvider, ClipConfig};
|
|
///
|
|
/// // Create provider
|
|
/// let provider = ClipModel::new(ClipConfig::default())?;
|
|
///
|
|
/// // Encode image for indexing
|
|
/// let image_embedding = provider.embed_image_file(&path)?;
|
|
///
|
|
/// // Encode query text for search
|
|
/// let query_embedding = provider.embed_query("a photo of a cat")?;
|
|
///
|
|
/// // Search uses cosine similarity between query and image embeddings
|
|
/// ```
|
|
pub trait ClipEmbeddingProvider: Send + Sync {
|
|
/// Return the provider kind (e.g., "mobileclip", "siglip").
|
|
fn kind(&self) -> &str;
|
|
|
|
/// Return the model identifier.
|
|
fn model(&self) -> &str;
|
|
|
|
/// Return the embedding dimension.
|
|
fn dimension(&self) -> usize;
|
|
|
|
/// Generate an embedding for an image file.
|
|
fn embed_image_file(&self, path: &Path) -> Result<Vec<f32>>;
|
|
|
|
/// Generate an embedding for image bytes.
|
|
fn embed_image_bytes(&self, bytes: &[u8]) -> Result<Vec<f32>>;
|
|
|
|
/// Generate an embedding for a text query (for searching).
|
|
fn embed_query(&self, text: &str) -> Result<Vec<f32>>;
|
|
|
|
/// Generate embeddings for multiple image files.
|
|
///
|
|
/// Default implementation calls `embed_image_file` in a loop.
|
|
/// Providers should override this for efficient batch processing.
|
|
fn embed_image_batch(&self, paths: &[&Path]) -> Result<Vec<Vec<f32>>> {
|
|
let mut embeddings = Vec::with_capacity(paths.len());
|
|
for path in paths {
|
|
embeddings.push(self.embed_image_file(path)?);
|
|
}
|
|
Ok(embeddings)
|
|
}
|
|
|
|
/// Check if the provider is ready to generate embeddings.
|
|
fn is_ready(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
/// Initialize the provider (e.g., load models).
|
|
fn init(&mut self) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Unload models to free memory.
|
|
fn unload(&self) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Result type for CLIP embedding operations
|
|
pub type ClipEmbeddingResult = Result<Vec<f32>>;
|
|
pub type ClipBatchEmbeddingResult = Result<Vec<Vec<f32>>>;
|
|
|
|
// ============================================================================
|
|
// ClipEmbeddingProvider Implementation (Feature-gated)
|
|
// ============================================================================
|
|
|
|
#[cfg(feature = "clip")]
|
|
impl ClipEmbeddingProvider for ClipModel {
|
|
fn kind(&self) -> &str {
|
|
"clip"
|
|
}
|
|
|
|
fn model(&self) -> &str {
|
|
self.model_info().name
|
|
}
|
|
|
|
fn dimension(&self) -> usize {
|
|
self.model_info().dims as usize
|
|
}
|
|
|
|
fn embed_image_file(&self, path: &Path) -> Result<Vec<f32>> {
|
|
self.encode_image_file(path)
|
|
}
|
|
|
|
fn embed_image_bytes(&self, bytes: &[u8]) -> Result<Vec<f32>> {
|
|
self.encode_image_bytes(bytes)
|
|
}
|
|
|
|
fn embed_query(&self, text: &str) -> Result<Vec<f32>> {
|
|
self.encode_text(text)
|
|
}
|
|
|
|
fn embed_image_batch(&self, paths: &[&Path]) -> Result<Vec<Vec<f32>>> {
|
|
let mut embeddings = Vec::with_capacity(paths.len());
|
|
for path in paths {
|
|
embeddings.push(self.encode_image_file(path)?);
|
|
}
|
|
Ok(embeddings)
|
|
}
|
|
|
|
fn is_ready(&self) -> bool {
|
|
// CLIP models are lazy-loaded, so always "ready"
|
|
true
|
|
}
|
|
|
|
fn unload(&self) -> Result<()> {
|
|
ClipModel::unload(self)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLIP Index Manifest (for TOC)
|
|
// ============================================================================
|
|
|
|
/// Manifest for CLIP index stored in TOC
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ClipIndexManifest {
|
|
/// Byte offset in file
|
|
pub bytes_offset: u64,
|
|
/// Length in bytes
|
|
pub bytes_length: u64,
|
|
/// Number of vectors
|
|
pub vector_count: u64,
|
|
/// Embedding dimensions
|
|
pub dimension: u32,
|
|
/// Blake3 checksum
|
|
pub checksum: [u8; 32],
|
|
/// Model name used to generate embeddings
|
|
pub model_name: String,
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tests
|
|
// ============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn clip_index_builder_roundtrip() {
|
|
let mut builder = ClipIndexBuilder::new();
|
|
builder.add_document(1, None, vec![0.1, 0.2, 0.3, 0.4]);
|
|
builder.add_document(2, None, vec![0.5, 0.6, 0.7, 0.8]);
|
|
|
|
let artifact = builder.finish().expect("finish");
|
|
assert_eq!(artifact.vector_count, 2);
|
|
assert_eq!(artifact.dimension, 4);
|
|
|
|
let index = ClipIndex::decode(&artifact.bytes).expect("decode");
|
|
assert_eq!(index.len(), 2);
|
|
|
|
let hits = index.search(&[0.1, 0.2, 0.3, 0.4], 10);
|
|
assert_eq!(hits[0].frame_id, 1);
|
|
assert!(hits[0].distance < 0.001); // Should be very close
|
|
}
|
|
|
|
#[test]
|
|
fn clip_index_search() {
|
|
let mut builder = ClipIndexBuilder::new();
|
|
builder.add_document(1, None, vec![1.0, 0.0, 0.0]);
|
|
builder.add_document(2, None, vec![0.0, 1.0, 0.0]);
|
|
builder.add_document(3, None, vec![0.0, 0.0, 1.0]);
|
|
|
|
let artifact = builder.finish().expect("finish");
|
|
let index = ClipIndex::decode(&artifact.bytes).expect("decode");
|
|
|
|
// Search for [1, 0, 0] - should find frame 1 first
|
|
let hits = index.search(&[1.0, 0.0, 0.0], 3);
|
|
assert_eq!(hits[0].frame_id, 1);
|
|
|
|
// Search for [0, 1, 0] - should find frame 2 first
|
|
let hits = index.search(&[0.0, 1.0, 0.0], 3);
|
|
assert_eq!(hits[0].frame_id, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn l2_distance_calculation() {
|
|
let d = l2_distance(&[0.0, 0.0], &[3.0, 4.0]);
|
|
assert!((d - 5.0).abs() < 1e-6);
|
|
|
|
let d = l2_distance(&[1.0, 1.0, 1.0], &[1.0, 1.0, 1.0]);
|
|
assert!(d.abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn image_info_filtering() {
|
|
// Tiny image - should skip
|
|
let tiny = ImageInfo {
|
|
width: 32,
|
|
height: 32,
|
|
color_variance: 0.5,
|
|
};
|
|
assert!(!tiny.should_embed());
|
|
|
|
// Good image
|
|
let good = ImageInfo {
|
|
width: 256,
|
|
height: 256,
|
|
color_variance: 0.5,
|
|
};
|
|
assert!(good.should_embed());
|
|
|
|
// Extreme aspect ratio
|
|
let wide = ImageInfo {
|
|
width: 1000,
|
|
height: 10,
|
|
color_variance: 0.5,
|
|
};
|
|
assert!(!wide.should_embed());
|
|
|
|
// Solid color
|
|
let solid = ImageInfo {
|
|
width: 256,
|
|
height: 256,
|
|
color_variance: 0.001,
|
|
};
|
|
assert!(!solid.should_embed());
|
|
}
|
|
|
|
#[test]
|
|
fn model_registry() {
|
|
let default = default_model_info();
|
|
assert_eq!(default.name, "mobileclip-s2");
|
|
assert_eq!(default.dims, 512);
|
|
assert!(default.is_default);
|
|
|
|
let siglip = get_model_info("siglip-base");
|
|
assert_eq!(siglip.dims, 768);
|
|
|
|
// Unknown model returns default
|
|
let unknown = get_model_info("nonexistent");
|
|
assert_eq!(unknown.name, "mobileclip-s2");
|
|
}
|
|
|
|
#[test]
|
|
fn clip_config_defaults() {
|
|
// Clear the env vars to test true defaults
|
|
// SAFETY: No other threads are modifying these env vars in this test
|
|
unsafe {
|
|
std::env::remove_var("MEMVID_CLIP_MODEL");
|
|
std::env::remove_var("MEMVID_OFFLINE");
|
|
}
|
|
|
|
let config = ClipConfig::default();
|
|
assert_eq!(config.model_name, "mobileclip-s2");
|
|
assert!(!config.offline);
|
|
}
|
|
|
|
#[test]
|
|
fn clip_embedding_provider_trait() {
|
|
// Test that the trait is properly defined
|
|
fn assert_send_sync<T: Send + Sync>() {}
|
|
|
|
// The trait should require Send + Sync
|
|
assert_send_sync::<Box<dyn super::ClipEmbeddingProvider>>();
|
|
}
|
|
}
|