afterimage / backend /app /embedder.py
KryptosK's picture
Afterimage live backend (FastAPI + FastEmbed + embedded Qdrant)
850c319 verified
Raw
History Blame Contribute Delete
2.95 kB
from pathlib import Path
import hashlib
import numpy as np
from PIL import Image
from .config import Settings
class ImageEmbedder:
def __init__(self, settings: Settings):
self.settings = settings
self._model = None
self._text_model = None
self.mode = "fastembed"
def _load_model(self):
if self._model is not None:
return self._model
try:
from fastembed import ImageEmbedding
self._model = ImageEmbedding(model_name=self.settings.model_name)
return self._model
except Exception:
if not self.settings.allow_fake_embeddings:
raise
self.mode = "deterministic-fallback"
self._model = False
return None
def _load_text_model(self):
if self._text_model is not None:
return self._text_model
from fastembed import TextEmbedding
self._text_model = TextEmbedding(model_name=self.settings.text_model_name)
return self._text_model
def embed_text(self, query: str) -> list[float]:
# CLIP text tower — shares the image vectors' 512-d space, so a text
# query can search the visual memory directly.
model = self._load_text_model()
vector = next(iter(model.embed([query])))
return self._normalize(np.asarray(vector, dtype=np.float32))
def embed_path(self, path: Path) -> list[float]:
if not path.exists():
raise FileNotFoundError(f"Image not found: {path}")
model = self._load_model()
if model is None:
return self._fallback_embedding(path)
image = Image.open(path).convert("RGB")
vector = next(iter(model.embed([image])))
return self._normalize(np.asarray(vector, dtype=np.float32))
def _fallback_embedding(self, path: Path) -> list[float]:
image = Image.open(path).convert("RGB").resize((64, 64))
arr = np.asarray(image, dtype=np.float32) / 255.0
means = arr.mean(axis=(0, 1))
stds = arr.std(axis=(0, 1))
hist = []
for channel in range(3):
values, _ = np.histogram(arr[:, :, channel], bins=32, range=(0, 1), density=True)
hist.extend(values.tolist())
digest = hashlib.sha256(path.read_bytes()).digest()
noise = np.frombuffer(digest * 16, dtype=np.uint8)[:410].astype(np.float32) / 255.0
vector = np.concatenate([means, stds, np.array(hist, dtype=np.float32), noise])
return self._normalize(vector[: self.settings.vector_size])
def _normalize(self, vector: np.ndarray) -> list[float]:
if vector.shape[0] != self.settings.vector_size:
raise ValueError(f"Expected {self.settings.vector_size}-dim vector, got {vector.shape[0]}")
norm = np.linalg.norm(vector)
if norm == 0:
return vector.tolist()
return (vector / norm).astype(float).tolist()