visual-search-api2 / src /models.py
AdarshDRC's picture
Update src/models.py
5066eba verified
Raw
History Blame
32.1 kB
"""
src/models.py — AI inference pipeline: face detection + object embedding.
Two independent lanes:
Face lane : InsightFace SCRFD detection → ArcFace + AdaFace → 1024-D vector
Object lane : YOLO segmentation crops → SigLIP + DINOv2 → 1536-D vector
Both lanes run on every image. main.py decides which results to use for search.
Key design decisions:
- Multi-scale + horizontal-flip detection catches small/turned faces.
- CLAHE pre-processing recovers detail in dark / over-exposed photos.
- ArcFace + AdaFace fusion: identity-discriminative + quality-adaptive.
- SigLIP + DINOv2 fusion: semantic understanding + fine-grained texture.
- Results are cached by (file_hash, detect_faces) to avoid re-inference
on duplicate uploads or repeated queries of the same image.
"""
import functools
import io
import threading
import asyncio
import traceback
import base64
import cv2
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image
from transformers import AutoImageProcessor, AutoModel, AutoProcessor
from ultralytics import YOLO
import insightface
from insightface.app import FaceAnalysis
from .config import (
# Object lane
MAX_IMAGE_SIZE, MAX_CROPS, YOLO_PERSON_CLASS_ID,
YOLO_MIN_CROP_PX, YOLO_CONF_THRESHOLD,
# Face lane — detection
DET_SIZE_PRIMARY, DET_SCALES, IOU_DEDUP_THRESHOLD,
MIN_FACE_SIZE, MAX_FACES_PER_IMAGE, FACE_QUALITY_GATE,
# Face lane — dimensions
FACE_DIM, ADAFACE_DIM, FUSED_FACE_DIM,
# Thumbnails
FACE_CROP_THUMB_SIZE, FACE_CROP_QUALITY,
FACE_CROP_PADDING, ADAFACE_CROP_PADDING,
# Cache
INFERENCE_CACHE_SIZE,
# AdaFace toggle
ENABLE_ADAFACE, HF_TOKEN,
)
from .utils import img_hash
# ════════════════════════════════════════════════════════════════════
# MODULE-LEVEL UTILITY FUNCTIONS
# Pure functions — no model state, safe to call from anywhere.
# ════════════════════════════════════════════════════════════════════
def _resize_pil(img: Image.Image, max_side: int = MAX_IMAGE_SIZE) -> Image.Image:
"""
Resize a PIL image so its longest side is at most `max_side` pixels,
preserving aspect ratio.
Why max-side (not fixed W×H)? Fixed dimensions squash portrait/landscape
images. Preserving aspect ratio keeps faces and objects undistorted.
Why LANCZOS? It's a windowed sinc filter that considers more surrounding
pixels than bilinear/nearest, preserving fine detail on downscale.
"""
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 _crop_to_b64(
img_bgr: np.ndarray,
x1: int, y1: int, x2: int, y2: int,
) -> str:
"""
Crop a face from a BGR image with FACE_CROP_PADDING padding,
resize to FACE_CROP_THUMB_SIZE × FACE_CROP_THUMB_SIZE,
and return as a base64-encoded JPEG string.
The 20 % padding (vs 10 % for AdaFace) ensures the UI thumbnail
includes hair, ears, and chin context — making it visually recognisable.
The thumbnail is stored in Pinecone metadata; the frontend renders it
as data:image/jpeg;base64,... without a Cloudinary round-trip.
"""
H, W = img_bgr.shape[:2]
w, h = x2 - x1, y2 - y1
pad_x = int(w * FACE_CROP_PADDING)
pad_y = int(h * FACE_CROP_PADDING)
cx1 = max(0, x1 - pad_x)
cy1 = max(0, y1 - pad_y)
cx2 = min(W, x2 + pad_x)
cy2 = min(H, y2 + pad_y)
crop = img_bgr[cy1:cy2, cx1:cx2]
if crop.size == 0:
return ""
pil = Image.fromarray(crop[:, :, ::-1]) # BGR → RGB
pil = pil.resize((FACE_CROP_THUMB_SIZE, FACE_CROP_THUMB_SIZE), Image.LANCZOS)
buf = io.BytesIO()
pil.save(buf, format="JPEG", quality=FACE_CROP_QUALITY)
return base64.b64encode(buf.getvalue()).decode()
def _face_crop_for_adaface(
img_bgr: np.ndarray,
x1: int, y1: int, x2: int, y2: int,
) -> np.ndarray | None:
"""
Crop and preprocess a face region for AdaFace IR-50 model input.
Input contract: BGR uint8 numpy array (H, W, 3)
Output contract: float32 numpy array (3, 112, 112) normalised to [-1, 1]
Why 10 % padding (not 20 %)? AdaFace expects a tight face crop; too
much background degrades embedding quality.
Why [-1, 1] normalisation? AdaFace was trained with this range.
Feeding [0, 1] or [0, 255] produces garbage embeddings because the
model's BN/weight distributions assume [-1, 1] input statistics.
Why HWC → CHW transpose? PIL and numpy use (H, W, C); PyTorch models
expect (C, H, W). The transpose bridges this convention difference.
"""
H, W = img_bgr.shape[:2]
w, h = x2 - x1, y2 - y1
pad_x = int(w * ADAFACE_CROP_PADDING)
pad_y = int(h * ADAFACE_CROP_PADDING)
cx1 = max(0, x1 - pad_x)
cy1 = max(0, y1 - pad_y)
cx2 = min(W, x2 + pad_x)
cy2 = min(H, y2 + pad_y)
crop = img_bgr[cy1:cy2, cx1:cx2]
if crop.size == 0:
return None
rgb = crop[:, :, ::-1].copy()
pil = Image.fromarray(rgb).resize((112, 112), Image.LANCZOS)
arr = np.array(pil, dtype=np.float32) / 255.0
arr = (arr - 0.5) / 0.5 # [0,1] → [-1,1]
return arr.transpose(2, 0, 1) # HWC → CHW
def _clahe_enhance(bgr: np.ndarray) -> np.ndarray:
"""
Apply CLAHE (Contrast-Limited Adaptive Histogram Equalisation) to the
luminance channel of a BGR image.
Why CLAHE? Face detection fails on dark, backlit, or washed-out photos.
CLAHE improves local contrast without globally blowing out highlights.
Why LAB colour space? The L channel is pure luminance — enhancing it
leaves the colour information (A, B channels) completely untouched,
preventing skin-tone shifts.
clipLimit=2.0 — caps per-tile histogram bin amplification to prevent
noise from being treated as real contrast.
tileGridSize=(8,8) — 8×8 tiles for local adaptation; smaller = more
aggressive local correction.
"""
lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB)
l_ch, a_ch, b_ch = cv2.split(lab)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
l_eq = clahe.apply(l_ch)
return cv2.cvtColor(cv2.merge([l_eq, a_ch, b_ch]), cv2.COLOR_LAB2BGR)
def _iou(box_a: list, box_b: list) -> float:
"""
Intersection-over-Union between two [x1, y1, x2, y2] bounding boxes.
IoU = area(intersection) / area(union)
Used by _dedup_faces to suppress duplicate face detections across
detection scales and the horizontal-flip pass.
Returns 0.0 if boxes don't overlap.
"""
xa = max(box_a[0], box_b[0])
ya = max(box_a[1], box_b[1])
xb = min(box_a[2], box_b[2])
yb = min(box_a[3], box_b[3])
inter = max(0, xb - xa) * max(0, yb - ya)
if inter == 0:
return 0.0
area_a = (box_a[2] - box_a[0]) * (box_a[3] - box_a[1])
area_b = (box_b[2] - box_b[0]) * (box_b[3] - box_b[1])
return inter / (area_a + area_b - inter)
def _dedup_faces(faces_list: list, iou_thresh: float = IOU_DEDUP_THRESHOLD) -> list:
"""
Non-Maximum Suppression over face detections from multiple scales/flips.
Algorithm (greedy NMS):
1. Sort detections by det_score descending.
2. For each face, keep it only if it doesn't overlap (IoU > iou_thresh)
with any already-kept face.
Sorting by confidence first ensures the higher-quality detection "wins"
when two boxes refer to the same physical face.
"""
if not faces_list:
return []
faces_list = sorted(faces_list, key=lambda f: float(f.det_score), reverse=True)
kept = []
for face in faces_list:
b = face.bbox.astype(int)
box = [b[0], b[1], b[2], b[3]]
if not any(_iou(box, [k.bbox.astype(int)[i] for i in range(4)]) > iou_thresh
for k in kept):
kept.append(face)
return kept
# ════════════════════════════════════════════════════════════════════
# AIModelManager
# ════════════════════════════════════════════════════════════════════
class AIModelManager:
"""
Loads and manages all AI models at server startup.
Thread-safe for the face lane (via _face_lock).
Cache-safe for all lanes (via _cache_lock).
Models loaded:
Object lane: SigLIP-base-patch16-224 + DINOv2-base → 1536-D fused
Face lane: InsightFace buffalo_l (SCRFD-10GF + ArcFace-R100) +
optionally AdaFace IR-50 → 1024-D fused
"""
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()}...")
# ── Object lane: SigLIP ──────────────────────────────────
print("📦 Loading SigLIP...")
self.siglip_processor = AutoProcessor.from_pretrained(
"google/siglip-base-patch16-224", use_fast=True)
self.siglip_model = (
AutoModel.from_pretrained("google/siglip-base-patch16-224")
.to(self.device).eval()
)
# ── Object lane: DINOv2 ──────────────────────────────────
print("📦 Loading DINOv2...")
self.dinov2_processor = AutoImageProcessor.from_pretrained("facebook/dinov2-base")
self.dinov2_model = (
AutoModel.from_pretrained("facebook/dinov2-base")
.to(self.device).eval()
)
# FP16 halves VRAM usage on CUDA with negligible accuracy loss at inference
if self.device == "cuda":
self.siglip_model = self.siglip_model.half()
self.dinov2_model = self.dinov2_model.half()
# ── Object lane: YOLO segmentation ──────────────────────
print("📦 Loading YOLO11n-seg...")
self.yolo = YOLO("yolo11n-seg.pt")
# ── Face lane: InsightFace SCRFD + ArcFace ───────────────
# buffalo_l = SCRFD-10GF detector + ArcFace-R100 recogniser.
# Always use buffalo_l (not buffalo_sc) — accuracy matters here.
print("📦 Loading InsightFace buffalo_l (SCRFD-10GF + ArcFace-R100)...")
self.face_app = FaceAnalysis(
name="buffalo_l",
providers=(
["CUDAExecutionProvider", "CPUExecutionProvider"]
if self.device == "cuda"
else ["CPUExecutionProvider"]
),
)
self.face_app.prepare(
ctx_id=0 if self.device == "cuda" else -1,
det_size=DET_SIZE_PRIMARY,
)
# Warmup — pre-allocates ONNX buffers so first real call isn't slow
self.face_app.get(np.zeros((112, 112, 3), dtype=np.uint8))
print(f"✅ InsightFace loaded | det_size={DET_SIZE_PRIMARY} | gate={FACE_QUALITY_GATE}")
# ── Face lane: AdaFace (optional) ────────────────────────
self.adaface_model = None
self._load_adaface()
# ── Thread safety ────────────────────────────────────────
# _face_lock : InsightFace ONNX runtime is NOT thread-safe
# _cache_lock : protects _cache dict from concurrent read-write-evict
self._face_lock = threading.Lock()
self._cache_lock = threading.Lock()
self._cache: dict[str, list] = {}
adaface_status = "FULL FUSION ✅" if self.adaface_model else "ZERO-PADDED ⚠️ (weights missing)"
print(
f"\n✅ Enterprise Lens V4 — Models Ready\n"
f" Device : {self.device.upper()}\n"
f" Face vectors : {FUSED_FACE_DIM}-D ({adaface_status})\n"
f" Object vectors: 1536-D (SigLIP+DINOv2)\n"
f" Quality gate : det_score ≥ {FACE_QUALITY_GATE}, face_px ≥ {MIN_FACE_SIZE}\n"
)
# ── AdaFace loader ───────────────────────────────────────────────
def _load_adaface(self):
"""
Load AdaFace IR-50 MS1MV2 from HuggingFace.
Controlled by ENABLE_ADAFACE env var (default off).
When disabled: ArcFace(512) + zeros(512) → 1024-D output.
Zero-padding is cosine-neutral — the ArcFace half still carries
full identity signal; padded zeros don't pull any direction.
When enabled: ArcFace(512) + AdaFace(512) → 1024-D.
AdaFace is quality-adaptive: blurry/low-quality face crops receive
downweighted embeddings, improving retrieval precision.
"""
if not ENABLE_ADAFACE:
print("⚠️ AdaFace disabled (ENABLE_ADAFACE != 1) — using zero-padded 1024-D")
return
import os, sys
REPO_ID = "minchul/cvlface_adaface_ir50_ms1mv2"
CACHE_PATH = os.path.expanduser("~/.cvlface_cache/minchul/cvlface_adaface_ir50_ms1mv2")
try:
from huggingface_hub import hf_hub_download
from transformers import AutoModel as _HFAutoModel
print("📦 Loading AdaFace IR-50 MS1MV2...")
os.makedirs(CACHE_PATH, exist_ok=True)
hf_hub_download(repo_id=REPO_ID, filename="files.txt",
token=HF_TOKEN, local_dir=CACHE_PATH,
local_dir_use_symlinks=False)
with open(os.path.join(CACHE_PATH, "files.txt")) as f:
extra = [x.strip() for x in f.read().split("\n") if x.strip()]
for fname in extra + ["config.json", "wrapper.py", "model.safetensors"]:
fpath = os.path.join(CACHE_PATH, fname)
if not os.path.exists(fpath):
hf_hub_download(repo_id=REPO_ID, filename=fname,
token=HF_TOKEN, local_dir=CACHE_PATH,
local_dir_use_symlinks=False)
cwd = os.getcwd()
os.chdir(CACHE_PATH)
sys.path.insert(0, CACHE_PATH)
try:
model = _HFAutoModel.from_pretrained(
CACHE_PATH, trust_remote_code=True, token=HF_TOKEN)
finally:
os.chdir(cwd)
if CACHE_PATH in sys.path:
sys.path.remove(CACHE_PATH)
model = model.to(self.device).eval()
with torch.no_grad():
out = model(torch.zeros(1, 3, 112, 112).to(self.device))
emb = out if isinstance(out, torch.Tensor) else out.embedding
assert emb.shape[-1] == ADAFACE_DIM, f"Expected {ADAFACE_DIM}-D, got {emb.shape[-1]}"
self.adaface_model = model
print("✅ AdaFace IR-50 loaded — 1024-D FULL FUSION active")
except Exception as e:
print(f"⚠️ AdaFace load failed: {e} — falling back to zero-padded 1024-D")
self.adaface_model = None
# ── AdaFace inference ────────────────────────────────────────────
def _adaface_embed(self, face_arr_chw: np.ndarray | None) -> np.ndarray | None:
"""
Run AdaFace on a preprocessed (3, 112, 112) float32 CHW array.
Returns a 512-D L2-normalised numpy embedding, or None on failure.
The cvlface model may return a raw tensor or an object with .embedding —
both output formats are handled here.
"""
if self.adaface_model is None or face_arr_chw is None:
return None
try:
t = torch.from_numpy(face_arr_chw).unsqueeze(0).to(self.device)
if self.device == "cuda":
t = t.half()
with torch.no_grad():
out = self.adaface_model(t)
emb = out if isinstance(out, torch.Tensor) else out.embedding
return F.normalize(emb.float(), p=2, dim=1)[0].cpu().numpy()
except Exception as e:
print(f"⚠️ AdaFace inference error: {e}")
return None
# ── Object lane: batched embedding ──────────────────────────────
def _embed_crops_batch(self, crops: list[Image.Image]) -> list[np.ndarray]:
"""
Embed a batch of PIL images through SigLIP and DINOv2, fuse results.
SigLIP captures semantic/language-aligned meaning ("a red sports car").
DINOv2 captures fine-grained visual texture and structure (self-supervised).
Fusing both gives vectors that are sensitive to BOTH what something IS
and what it LOOKS LIKE — better retrieval than either model alone.
Why batch? GPUs process many inputs in parallel almost as fast as one.
Why torch.no_grad()? Skips gradient graph construction — ~30 % faster,
significant memory saving at inference time.
Why F.normalize (L2)? Projects embeddings onto unit sphere.
On the unit sphere: cosine_similarity = dot_product
(cheaper and numerically stable).
Also ensures neither SigLIP nor DINOv2 dominates
the fused vector due to scale differences.
"""
if not crops:
return []
with torch.no_grad():
# SigLIP
sig_in = self.siglip_processor(images=crops, return_tensors="pt", padding=True)
sig_in = {k: v.to(self.device) for k, v in sig_in.items()}
if self.device == "cuda":
sig_in = {k: v.half() if v.dtype == torch.float32 else v
for k, v in sig_in.items()}
sig_out = self.siglip_model.get_image_features(**sig_in)
# Handle all output types across transformers versions
if hasattr(sig_out, "image_embeds"):
sig_out = sig_out.image_embeds
elif hasattr(sig_out, "pooler_output"):
sig_out = sig_out.pooler_output
elif hasattr(sig_out, "last_hidden_state"):
sig_out = sig_out.last_hidden_state[:, 0, :]
elif isinstance(sig_out, tuple):
sig_out = sig_out[0]
sig_vecs = F.normalize(sig_out.float(), p=2, dim=1).cpu()
# DINOv2 — [:, 0, :] extracts the [CLS] token which aggregates
# the global image representation across the entire sequence
dino_in = self.dinov2_processor(images=crops, return_tensors="pt")
dino_in = {k: v.to(self.device) for k, v in dino_in.items()}
if self.device == "cuda":
dino_in = {k: v.half() if v.dtype == torch.float32 else v
for k, v in dino_in.items()}
dino_out = self.dinov2_model(**dino_in)
dino_vecs = F.normalize(
dino_out.last_hidden_state[:, 0, :].float(), p=2, dim=1).cpu()
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))]
# ── Face lane: detection + dual encoding ─────────────────────────
def _detect_and_encode_faces(self, img_np: np.ndarray) -> list[dict]:
"""
Detect all faces using InsightFace SCRFD-10GF at multiple scales,
encode each face with ArcFace-R100 + AdaFace IR-50, and return
1024-D fused vectors.
Pipeline per face:
1. ArcFace-R100 (512-D) from InsightFace's built-in recognition
2. AdaFace IR-50 (512-D) from separately loaded model
3. Concatenate + L2-normalise → 1024-D final vector
4. Quality gates: det_score ≥ FACE_QUALITY_GATE, width ≥ MIN_FACE_SIZE
Multi-scale strategy:
- Run SCRFD at 1280, 960, and 640 px.
- Run once more on horizontally flipped image (catches turned faces).
- Merge all detections and deduplicate by IoU.
Rationale: a face that's 15 px at 640 becomes 30 px at 1280;
the detector finds it at the larger scale.
AdaFace unavailable:
Zero-pad to maintain 1024-D. The ArcFace half carries full identity
signal; zero padding is cosine-neutral (no direction bias).
Returns list of dicts:
{ type, vector (1024-D), face_idx, bbox, face_crop, det_score, face_width_px }
"""
if self.face_app is None:
return []
try:
if img_np.dtype != np.uint8:
img_np = (img_np * 255).astype(np.uint8)
bgr = img_np[:, :, ::-1].copy() if img_np.shape[2] == 3 else img_np.copy()
# CLAHE: boost contrast on dark/backlit/low-contrast photos
bgr_enhanced = _clahe_enhance(bgr)
# Multi-scale detection — bboxes are scaled back to original coords
all_raw_faces = []
H, W = bgr.shape[:2]
for scale in DET_SCALES:
scale_w = min(W, scale[0])
scale_h = min(H, scale[1])
bgr_scaled = (
bgr_enhanced if scale_w == W and scale_h == H
else cv2.resize(bgr_enhanced, (scale_w, scale_h))
)
try:
self.face_app.det_model.input_size = scale
with self._face_lock:
faces_at_scale = self.face_app.get(bgr_scaled)
sx, sy = W / scale_w, H / scale_h
for f in faces_at_scale:
if sx != 1.0 or sy != 1.0:
f.bbox[0] *= sx; f.bbox[1] *= sy
f.bbox[2] *= sx; f.bbox[3] *= sy
all_raw_faces.extend(faces_at_scale)
except Exception:
pass
# Horizontal-flip pass — catches profile/turned faces
bgr_flip = cv2.flip(bgr_enhanced, 1)
try:
self.face_app.det_model.input_size = DET_SIZE_PRIMARY
with self._face_lock:
faces_flip = self.face_app.get(bgr_flip)
for f in faces_flip:
x1, y1, x2, y2 = f.bbox
f.bbox[0] = W - x2
f.bbox[2] = W - x1
all_raw_faces.extend(faces_flip)
except Exception:
pass
# Restore primary size
self.face_app.det_model.input_size = DET_SIZE_PRIMARY
faces = _dedup_faces(all_raw_faces)
print(f" Raw detections: {len(all_raw_faces)} → after dedup: {len(faces)}")
results = []
accepted = 0
for idx, face in enumerate(faces):
if accepted >= MAX_FACES_PER_IMAGE:
break
bbox_raw = face.bbox.astype(int)
x1, y1, x2, y2 = bbox_raw
x1 = max(0, x1); y1 = max(0, y1)
x2 = min(bgr.shape[1], x2); y2 = min(bgr.shape[0], y2)
w, h = x2 - x1, y2 - y1
if w <= 0 or h <= 0:
continue
# Quality gate 1: minimum pixel size
if w < MIN_FACE_SIZE or h < MIN_FACE_SIZE:
print(f" Face {idx}: SKIP — too small ({w}×{h}px)")
continue
# Quality gate 2: detector confidence
det_score = float(face.det_score) if hasattr(face, "det_score") else 1.0
if det_score < FACE_QUALITY_GATE:
print(f" Face {idx}: SKIP — low det_score ({det_score:.3f})")
continue
if face.embedding is None:
continue
# ArcFace embedding (built into InsightFace buffalo_l)
arcface_vec = face.embedding.astype(np.float32)
n = np.linalg.norm(arcface_vec)
if n > 0:
arcface_vec = arcface_vec / n
# AdaFace embedding (quality-adaptive)
face_chw = _face_crop_for_adaface(bgr, x1, y1, x2, y2)
adaface_vec = self._adaface_embed(face_chw)
# Fuse to 1024-D — always output FUSED_FACE_DIM regardless of AdaFace status
if adaface_vec is not None:
fused_raw = np.concatenate([arcface_vec, adaface_vec])
else:
fused_raw = np.concatenate([arcface_vec,
np.zeros(ADAFACE_DIM, dtype=np.float32)])
n2 = np.linalg.norm(fused_raw)
final_vec = (fused_raw / n2) if n2 > 0 else fused_raw
face_crop_b64 = _crop_to_b64(bgr, x1, y1, x2, y2)
results.append({
"type": "face",
"vector": final_vec,
"face_idx": accepted,
# bbox exposed so the frontend can draw boxes on the query image
"bbox": [int(x1), int(y1), int(w), int(h)],
"face_crop": face_crop_b64,
"det_score": det_score,
"face_width_px": int(w),
})
accepted += 1
print(f" Face {idx}: ✅ ACCEPTED — {w}×{h}px | det={det_score:.3f}")
print(f"👤 {accepted} face(s) passed quality gate")
return results
except Exception as e:
print(f"🟠 InsightFace error: {e}\n{traceback.format_exc()[-600:]}")
return []
# ── Main pipeline ────────────────────────────────────────────────
def process_image(
self,
image_path: str,
detect_faces: bool = True,
) -> list[dict]:
"""
Full inference pipeline for a single image.
Always runs both lanes:
Face → list of { type:"face", vector(1024-D), face_idx, bbox,
face_crop, det_score, face_width_px }
Object → list of { type:"object", vector(1536-D) }
main.py decides which lane's results to use for Pinecone operations
based on the endpoint context (upload stores both; search can use both).
Cache strategy:
Key = (md5_of_first_64KB, detect_faces)
Hit → return cached result immediately (skips all model inference)
Miss → run pipeline, cache result, evict LRU entry if over capacity
Cache is protected by _cache_lock (threading.Lock) to prevent race
conditions when MAX_CONCURRENT_INFERENCES > 1.
"""
cache_key = f"{img_hash(image_path)}_{detect_faces}"
with self._cache_lock:
if cache_key in self._cache:
print("⚡ Cache hit")
return self._cache[cache_key]
extracted = []
original_pil = Image.open(image_path).convert("RGB")
img_np = np.array(original_pil) # RGB uint8, full resolution
faces_found = False
# ── Face lane ────────────────────────────────────────────
if detect_faces and self.face_app is not None:
face_results = self._detect_and_encode_faces(img_np)
if face_results:
faces_found = True
extracted.extend(face_results)
# ── Object lane ──────────────────────────────────────────
# Always runs, even when faces are found.
# Person-class YOLO crops are skipped when face lane is active
# to avoid embedding the same person twice.
#
# Crop 0 is always the full (resized) image — ensures we always
# have at least one embedding even if YOLO finds nothing.
# YOLO is given the already-loaded PIL image to avoid re-reading
# the file from disk.
crops: list[Image.Image] = []
yolo_results = self.yolo(original_pil, conf=YOLO_CONF_THRESHOLD, verbose=False)
for r in yolo_results:
if r.masks is not None:
for seg_idx, mask_xy in enumerate(r.masks.xy):
cls_id = int(r.boxes.cls[seg_idx].item())
if faces_found and cls_id == YOLO_PERSON_CLASS_ID:
continue
polygon = np.array(mask_xy, dtype=np.int32)
if len(polygon) < 3:
continue
x, y, w, h = cv2.boundingRect(polygon)
if w < YOLO_MIN_CROP_PX or h < YOLO_MIN_CROP_PX:
continue
crops.append(original_pil.crop((x, y, x + w, y + h)))
if len(crops) >= MAX_CROPS:
break
elif r.boxes is not None:
for box in r.boxes:
cls_id = int(box.cls.item())
if faces_found and cls_id == YOLO_PERSON_CLASS_ID:
continue
x1, y1, x2, y2 = box.xyxy[0].tolist()
if (x2 - x1) < YOLO_MIN_CROP_PX or (y2 - y1) < YOLO_MIN_CROP_PX:
continue
crops.append(original_pil.crop((x1, y1, x2, y2)))
if len(crops) >= MAX_CROPS:
break
# Prepend the full image as crop 0, then resize ALL crops uniformly.
# (Previously the full image was pre-resized before appending, causing
# _resize_pil to be called on it twice. Now we resize everything once.)
all_crops = [original_pil] + crops
all_crops = [_resize_pil(c, MAX_IMAGE_SIZE) for c in all_crops]
print(f"🧠 Embedding {len(all_crops)} object crop(s)...")
obj_vecs = self._embed_crops_batch(all_crops)
extracted.extend({"type": "object", "vector": v} for v in obj_vecs)
# Cache with lock — prevents concurrent writes from corrupting eviction
with self._cache_lock:
if len(self._cache) >= INFERENCE_CACHE_SIZE:
# Evict LRU entry (first inserted key in plain dict = oldest)
oldest = next(iter(self._cache))
del self._cache[oldest]
self._cache[cache_key] = extracted
return extracted
async def process_image_async(
self,
image_path: str,
detect_faces: bool = True,
) -> list[dict]:
"""
Async wrapper for process_image — offloads blocking inference to a
thread-pool executor so FastAPI's event loop remains responsive.
functools.partial is used instead of a lambda to make the call
picklable, which some executor backends require.
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
functools.partial(self.process_image, image_path, detect_faces),
)