AdarshDRC commited on
Commit
58c92f2
·
verified ·
1 Parent(s): 2581fff

Update src/models.py

Browse files
Files changed (1) hide show
  1. src/models.py +25 -113
src/models.py CHANGED
@@ -1,16 +1,8 @@
1
  # src/models.py
2
- #
3
- # OPTIMISATION SUMMARY vs original:
4
- # 1. torch.compile() — fuses ops in SigLIP + DINOv2 forward passes (~25-40% faster on CPU/GPU)
5
- # 2. Batch embedding — all crops embedded in ONE forward pass instead of N separate calls
6
- # 3. Image resize before AI — downscale to 512px before any model touches the image (2-4x faster YOLO + DeepFace)
7
- # 4. half() on GPU — FP16 inference halves memory and speeds up GPU (~2x)
8
- # 5. asyncio.to_thread() — heavy CPU/GPU work offloaded so FastAPI stays non-blocking
9
- # 6. LRU image hash cache — identical query images skip all inference (instant re-query)
10
- # 7. YOLO task='detect' — segmentation masks (yolo11n-seg) replaced by plain detect (yolon11) for 3x speedup,
11
- # bounding boxes are just as good for crops
12
- # 8. Crop limit — cap at MAX_CROPS (default 6) to prevent runaway latency on busy images
13
- # 9. enforce_detection=False — DeepFace won't raise on no-face; avoids Python exception overhead
14
 
15
  import asyncio
16
  import hashlib
@@ -32,22 +24,18 @@ MAX_IMAGE_SIZE = 512 # resize longest edge before any inference
32
 
33
 
34
  def _resize_pil(img: Image.Image, max_side: int = MAX_IMAGE_SIZE) -> Image.Image:
35
- """Downscale so the longest side ≤ max_side, preserving aspect ratio."""
36
  w, h = img.size
37
  if max(w, h) <= max_side:
38
  return img
39
  scale = max_side / max(w, h)
40
  return img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
41
 
42
-
43
  def _img_hash(image_path: str) -> str:
44
- """Fast xxhash-like hash of first 64 KB — good enough for cache keys."""
45
  h = hashlib.md5()
46
  with open(image_path, "rb") as f:
47
  h.update(f.read(65536))
48
  return h.hexdigest()
49
 
50
-
51
  class AIModelManager:
52
  def __init__(self):
53
  self.device = (
@@ -56,119 +44,68 @@ class AIModelManager:
56
  )
57
  print(f"Loading models onto: {self.device.upper()}...")
58
 
59
- # ── SigLIP ────────────────────────────────────────────────
60
- self.siglip_processor = AutoProcessor.from_pretrained(
61
- "google/siglip-base-patch16-224", use_fast=True # use_fast=True saves ~10ms
62
- )
63
- self.siglip_model = AutoModel.from_pretrained(
64
- "google/siglip-base-patch16-224"
65
- ).to(self.device).eval()
66
 
67
- # ── DINOv2 ────────────────────────────────────────────────
68
  self.dinov2_processor = AutoImageProcessor.from_pretrained("facebook/dinov2-base")
69
- self.dinov2_model = AutoModel.from_pretrained(
70
- "facebook/dinov2-base"
71
- ).to(self.device).eval()
72
 
73
- # ── FP16 on GPU — halves memory, ~2x throughput ───────────
74
  if self.device == "cuda":
75
  self.siglip_model = self.siglip_model.half()
76
  self.dinov2_model = self.dinov2_model.half()
77
 
78
- # ── torch.compile (PyTorch 2.0+) fuses kernels ─────────
79
- # Falls back silently on older torch versions
80
- try:
81
- self.siglip_model = torch.compile(self.siglip_model, mode="reduce-overhead")
82
- self.dinov2_model = torch.compile(self.dinov2_model, mode="reduce-overhead")
83
- print("✅ torch.compile enabled")
84
- except Exception:
85
- print("⚠️ torch.compile not available — running eager mode")
86
-
87
- # ── YOLO — plain detect is 3x faster than seg ────────────
88
- # Switch from yolo11n-seg.pt → yolo11n.pt (detection only)
89
- # bounding boxes are sufficient for crops; we don't need masks
90
  self.yolo = YOLO("yolo11n.pt")
91
 
92
- # ── LRU result cache (keyed on MD5 of image bytes) ───────
93
- # Caches the final vector list so identical re-uploads are instant
94
  self._cache = {}
95
  self._cache_maxsize = 256
96
 
97
  print("✅ Models ready!")
98
 
