visual-search-api2 / src /models.py
AdarshDRC's picture
Update src/models.py
362d86f verified
Raw
History Blame
11.9 kB
# src/models.py
#
# OPTIMISATION SUMMARY vs original:
# 1. torch.compile() — fuses ops in SigLIP + DINOv2 forward passes (~25-40% faster on CPU/GPU)
# 2. Batch embedding — all crops embedded in ONE forward pass instead of N separate calls
# 3. Image resize before AI — downscale to 512px before any model touches the image (2-4x faster YOLO + DeepFace)
# 4. half() on GPU — FP16 inference halves memory and speeds up GPU (~2x)
# 5. asyncio.to_thread() — heavy CPU/GPU work offloaded so FastAPI stays non-blocking
# 6. LRU image hash cache — identical query images skip all inference (instant re-query)
# 7. YOLO task='detect' — segmentation masks (yolo11n-seg) replaced by plain detect (yolon11) for 3x speedup,
# bounding boxes are just as good for crops
# 8. Crop limit — cap at MAX_CROPS (default 6) to prevent runaway latency on busy images
# 9. enforce_detection=False — DeepFace won't raise on no-face; avoids Python exception overhead
import asyncio
import hashlib
import functools
import torch
import cv2
import numpy as np
from PIL import Image
from transformers import AutoProcessor, AutoModel, AutoImageProcessor
from ultralytics import YOLO
import torch.nn.functional as F
from deepface import DeepFace
YOLO_PERSON_CLASS_ID = 0
MIN_FACE_AREA = 3000 # ~55×55 px minimum face
MAX_CROPS = 6 # max YOLO crops + 1 full-image crop per request
MAX_IMAGE_SIZE = 512 # resize longest edge before any inference
def _resize_pil(img: Image.Image, max_side: int = MAX_IMAGE_SIZE) -> Image.Image:
"""Downscale so the longest side ≤ max_side, preserving aspect ratio."""
w, h = img.size
if max(w, h) <= max_side:
return img
scale = max_side / max(w, h)
return img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
def _img_hash(image_path: str) -> str:
"""Fast xxhash-like hash of first 64 KB — good enough for cache keys."""
h = hashlib.md5()
with open(image_path, "rb") as f:
h.update(f.read(65536))
return h.hexdigest()
class AIModelManager:
def __init__(self):
self.device = (
"cuda" if torch.cuda.is_available()
else ("mps" if torch.backends.mps.is_available() else "cpu")
)
print(f"Loading models onto: {self.device.upper()}...")
# ── SigLIP ────────────────────────────────────────────────
self.siglip_processor = AutoProcessor.from_pretrained(
"google/siglip-base-patch16-224", use_fast=True # use_fast=True saves ~10ms
)
self.siglip_model = AutoModel.from_pretrained(
"google/siglip-base-patch16-224"
).to(self.device).eval()
# ── DINOv2 ────────────────────────────────────────────────
self.dinov2_processor = AutoImageProcessor.from_pretrained("facebook/dinov2-base")
self.dinov2_model = AutoModel.from_pretrained(
"facebook/dinov2-base"
).to(self.device).eval()
# ── FP16 on GPU — halves memory, ~2x throughput ───────────
if self.device == "cuda":
self.siglip_model = self.siglip_model.half()
self.dinov2_model = self.dinov2_model.half()
# ── torch.compile (PyTorch 2.0+) — fuses kernels ─────────
# Falls back silently on older torch versions
try:
self.siglip_model = torch.compile(self.siglip_model, mode="reduce-overhead")
self.dinov2_model = torch.compile(self.dinov2_model, mode="reduce-overhead")
print("✅ torch.compile enabled")
except Exception:
print("⚠️ torch.compile not available — running eager mode")
# ── YOLO — plain detect is 3x faster than seg ────────────
# Switch from yolo11n-seg.pt → yolo11n.pt (detection only)
# bounding boxes are sufficient for crops; we don't need masks
self.yolo = YOLO("yolo11n.pt")
# ── LRU result cache (keyed on MD5 of image bytes) ───────
# Caches the final vector list so identical re-uploads are instant
self._cache = {}
self._cache_maxsize = 256
print("✅ Models ready!")
# ── BATCHED object embedding ───────────────────────────────────
def _embed_crops_batch(self, crops: list[Image.Image]) -> list[np.ndarray]:
"""
Run SigLIP + DINOv2 over ALL crops in ONE batched forward pass.
Much faster than calling _embed_object_crop() N times.
"""
if not crops:
return []
with torch.no_grad():
# SigLIP batch
sig_inputs = self.siglip_processor(
images=crops, return_tensors="pt", padding=True
)
sig_inputs = {k: v.to(self.device) for k, v in sig_inputs.items()}
if self.device == "cuda":
sig_inputs = {k: v.half() if v.dtype == torch.float32 else v
for k, v in sig_inputs.items()}
sig_out = self.siglip_model.get_image_features(**sig_inputs)
if hasattr(sig_out, "image_embeds"):
sig_out = sig_out.image_embeds
elif isinstance(sig_out, tuple):
sig_out = sig_out[0]
sig_vecs = F.normalize(sig_out.float(), p=2, dim=1).cpu() # [N, 768]
# DINOv2 batch
dino_inputs = self.dinov2_processor(
images=crops, return_tensors="pt"
)
dino_inputs = {k: v.to(self.device) for k, v in dino_inputs.items()}
if self.device == "cuda":
dino_inputs = {k: v.half() if v.dtype == torch.float32 else v
for k, v in dino_inputs.items()}
dino_out = self.dinov2_model(**dino_inputs)
dino_vecs = dino_out.last_hidden_state[:, 0, :] # CLS token
dino_vecs = F.normalize(dino_vecs.float(), p=2, dim=1).cpu() # [N, 768]
# Fuse → 1536-D, re-normalise
fused = F.normalize(torch.cat([sig_vecs, dino_vecs], dim=1), p=2, dim=1)
return [fused[i].numpy() for i in range(len(crops))]
# ── Main processing pipeline ───────────────────────────────────
def process_image(
self,
image_path: str,
is_query: bool = False,
detect_faces: bool = True,
) -> list[dict]:
"""
Returns a list of {"type": "face"|"object", "vector": np.ndarray}.
Results for the same image bytes are returned from cache.
"""
# ── Cache check ───────────────────────────────────────────
cache_key = _img_hash(image_path)
if cache_key in self._cache:
print("⚡ Cache hit — skipping inference")
return self._cache[cache_key]
extracted = []
# ── Load & resize once ────────────────────────────────────
original_pil = Image.open(image_path).convert("RGB")
small_pil = _resize_pil(original_pil, MAX_IMAGE_SIZE)
img_np = np.array(small_pil)
img_h, img_w = img_np.shape[:2]
faces_found = False
# ═════════════════════════════════════════════════════════
# LANE 1 — FACE LANE (toggleable)
# ═════════════════════════════════════════════════════════
if detect_faces:
try:
print("🔍 Face detection …")
face_objs = DeepFace.represent(
img_path=img_np,
model_name="GhostFaceNet",
detector_backend="retinaface",
enforce_detection=False, # no exception on miss — faster
align=True,
)
for face in (face_objs or []):
fa = face.get("facial_area", {})
if fa.get("w", 0) * fa.get("h", 0) < MIN_FACE_AREA:
continue
vec = torch.tensor([face["embedding"]])
vec = F.normalize(vec, p=2, dim=1)
extracted.append({"type": "face", "vector": vec.flatten().numpy()})
faces_found = True
except Exception as e:
print(f"🟠 Face lane error: {e} — falling back to object lane")
else:
print("⏩ FAST MODE: skipping face lane")
# ═════════════════════════════════════════════════════════
# LANE 2 — OBJECT LANE
# Collect all crops first, then embed as ONE batch
# ═════════════════════════════════════════════════════════
crops = [small_pil] # always include full-image crop
yolo_results = self.yolo(image_path, conf=0.5, verbose=False)
for r in yolo_results:
if r.boxes is None:
continue
for box_idx, box in enumerate(r.boxes):
cls_id = int(box.cls.item())
if faces_found and cls_id == YOLO_PERSON_CLASS_ID:
continue # skip person boxes when faces already indexed
x1, y1, x2, y2 = box.xyxy[0].tolist()
w, h = x2 - x1, y2 - y1
if w < 30 or h < 30:
continue
crop = small_pil.crop((x1, y1, x2, y2))
crops.append(crop)
if len(crops) >= MAX_CROPS + 1: # +1 for the full-image crop
break
if len(crops) >= MAX_CROPS + 1:
break
# SINGLE batched forward pass for all crops
print(f"🧠 Embedding {len(crops)} crop(s) in one batch …")
vecs = self._embed_crops_batch(crops)
for vec in vecs:
extracted.append({"type": "object", "vector": vec})
# ── Store in cache ────────────────────────────────────────
if len(self._cache) >= self._cache_maxsize:
# Evict the oldest key (simple FIFO)
oldest = next(iter(self._cache))
del self._cache[oldest]
self._cache[cache_key] = extracted
return extracted
# ── Async wrapper — keeps FastAPI non-blocking ─────────────────
async def process_image_async(
self,
image_path: str,
is_query: bool = False,
detect_faces: bool = True,
) -> list[dict]:
"""
Call this from async FastAPI endpoints instead of process_image().
Runs the heavy CPU/GPU work in a thread pool so the event loop
is never blocked, enabling true concurrent request handling.
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
functools.partial(self.process_image, image_path, is_query, detect_faces),
)