1"""CLIP embeddings for cross-modal image-text retrieval.
2
3This module provides CLIP (Contrastive Language-Image Pre-training) embeddings
4that create aligned vector spaces for images and text, enabling:
5- Text-to-image search
6- Image-to-text search
7- Image similarity search
8- Zero-shot image classification
9"""
10
11from __future__ import annotations
12
13from pathlib import Path
14import sys
15from typing import TYPE_CHECKING, Any, cast
16
17if TYPE_CHECKING:
18 from PIL.Image import Image as PILImage
19
20 PILImageType = PILImage
21else:
22 PILImageType: Any = Any
23
24# Runtime optional dependency enforcement: imports happen lazily inside the
25# CLIPEmbedding constructor to avoid import-time failures in environments
26# where heavy ML packages are not installed. Use helper to give actionable
27# error messages when required packages are missing.
28
29from lexigram.ai.rag.deps import MissingOptionalDependencyError, ensure_packages
30
31
32def __getattr__(name: str) -> Any:
33 """Allow module-level attribute assignments for lazy optional imports.
34
35 This satisfies mypy while still allowing runtime lazy loading of CLIP
36 dependencies. The actual attributes (CLIPModel, CLIPProcessor, torch,
37 Image) are set at runtime in the constructor.
38 """
39 if name in {"CLIPModel", "CLIPProcessor", "torch", "Image"}:
40 try:
41 return sys.modules[__name__].__dict__[name]
42 except KeyError:
43 pass
44 msg = f"module {__name__!r} has no attribute {name!r}"
45 raise AttributeError(msg)
46
47
48# Module-level placeholders populated at runtime when the packages are available
49torch: Any = None
50Image: Any = None
51CLIPModel: Any | None = None
52CLIPProcessor: Any | None = None
53
54# Backwards-compat shim for existing tests that patch this variable
55CLIP_AVAILABLE = False
56
57from lexigram.ai.rag.exceptions import CLIPEmbeddingError
58from lexigram.ai.rag.multimodal.types import ImageDocument
59
60
61class CLIPEmbedding:
62 """CLIP embeddings for images and text.
63
64 Uses OpenAI's CLIP model to create aligned embeddings for images and text
65 in the same vector space, enabling cross-modal retrieval.
66
67 Args:
68 model_name: CLIP model name from HuggingFace
69 device: Device to run model on ("cpu", "cuda", "mps")
70 batch_size: Batch size for processing multiple items
71
72 Example:
73 >>> embedder = CLIPEmbedding()
74 >>> # Embed text
75 >>> text_emb = await embedder.embed_text("a photo of a cat")
76 >>> # Embed image
77 >>> img_emb = await embedder.embed_image(image_doc)
78 >>> # Compute similarity
79 >>> similarity = cosine_similarity(text_emb, img_emb)
80 """
81
82 def __init__(
83 self,
84 model_name: str = "openai/clip-vit-base-patch32",
85 device: str | None = None,
86 batch_size: int = 32,
87 ):
88 """Initialize CLIP embedder."""
89 # If test suite or older callers set CLIP_AVAILABLE to True, honor that
90 # and avoid importing the heavy runtime packages (they will be patched
91 # in tests). Otherwise, ensure the optional packages are present and
92 # perform lazy imports.
93 mod = sys.modules[__name__]
94 if not CLIP_AVAILABLE:
95 try:
96 _ = ensure_packages(
97 ["transformers", "torch", "PIL"],
98 hint="pip install 'lexigram-ai[llm]' or 'lexigram-ai[all]'",
99 )
100 except MissingOptionalDependencyError as e:
101 msg = (
102 "CLIP embeddings require transformers, torch, and pillow. "
103 "Install with: pip install 'lexigram-ai[llm]' or 'lexigram-ai[all]'."
104 )
105 raise ImportError(msg) from e
106
107 # Perform actual imports now that availability is asserted.
108 # Wrap imports and re-raise a consistent ImportError so tests and
109 # callers see a helpful message rather than package internals.
110 try:
111 from PIL import Image as _Image
112 import torch as _torch
113 from transformers import (
114 CLIPModel as _CLIPModel,
115 )
116 from transformers import CLIPProcessor as _CLIPProcessor
117 except Exception as e:
118 msg = (
119 "CLIP embeddings require transformers, torch, and pillow. "
120 "Install with: pip install 'lexigram-ai[llm]' or 'lexigram-ai[all]'."
121 )
122 raise ImportError(msg) from e
123
124 mod.CLIPModel = _CLIPModel # type: ignore[attr-defined]
125 mod.CLIPProcessor = _CLIPProcessor # type: ignore[attr-defined]
126 # Also update the local module-level bindings so that subsequent
127 # references (e.g. `torch.cuda`) work correctly within this process.
128 torch = _torch
129 Image = _Image
130 else:
131 # CLIP_AVAILABLE True -> assume tests or caller patched module-level
132 # symbols (CLIPModel/CLIPProcessor/torch/Image). If one is missing,
133 # let normal attribute access/patching handle the failure in tests.
134 torch = sys.modules.get("lexigram.ai.rag.multimodal.embeddings.clip").torch # type: ignore[union-attr]
135
136 self.model_name = model_name
137 self.batch_size = batch_size
138
139 # Auto-detect device
140 if device is None:
141 cuda_mod = getattr(torch, "cuda", None)
142 mps_mod = getattr(getattr(torch, "backends", None), "mps", None)
143 if cuda_mod and getattr(cuda_mod, "is_available", lambda: False)():
144 device = "cuda"
145 elif mps_mod and getattr(mps_mod, "is_available", lambda: False)():
146 device = "mps"
147 else:
148 device = "cpu"
149 self.device = device
150
151 # Load model and processor
152 self._model: Any | None = None
153 self._processor: Any | None = None
154 self._load_model()
155
156 def _load_model(self) -> Any:
157 """Load CLIP model and processor."""
158 try:
159 self._processor = CLIPProcessor.from_pretrained(self.model_name) # type: ignore[union-attr]
160 self._model = CLIPModel.from_pretrained(self.model_name) # type: ignore[union-attr]
161 self._model.to(self.device)
162 self._model.eval()
163 except Exception as e:
164 msg = f"Failed to load CLIP model: {e}"
165 raise CLIPEmbeddingError(msg) from e
166
167 async def embed_text(
168 self,
169 text: str | list[str],
170 ) -> list[float] | list[list[float]]:
171 """Embed text using CLIP text encoder.
172
173 Args:
174 text: Single text string or list of texts
175
176 Returns:
177 Embedding vector(s) of dimension 512
178 """
179 if not self._model or not self._processor:
180 msg = "CLIP model not loaded or processor missing"
181 raise CLIPEmbeddingError(msg)
182
183 # Ensure list
184 is_single = isinstance(text, str)
185 texts = [text] if is_single else text
186
187 try:
188 # Process text
189 inputs = self._processor(
190 text=texts,
191 return_tensors="pt",
192 padding=True,
193 truncation=True,
194 )
195 inputs = {k: v.to(self.device) for k, v in inputs.items()}
196
197 # Get embeddings
198 with torch.no_grad():
199 text_features = self._model.get_text_features(**inputs)
200
201 # Normalize embeddings
202 text_features = text_features / text_features.norm(dim=-1, keepdim=True)
203
204 # Convert to list
205 embeddings = text_features.cpu().numpy().tolist()
206
207 # Return single embedding or list
208 return embeddings[0] if is_single else embeddings
209
210 except Exception as e:
211 msg = f"Failed to embed text: {e}"
212 raise CLIPEmbeddingError(msg) from e
213
214 async def embed_image(
215 self,
216 image: (
217 ImageDocument
218 | PILImageType
219 | str
220 | Path
221 | list[ImageDocument | PILImageType | str | Path]
222 ),
223 ) -> list[float] | list[list[float]]:
224 """Embed image using CLIP image encoder.
225
226 Args:
227 image: ImageDocument, PIL Image, file path, or list of any
228
229 Returns:
230 Embedding vector(s) of dimension 512
231 """
232 if not self._model or not self._processor:
233 msg = "CLIP model not loaded or processor missing"
234 raise CLIPEmbeddingError(msg)
235
236 # Normalize to a list for processing
237 images: list[ImageDocument | PILImageType | str | Path]
238 if isinstance(image, list):
239 images = image
240 is_single = False
241 else:
242 images = [image]
243 is_single = True
244
245 try:
246 # Load images
247 pil_images = []
248 for img in images:
249 pil_img = self._load_image(img)
250 pil_images.append(pil_img)
251
252 # Process images
253 inputs = self._processor(
254 images=pil_images,
255 return_tensors="pt",
256 )
257 inputs = {k: v.to(self.device) for k, v in inputs.items()}
258
259 # Get embeddings
260 with torch.no_grad():
261 image_features = self._model.get_image_features(**inputs)
262
263 # Normalize embeddings
264 image_features = image_features / image_features.norm(
265 dim=-1,
266 keepdim=True,
267 )
268
269 # Convert to list
270 embeddings = image_features.cpu().numpy().tolist()
271
272 # Return single embedding or list
273 return embeddings[0] if is_single else embeddings
274
275 except Exception as e:
276 msg = f"Failed to embed image: {e}"
277 raise CLIPEmbeddingError(msg) from e
278
279 async def embed_batch(
280 self,
281 texts: list[str] | None = None,
282 images: list[ImageDocument | PILImage | str | Path] | None = None,
283 ) -> dict:
284 """Embed batches of texts and/or images.
285
286 Args:
287 texts: Optional list of texts to embed
288 images: Optional list of images to embed
289
290 Returns:
291 Dictionary with "text_embeddings" and/or "image_embeddings"
292 """
293 result = {}
294
295 if texts:
296 result["text_embeddings"] = await self.embed_text(texts)
297
298 if images:
299 result["image_embeddings"] = await self.embed_image(images)
300
301 return result
302
303 def _load_image(
304 self,
305 image: ImageDocument | PILImageType | str | Path,
306 ) -> PILImageType:
307 """Load image from various sources.
308
309 Args:
310 image: ImageDocument, PIL Image, or file path
311
312 Returns:
313 PIL Image
314 """
315 # Check if it's a PIL Image by checking module and class name
316 if (
317 hasattr(image, "__class__")
318 and image.__class__.__module__.startswith("PIL")
319 and image.__class__.__name__ == "Image"
320 ):
321 return cast("PILImageType", image)
322
323 if isinstance(image, ImageDocument):
324 # Load from ImageDocument
325 if isinstance(image.content, bytes):
326 import io
327
328 return cast("PILImageType", Image.open(io.BytesIO(image.content)))
329 return cast("PILImageType", Image.open(image.content))
330
331 if isinstance(image, (str, Path)):
332 # Load from file path
333 return cast("PILImageType", Image.open(image))
334
335 msg = f"Unsupported image type: {type(image)}"
336 raise CLIPEmbeddingError(msg)
337
338 def get_embedding_dimension(self) -> int:
339 """Get the dimension of CLIP embeddings.
340
341 Returns:
342 Embedding dimension (512 for base model)
343 """
344 return self._model.config.projection_dim if self._model else 512
345
346 def compute_similarity(
347 self,
348 embedding1: list[float],
349 embedding2: list[float],
350 ) -> float:
351 """Compute cosine similarity between two embeddings.
352
353 Args:
354 embedding1: First embedding vector
355 embedding2: Second embedding vector
356
357 Returns:
358 Cosine similarity score between -1 and 1
359 """
360 import numpy as np
361
362 # Convert to numpy arrays
363 emb1 = np.array(embedding1)
364 emb2 = np.array(embedding2)
365
366 # Compute cosine similarity
367 similarity = np.dot(emb1, emb2) / (np.linalg.norm(emb1) * np.linalg.norm(emb2))
368
369 return float(similarity)