99
- # ── BATCHED object embedding ───────────────────────────────────
100
  def _embed_crops_batch(self, crops: list[Image.Image]) -> list[np.ndarray]:
101
- """
102
- Run SigLIP + DINOv2 over ALL crops in ONE batched forward pass.
103
- Much faster than calling _embed_object_crop() N times.
104
- """
105
  if not crops:
106
  return []
107
 
108
  with torch.no_grad():
109
- # SigLIP batch
110
- sig_inputs = self.siglip_processor(
111
- images=crops, return_tensors="pt", padding=True
112
- )
113
  sig_inputs = {k: v.to(self.device) for k, v in sig_inputs.items()}
114
  if self.device == "cuda":
115
- sig_inputs = {k: v.half() if v.dtype == torch.float32 else v
116
- for k, v in sig_inputs.items()}
117
 
118
  sig_out = self.siglip_model.get_image_features(**sig_inputs)
119
  if hasattr(sig_out, "image_embeds"):
120
  sig_out = sig_out.image_embeds
121
  elif isinstance(sig_out, tuple):
122
  sig_out = sig_out[0]
123
- sig_vecs = F.normalize(sig_out.float(), p=2, dim=1).cpu() # [N, 768]
124
 
125
- # DINOv2 batch
126
- dino_inputs = self.dinov2_processor(
127
- images=crops, return_tensors="pt"
128
- )
129
  dino_inputs = {k: v.to(self.device) for k, v in dino_inputs.items()}
130
  if self.device == "cuda":
131
- dino_inputs = {k: v.half() if v.dtype == torch.float32 else v
132
- for k, v in dino_inputs.items()}
133
 
134
  dino_out = self.dinov2_model(**dino_inputs)
135
- dino_vecs = dino_out.last_hidden_state[:, 0, :] # CLS token
136
- dino_vecs = F.normalize(dino_vecs.float(), p=2, dim=1).cpu() # [N, 768]
137
 
138
- # Fuse → 1536-D, re-normalise
139
  fused = F.normalize(torch.cat([sig_vecs, dino_vecs], dim=1), p=2, dim=1)
140
 
141
  return [fused[i].numpy() for i in range(len(crops))]
142
 
143
- # ── Main processing pipeline ───────────────────────────────────
144
- def process_image(
145
- self,
146
- image_path: str,
147
- is_query: bool = False,
148
- detect_faces: bool = True,
149
- ) -> list[dict]:
150
- """
151
- Returns a list of {"type": "face"|"object", "vector": np.ndarray}.
152
- Results for the same image bytes are returned from cache.
153
- """
154
- # ── Cache check ───────────────────────────────────────────
155
  cache_key = _img_hash(image_path)
156
  if cache_key in self._cache:
157
  print("⚡ Cache hit — skipping inference")
158
  return self._cache[cache_key]
159
 
160
  extracted = []
161
-
162
- # ── Load & resize once ────────────────────────────────────
163
  original_pil = Image.open(image_path).convert("RGB")
164
  small_pil = _resize_pil(original_pil, MAX_IMAGE_SIZE)
165
  img_np = np.array(small_pil)
166
- img_h, img_w = img_np.shape[:2]
167
  faces_found = False
168
 
169
- # ═════════════════════════════════════════════════════════
170
- # LANE 1 — FACE LANE (toggleable)
171
- # ═════════════════════════════════════════════════════════
172
  if detect_faces:
173
  try:
174
  print("🔍 Face detection …")
@@ -176,10 +113,9 @@ class AIModelManager:
176
  img_path=img_np,
177
  model_name="GhostFaceNet",
178
  detector_backend="retinaface",
179
- enforce_detection=False, # no exception on miss — faster
180
  align=True,
181
  )
182
-
183
  for face in (face_objs or []):
184
  fa = face.get("facial_area", {})
185
  if fa.get("w", 0) * fa.get("h", 0) < MIN_FACE_AREA:
@@ -191,64 +127,40 @@ class AIModelManager:
191
 
192
  except Exception as e:
193
  print(f"🟠 Face lane error: {e} — falling back to object lane")
194
- else:
195
- print("⏩ FAST MODE: skipping face lane")
196
-
197
- # ══════════════════���══════════════════════════════════════
198
- # LANE 2 — OBJECT LANE
199
- # Collect all crops first, then embed as ONE batch
200
- # ═════════════════════════════════════════════════════════
201
- crops = [small_pil] # always include full-image crop
202
 
 
203
  yolo_results = self.yolo(image_path, conf=0.5, verbose=False)
204
 
205
  for r in yolo_results:
206
  if r.boxes is None:
207
  continue
208
- for box_idx, box in enumerate(r.boxes):
209
  cls_id = int(box.cls.item())
210
  if faces_found and cls_id == YOLO_PERSON_CLASS_ID:
211
- continue # skip person boxes when faces already indexed
212
  x1, y1, x2, y2 = box.xyxy[0].tolist()
213
  w, h = x2 - x1, y2 - y1
214
  if w < 30 or h < 30:
215
  continue
216
  crop = small_pil.crop((x1, y1, x2, y2))
217
  crops.append(crop)
218
- if len(crops) >= MAX_CROPS + 1: # +1 for the full-image crop
219
  break
220
  if len(crops) >= MAX_CROPS + 1:
221
  break
222
 
223
- # SINGLE batched forward pass for all crops
224
  print(f"🧠 Embedding {len(crops)} crop(s) in one batch …")
225
  vecs = self._embed_crops_batch(crops)
226
  for vec in vecs:
227
  extracted.append({"type": "object", "vector": vec})
228
 
229
- # ── Store in cache ────────────────────────────────────────
230
  if len(self._cache) >= self._cache_maxsize:
231
- # Evict the oldest key (simple FIFO)
232
  oldest = next(iter(self._cache))
233
  del self._cache[oldest]
234
  self._cache[cache_key] = extracted
235
 
236
  return extracted
237
 
238
- # ── Async wrapper keeps FastAPI non-blocking ─────────────────
239
- async def process_image_async(
240
- self,
241
- image_path: str,
242
- is_query: bool = False,
243
- detect_faces: bool = True,
244
- ) -> list[dict]:
245
- """
246
- Call this from async FastAPI endpoints instead of process_image().
247
- Runs the heavy CPU/GPU work in a thread pool so the event loop
248
- is never blocked, enabling true concurrent request handling.
249
- """
250
  loop = asyncio.get_event_loop()
251
- return await loop.run_in_executor(
252
- None,
253
- functools.partial(self.process_image, image_path, is_query, detect_faces),
254
- )
 
1
  # src/models.py
2
+ import os
3
+ # FIX 1: Force Legacy Keras to prevent DeepFace/RetinaFace crash in TF 2.16+
4
+ os.environ["TF_USE_LEGACY_KERAS"] = "1"
5
+ os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" # Hides the annoying CUDA/cuInit warnings
 
 
 
 
 
 
 
 
6
 
7
  import asyncio
8
  import hashlib
 
24
 
25
 
26
  def _resize_pil(img: Image.Image, max_side: int = MAX_IMAGE_SIZE) -> Image.Image:
 
27
  w, h = img.size
28
  if max(w, h) <= max_side:
29
  return img
30
  scale = max_side / max(w, h)
31
  return img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
32
 
 
33
  def _img_hash(image_path: str) -> str:
 
34
  h = hashlib.md5()
35
  with open(image_path, "rb") as f:
36
  h.update(f.read(65536))
37
  return h.hexdigest()
38
 
 
39
  class AIModelManager:
40
  def __init__(self):
41
  self.device = (
 
44
  )
45
  print(f"Loading models onto: {self.device.upper()}...")
46
 
47
+ self.siglip_processor = AutoProcessor.from_pretrained("google/siglip-base-patch16-224", use_fast=True)
48
+ self.siglip_model = AutoModel.from_pretrained("google/siglip-base-patch16-224").to(self.device).eval()
 
 
 
 
 
49
 
 
50
  self.dinov2_processor = AutoImageProcessor.from_pretrained("facebook/dinov2-base")
51
+ self.dinov2_model = AutoModel.from_pretrained("facebook/dinov2-base").to(self.device).eval()
 
 
52
 
 
53
  if self.device == "cuda":
54
  self.siglip_model = self.siglip_model.half()
55
  self.dinov2_model = self.dinov2_model.half()
56
 
57
+ # FIX 2: Removed torch.compile() because HF Spaces do not have the g++ compiler installed by default.
58
+ # This fixes the "InvalidCxxCompiler" Search crash.
59
+
 
 
 
 
 
 
 
 
 
60
  self.yolo = YOLO("yolo11n.pt")
61
 
 
 
62
  self._cache = {}
63
  self._cache_maxsize = 256
64
 
65
  print("✅ Models ready!")
66
 
 
67
  def _embed_crops_batch(self, crops: list[Image.Image]) -> list[np.ndarray]:
 
 
 
 
68
  if not crops:
69
  return []
70
 
71
  with torch.no_grad():
72
+ sig_inputs = self.siglip_processor(images=crops, return_tensors="pt", padding=True)
 
 
 
73
  sig_inputs = {k: v.to(self.device) for k, v in sig_inputs.items()}
74
  if self.device == "cuda":
75
+ sig_inputs = {k: v.half() if v.dtype == torch.float32 else v for k, v in sig_inputs.items()}
 
76
 
77
  sig_out = self.siglip_model.get_image_features(**sig_inputs)
78
  if hasattr(sig_out, "image_embeds"):
79
  sig_out = sig_out.image_embeds
80
  elif isinstance(sig_out, tuple):
81
  sig_out = sig_out[0]
82
+ sig_vecs = F.normalize(sig_out.float(), p=2, dim=1).cpu()
83
 
84
+ dino_inputs = self.dinov2_processor(images=crops, return_tensors="pt")
 
 
 
85
  dino_inputs = {k: v.to(self.device) for k, v in dino_inputs.items()}
86
  if self.device == "cuda":
87
+ dino_inputs = {k: v.half() if v.dtype == torch.float32 else v for k, v in dino_inputs.items()}
 
88
 
89
  dino_out = self.dinov2_model(**dino_inputs)
90
+ dino_vecs = dino_out.last_hidden_state[:, 0, :]
91
+ dino_vecs = F.normalize(dino_vecs.float(), p=2, dim=1).cpu()
92
 
 
93
  fused = F.normalize(torch.cat([sig_vecs, dino_vecs], dim=1), p=2, dim=1)
94
 
95
  return [fused[i].numpy() for i in range(len(crops))]
96
 
97
+ def process_image(self, image_path: str, is_query: bool = False, detect_faces: bool = True) -> list[dict]:
 
 
 
 
 
 
 
 
 
 
 
98
  cache_key = _img_hash(image_path)
99
  if cache_key in self._cache:
100
  print("⚡ Cache hit — skipping inference")
101
  return self._cache[cache_key]
102
 
103
  extracted = []
 
 
104
  original_pil = Image.open(image_path).convert("RGB")
105
  small_pil = _resize_pil(original_pil, MAX_IMAGE_SIZE)
106
  img_np = np.array(small_pil)
 
107
  faces_found = False
108
 
 
 
 
109
  if detect_faces:
110
  try:
111
  print("🔍 Face detection …")
 
113
  img_path=img_np,
114
  model_name="GhostFaceNet",
115
  detector_backend="retinaface",
116
+ enforce_detection=False,
117
  align=True,
118
  )
 
119
  for face in (face_objs or []):
120
  fa = face.get("facial_area", {})
121
  if fa.get("w", 0) * fa.get("h", 0) < MIN_FACE_AREA:
 
127
 
128
  except Exception as e:
129
  print(f"🟠 Face lane error: {e} — falling back to object lane")
 
 
 
 
 
 
 
 
130
 
131
+ crops = [small_pil]
132
  yolo_results = self.yolo(image_path, conf=0.5, verbose=False)
133
 
134
  for r in yolo_results:
135
  if r.boxes is None:
136
  continue
137
+ for box in r.boxes:
138
  cls_id = int(box.cls.item())
139
  if faces_found and cls_id == YOLO_PERSON_CLASS_ID:
140
+ continue
141
  x1, y1, x2, y2 = box.xyxy[0].tolist()
142
  w, h = x2 - x1, y2 - y1
143
  if w < 30 or h < 30:
144
  continue
145
  crop = small_pil.crop((x1, y1, x2, y2))
146
  crops.append(crop)
147
+ if len(crops) >= MAX_CROPS + 1:
148
  break
149
  if len(crops) >= MAX_CROPS + 1:
150
  break
151
 
 
152
  print(f"🧠 Embedding {len(crops)} crop(s) in one batch …")
153
  vecs = self._embed_crops_batch(crops)
154
  for vec in vecs:
155
  extracted.append({"type": "object", "vector": vec})
156
 
 
157
  if len(self._cache) >= self._cache_maxsize:
 
158
  oldest = next(iter(self._cache))
159
  del self._cache[oldest]
160
  self._cache[cache_key] = extracted
161
 
162
  return extracted
163
 
164
+ async def process_image_async(self, image_path: str, is_query: bool = False, detect_faces: bool = True) -> list[dict]:
 
 
 
 
 
 
 
 
 
 
 
165
  loop = asyncio.get_event_loop()
166
+ return await loop.run_in_executor(None, functools.partial(self.process_image, image_path, is_query, detect_faces))