AdarshDRC commited on
Commit
ef87e19
·
1 Parent(s): b24468d

update: improving the face search system

Browse files
Files changed (4) hide show
  1. Dockerfile +21 -20
  2. main.py +98 -17
  3. requirements.txt +30 -1
  4. src/models.py +235 -83
Dockerfile CHANGED
@@ -18,40 +18,41 @@ COPY . .
18
 
19
  RUN mkdir -p temp_uploads saved_images && chmod -R 777 temp_uploads saved_images
20
 
21
- # ── Pre-download ALL AI models at BUILD time ─────────────────────
22
- # Bakes weights into Docker image layer → cold start ~10 sec not 5 min
 
23
  RUN python - <<'EOF'
24
- from transformers import AutoProcessor, AutoModel, AutoImageProcessor
25
- from ultralytics import YOLO
26
- from deepface import DeepFace
27
- import numpy as np
28
 
29
- print("Pre-downloading SigLIP")
 
30
  AutoProcessor.from_pretrained("google/siglip-base-patch16-224", use_fast=True)
31
  AutoModel.from_pretrained("google/siglip-base-patch16-224")
 
32
 
33
- print("Pre-downloading DINOv2")
 
34
  AutoImageProcessor.from_pretrained("facebook/dinov2-base")
35
  AutoModel.from_pretrained("facebook/dinov2-base")
 
36
 
37
- print("Pre-downloading YOLO (seg model) …")
38
- YOLO("yolo11n-seg.pt") # FIXED: was yolo11n.pt, now yolo11n-seg.pt
 
 
39
 
40
- print("Pre-downloading GhostFaceNet + RetinaFace ")
41
- dummy = np.zeros((112, 112, 3), dtype=np.uint8)
42
- try:
43
- DeepFace.represent(img_path=dummy, model_name="GhostFaceNet",
44
- detector_backend="retinaface", enforce_detection=False)
45
- except Exception:
46
- pass # first run just downloads weights; inference error is fine
47
 
48
- print("All models cached in image layer")
49
  EOF
50
 
51
  EXPOSE 7860
52
 
53
- # ── Single worker — models are already in memory, no need for 2 ──
54
- # 2 workers was causing both to re-download yolo11n-seg.pt simultaneously
55
  ENV WEB_CONCURRENCY=1
56
 
57
  CMD uvicorn main:app \
 
18
 
19
  RUN mkdir -p temp_uploads saved_images && chmod -R 777 temp_uploads saved_images
20
 
21
+ # ── Pre-download ALL models at build time ───────────────────────
22
+ # V3: Added InsightFace buffalo_sc (YuNet + ArcFace)
23
+ # Replaces DeepFace + RetinaFace + GhostFaceNet entirely
24
  RUN python - <<'EOF'
25
+ import os
26
+ os.environ["TRANSFORMERS_VERBOSITY"] = "error"
 
 
27
 
28
+ print("Pre-downloading SigLIP...")
29
+ from transformers import AutoProcessor, AutoModel
30
  AutoProcessor.from_pretrained("google/siglip-base-patch16-224", use_fast=True)
31
  AutoModel.from_pretrained("google/siglip-base-patch16-224")
32
+ print("SigLIP done")
33
 
34
+ print("Pre-downloading DINOv2...")
35
+ from transformers import AutoImageProcessor
36
  AutoImageProcessor.from_pretrained("facebook/dinov2-base")
37
  AutoModel.from_pretrained("facebook/dinov2-base")
38
+ print("DINOv2 done")
39
 
40
+ print("Pre-downloading YOLO seg...")
41
+ from ultralytics import YOLO
42
+ YOLO("yolo11n-seg.pt")
43
+ print("YOLO done")
44
 
45
+ print("Pre-downloading InsightFace buffalo_sc (YuNet + ArcFace)...")
46
+ from insightface.app import FaceAnalysis
47
+ app = FaceAnalysis(name="buffalo_sc", providers=["CPUExecutionProvider"])
48
+ app.prepare(ctx_id=-1, det_size=(640, 640))
49
+ print("InsightFace buffalo_sc done")
 
 
50
 
51
+ print("All V3 models cached!")
52
  EOF
53
 
54
  EXPOSE 7860
55
 
 
 
56
  ENV WEB_CONCURRENCY=1
57
 
58
  CMD uvicorn main:app \
main.py CHANGED
@@ -327,9 +327,20 @@ async def upload_new_images(
327
  face_upserts, object_upserts = [], []
328
  for v in vectors:
329
  vec_list = v["vector"].tolist() if hasattr(v["vector"], "tolist") else v["vector"]
330
- record = {"id": str(uuid.uuid4()), "values": vec_list,
331
- "metadata": {"url": image_url, "folder": folder}}
332
- (face_upserts if v["type"] == "face" else object_upserts).append(record)
 
 
 
 
 
 
 
 
 
 
 
333
 
334
  face_vec_total += len(face_upserts)
335
  object_vec_total += len(object_upserts)
@@ -436,22 +447,89 @@ async def search_database(
436
  "caption": "👤 Verified Identity" if is_face else match["metadata"].get("folder", "🎯 Object Match")})
437
  return out
438
 
439
- nested = await asyncio.gather(*[_query_one(v) for v in vectors])
440
- all_results = [r for sub in nested for r in sub]
441
- seen = {}
442
- for r in all_results:
443
- url = r["url"]
444
- if url not in seen or r["score"] > seen[url]["score"]:
445
- seen[url] = r
446
- final = sorted(seen.values(), key=lambda x: x["score"], reverse=True)[:10]
447
 
448
- log("INFO", "search.complete",
449
- user_id=user_id or "anonymous", ip=ip, mode=mode,
450
- lanes=lanes_used, detect_faces=detect_faces,
451
- results_count=len(final), top_score=final[0]["score"] if final else 0,
452
- duration_ms=round((time.perf_counter()-start)*1000))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
 
454
- return {"results": final}
455
 
456
  except HTTPException: raise
457
  except Exception as e:
@@ -488,9 +566,12 @@ async def get_categories(
488
  log("ERROR", "categories.error", user_id=user_id or "anonymous", ip=ip, error=str(e))
489
  return {"categories": []}
490
 
 
491
  @app.get("/")
492
  async def root():
493
  return {"status": "ok"}
 
 
494
  @app.get("/api/health")
495
  async def health():
496
  return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}
 
327
  face_upserts, object_upserts = [], []
328
  for v in vectors:
329
  vec_list = v["vector"].tolist() if hasattr(v["vector"], "tolist") else v["vector"]
330
+ if v["type"] == "face":
331
+ # V3: store per-face metadata including crop thumbnail + bbox
332
+ metadata = {
333
+ "url": image_url,
334
+ "folder": folder,
335
+ "face_idx": v.get("face_idx", 0),
336
+ "bbox": str(v.get("bbox", [])),
337
+ "face_crop": v.get("face_crop", ""), # base64 thumbnail
338
+ "det_score": v.get("det_score", 1.0),
339
+ }
340
+ face_upserts.append({"id": str(uuid.uuid4()), "values": vec_list, "metadata": metadata})
341
+ else:
342
+ object_upserts.append({"id": str(uuid.uuid4()), "values": vec_list,
343
+ "metadata": {"url": image_url, "folder": folder}})
344
 
345
  face_vec_total += len(face_upserts)
346
  object_vec_total += len(object_upserts)
 
447
  "caption": "👤 Verified Identity" if is_face else match["metadata"].get("folder", "🎯 Object Match")})
448
  return out
449
 
450
+ # ── V3: separate face vectors from object vectors ────────
451
+ face_vectors = [v for v in vectors if v["type"] == "face"]
452
+ object_vectors = [v for v in vectors if v["type"] == "object"]
 
 
 
 
 
453
 
454
+ if detect_faces and face_vectors:
455
+ # ── FACE MODE: return grouped results per detected face ──
456
+ async def _query_face_group(face_vec: dict) -> dict:
457
+ vec_list = face_vec["vector"].tolist() if hasattr(face_vec["vector"], "tolist") else face_vec["vector"]
458
+ try:
459
+ res = await asyncio.to_thread(idx_face.query,
460
+ vector=vec_list, top_k=10, include_metadata=True)
461
+ except Exception as e:
462
+ if "404" in str(e):
463
+ raise HTTPException(404, "Pinecone index not found. Go to Settings → Verify & Save.")
464
+ raise e
465
+
466
+ matches = []
467
+ seen_urls = set()
468
+ for match in res.get("matches", []):
469
+ score = match["score"]
470
+ # ArcFace cosine — threshold 0.35 same as before
471
+ if score < 0.35:
472
+ continue
473
+ url = match["metadata"].get("url", "")
474
+ if url in seen_urls:
475
+ continue
476
+ seen_urls.add(url)
477
+ # Remap score to 75-99% for UI
478
+ ui_score = min(0.99, 0.75 + ((score - 0.35) / 0.65) * 0.24)
479
+ matches.append({
480
+ "url": url,
481
+ "score": round(ui_score, 4),
482
+ "face_crop": match["metadata"].get("face_crop", ""),
483
+ "bbox": match["metadata"].get("bbox", ""),
484
+ "folder": match["metadata"].get("folder", ""),
485
+ "caption": "👤 Verified Identity",
486
+ })
487
+
488
+ return {
489
+ "query_face_idx": face_vec.get("face_idx", 0),
490
+ "query_face_crop": face_vec.get("face_crop", ""),
491
+ "det_score": face_vec.get("det_score", 1.0),
492
+ "matches": sorted(matches, key=lambda x: x["score"], reverse=True)[:10],
493
+ }
494
+
495
+ face_groups = await asyncio.gather(*[_query_face_group(fv) for fv in face_vectors])
496
+ # Filter out groups with 0 matches
497
+ face_groups = [g for g in face_groups if g["matches"]]
498
+
499
+ duration_ms = round((time.perf_counter() - start) * 1000)
500
+ total_matches = sum(len(g["matches"]) for g in face_groups)
501
+ log("INFO", "search.complete",
502
+ user_id=user_id or "anonymous", ip=ip, mode=mode,
503
+ lanes=["face"], detect_faces=detect_faces,
504
+ face_groups=len(face_groups), results_count=total_matches,
505
+ top_score=face_groups[0]["matches"][0]["score"] if face_groups and face_groups[0]["matches"] else 0,
506
+ duration_ms=duration_ms)
507
+
508
+ return {
509
+ "mode": "face",
510
+ "face_groups": list(face_groups),
511
+ "results": [], # empty for backward compat
512
+ }
513
+
514
+ else:
515
+ # ── OBJECT MODE: original flat results ──────────────
516
+ nested = await asyncio.gather(*[_query_one(v) for v in vectors])
517
+ all_results = [r for sub in nested for r in sub]
518
+ seen = {}
519
+ for r in all_results:
520
+ url = r["url"]
521
+ if url not in seen or r["score"] > seen[url]["score"]:
522
+ seen[url] = r
523
+ final = sorted(seen.values(), key=lambda x: x["score"], reverse=True)[:10]
524
+
525
+ duration_ms = round((time.perf_counter() - start) * 1000)
526
+ log("INFO", "search.complete",
527
+ user_id=user_id or "anonymous", ip=ip, mode=mode,
528
+ lanes=lanes_used, detect_faces=detect_faces,
529
+ results_count=len(final), top_score=final[0]["score"] if final else 0,
530
+ duration_ms=duration_ms)
531
 
532
+ return {"mode": "object", "results": final, "face_groups": []}
533
 
534
  except HTTPException: raise
535
  except Exception as e:
 
566
  log("ERROR", "categories.error", user_id=user_id or "anonymous", ip=ip, error=str(e))
567
  return {"categories": []}
568
 
569
+
570
  @app.get("/")
571
  async def root():
572
  return {"status": "ok"}
573
+
574
+
575
  @app.get("/api/health")
576
  async def health():
577
  return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}
requirements.txt CHANGED
@@ -18,4 +18,33 @@ ultralytics
18
  tf-keras
19
  opencv-python-headless
20
  aiohttp
21
- loguru
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  tf-keras
19
  opencv-python-headless
20
  aiohttp
21
+ loguru
22
+ # Enterprise Lens V3 — requirements.txt
23
+ # Face Lane: InsightFace (replaces DeepFace + RetinaFace + GhostFaceNet)
24
+
25
+ # ── Core API ──────────────────────────────────────────────────────
26
+ fastapi
27
+ uvicorn[standard]
28
+ python-multipart
29
+ aiohttp
30
+ loguru
31
+
32
+ # ── AI / ML ───────────────────────────────────────────────────────
33
+ torch
34
+ torchvision
35
+ transformers
36
+ ultralytics
37
+
38
+ # ── V3 Face Engine (replaces deepface) ───────────────────────────
39
+ insightface
40
+ onnxruntime # CPU inference for InsightFace
41
+ opencv-python-headless
42
+
43
+ # ── Vector DB ────────────────────────────────────────────────────
44
+ pinecone
45
+
46
+ # ── Image / Cloud ────────────────────────────────────────────────
47
+ cloudinary
48
+ Pillow
49
+ numpy
50
+ inflect
src/models.py CHANGED
@@ -1,26 +1,47 @@
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
9
  import functools
 
 
10
 
11
- import torch
12
  import cv2
13
  import numpy as np
 
 
14
  from PIL import Image
15
- from transformers import AutoProcessor, AutoModel, AutoImageProcessor
16
  from ultralytics import YOLO
17
- import torch.nn.functional as F
18
- from deepface import DeepFace
19
 
 
 
 
 
 
 
 
 
 
 
20
  YOLO_PERSON_CLASS_ID = 0
21
- MIN_FACE_AREA = 3000 # ~55×55 px minimum face
22
- MAX_CROPS = 6 # max YOLO crops + 1 full-image crop per request
23
- MAX_IMAGE_SIZE = 512 # resize longest edge before any inference
 
 
 
24
 
25
 
26
  def _resize_pil(img: Image.Image, max_side: int = MAX_IMAGE_SIZE) -> Image.Image:
@@ -30,12 +51,36 @@ def _resize_pil(img: Image.Image, max_side: int = MAX_IMAGE_SIZE) -> Image.Image
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,105 +89,207 @@ class AIModelManager:
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=False)
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-seg.pt") # seg model → pixel masks → accurate crops
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 …")
112
- face_objs = DeepFace.represent(
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:
122
- continue
123
- vec = torch.tensor([face["embedding"]])
124
- vec = F.normalize(vec, p=2, dim=1)
125
- extracted.append({"type": "face", "vector": vec.flatten().numpy()})
126
- faces_found = True
127
-
128
- except Exception as e:
129
- print(f"🟠 Face lane error: {e} — falling back to object lane")
130
-
131
- # Full-res PIL for cropsYOLO returns coordinates in full-res pixel space.
132
- # We crop from original_pil then resize each crop before embedding.
133
- # BUG FIX: old optimised code cropped from small_pil (512px) using
134
- # full-res YOLO coordinates → completely wrong crop regions.
135
- crops_pil = [original_pil] # full-image always included for global context
136
-
137
  yolo_results = self.yolo(image_path, conf=0.5, verbose=False)
138
 
139
  for r in yolo_results:
140
- # Use segmentation masks when available (yolo11n-seg.pt)
141
  if r.masks is not None:
142
  for seg_idx, mask_xy in enumerate(r.masks.xy):
143
  cls_id = int(r.boxes.cls[seg_idx].item())
 
144
  if faces_found and cls_id == YOLO_PERSON_CLASS_ID:
145
- print("🔵 PERSON crop skipped — face lane already active")
146
  continue
147
  polygon = np.array(mask_xy, dtype=np.int32)
148
  if len(polygon) < 3:
@@ -155,7 +302,6 @@ class AIModelManager:
155
  if len(crops_pil) >= MAX_CROPS + 1:
156
  break
157
  elif r.boxes is not None:
158
- # Fallback: plain bounding boxes (shouldn't happen with seg model)
159
  for box in r.boxes:
160
  cls_id = int(box.cls.item())
161
  if faces_found and cls_id == YOLO_PERSON_CLASS_ID:
@@ -168,15 +314,13 @@ class AIModelManager:
168
  if len(crops_pil) >= MAX_CROPS + 1:
169
  break
170
 
171
- # Resize each crop to MAX_IMAGE_SIZE before batched embedding
172
- # (models expect ~224px anyway; no quality loss, big speed gain)
173
  crops = [_resize_pil(c, MAX_IMAGE_SIZE) for c in crops_pil]
174
-
175
- print(f"🧠 Embedding {len(crops)} crop(s) in one batch …")
176
- vecs = self._embed_crops_batch(crops)
177
- for vec in vecs:
178
  extracted.append({"type": "object", "vector": vec})
179
 
 
180
  if len(self._cache) >= self._cache_maxsize:
181
  oldest = next(iter(self._cache))
182
  del self._cache[oldest]
@@ -184,6 +328,14 @@ class AIModelManager:
184
 
185
  return extracted
186
 
187
- async def process_image_async(self, image_path: str, is_query: bool = False, detect_faces: bool = True) -> list[dict]:
 
 
 
 
 
188
  loop = asyncio.get_event_loop()
189
- return await loop.run_in_executor(None, functools.partial(self.process_image, image_path, is_query, detect_faces))
 
 
 
 
1
+ # src/models.py — Enterprise Lens V3
2
+ # ════════════════════════════════════════════════════════════════════
3
+ # Face Lane : InsightFace (YuNet detection + ArcFace 512-D encoding)
4
+ # • Replaces DeepFace + RetinaFace + GhostFaceNet entirely
5
+ # • 3-5x faster on CPU, handles small faces (≥20×20 px)
6
+ # • Stores one 512-D vector PER face (not per image)
7
+ # • Each vector carries a base64 face-crop thumbnail
8
+ # Object Lane: SigLIP + DINOv2 fused 1536-D (unchanged from V2)
9
+ # ════════════════════════════════════════════════════════════════════
10
+
11
  import os
12
+ os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
 
 
13
 
14
  import asyncio
15
+ import base64
16
  import functools
17
+ import hashlib
18
+ import io
19
 
 
20
  import cv2
21
  import numpy as np
22
+ import torch
23
+ import torch.nn.functional as F
24
  from PIL import Image
25
+ from transformers import AutoImageProcessor, AutoModel, AutoProcessor
26
  from ultralytics import YOLO
 
 
27
 
28
+ # ── InsightFace ───────────────────────────────────────────────────
29
+ try:
30
+ import insightface
31
+ from insightface.app import FaceAnalysis
32
+ INSIGHTFACE_AVAILABLE = True
33
+ except ImportError:
34
+ INSIGHTFACE_AVAILABLE = False
35
+ print("⚠️ insightface not installed — face lane disabled")
36
+
37
+ # ── Constants ─────────────────────────────────────────────────────
38
  YOLO_PERSON_CLASS_ID = 0
39
+ MIN_FACE_SIZE = 20 # minimum face width/height in pixels
40
+ MAX_FACES_PER_IMAGE = 10 # cap faces per image for upload
41
+ MAX_CROPS = 6 # max YOLO object crops per image
42
+ MAX_IMAGE_SIZE = 640 # resize longest edge before inference (V3: 640 vs V2: 512)
43
+ FACE_CROP_THUMB_SIZE = 112 # face thumbnail size stored in Pinecone metadata
44
+ FACE_CROP_QUALITY = 75 # JPEG quality for stored thumbnails
45
 
46
 
47
  def _resize_pil(img: Image.Image, max_side: int = MAX_IMAGE_SIZE) -> Image.Image:
 
51
  scale = max_side / max(w, h)
52
  return img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
53
 
54
+
55
  def _img_hash(image_path: str) -> str:
56
  h = hashlib.md5()
57
  with open(image_path, "rb") as f:
58
  h.update(f.read(65536))
59
  return h.hexdigest()
60
 
61
+
62
+ def _crop_to_b64(img_np: np.ndarray, bbox: list, thumb_size: int = FACE_CROP_THUMB_SIZE) -> str:
63
+ """Crop face from image, resize to thumbnail, return as base64 JPEG string."""
64
+ x, y, w, h = bbox
65
+ x, y = max(0, x), max(0, y)
66
+ # Add 20% padding for more natural face crop
67
+ pad_x = int(w * 0.2)
68
+ pad_y = int(h * 0.2)
69
+ x1 = max(0, x - pad_x)
70
+ y1 = max(0, y - pad_y)
71
+ x2 = min(img_np.shape[1], x + w + pad_x)
72
+ y2 = min(img_np.shape[0], y + h + pad_y)
73
+ face_crop = img_np[y1:y2, x1:x2]
74
+ if face_crop.size == 0:
75
+ return ""
76
+ # Resize to thumbnail
77
+ face_pil = Image.fromarray(face_crop[..., ::-1]) # BGR → RGB
78
+ face_pil = face_pil.resize((thumb_size, thumb_size), Image.LANCZOS)
79
+ buf = io.BytesIO()
80
+ face_pil.save(buf, format="JPEG", quality=FACE_CROP_QUALITY)
81
+ return base64.b64encode(buf.getvalue()).decode()
82
+
83
+
84
  class AIModelManager:
85
  def __init__(self):
86
  self.device = (
 
89
  )
90
  print(f"Loading models onto: {self.device.upper()}...")
91
 
92
+ # ── Object Lane: SigLIP + DINOv2 (unchanged) ─────────────
93
+ self.siglip_processor = AutoProcessor.from_pretrained(
94
+ "google/siglip-base-patch16-224", use_fast=True)
95
+ self.siglip_model = AutoModel.from_pretrained(
96
+ "google/siglip-base-patch16-224").to(self.device).eval()
97
 
98
  self.dinov2_processor = AutoImageProcessor.from_pretrained("facebook/dinov2-base")
99
+ self.dinov2_model = AutoModel.from_pretrained(
100
+ "facebook/dinov2-base").to(self.device).eval()
101
 
102
  if self.device == "cuda":
103
  self.siglip_model = self.siglip_model.half()
104
  self.dinov2_model = self.dinov2_model.half()
105
 
106
+ # ── YOLO for object segmentation ─────────────────────────
107
+ self.yolo = YOLO("yolo11n-seg.pt")
 
 
108
 
109
+ # ── Face Lane: InsightFace (YuNet + ArcFace) ─────────────
110
+ self.face_app = None
111
+ if INSIGHTFACE_AVAILABLE:
112
+ try:
113
+ # buffalo_sc = small+fast model (CPU optimised)
114
+ # buffalo_l = large+accurate (use if GPU available)
115
+ model_name = "buffalo_l" if self.device == "cuda" else "buffalo_sc"
116
+ self.face_app = FaceAnalysis(
117
+ name=model_name,
118
+ providers=["CUDAExecutionProvider"] if self.device == "cuda"
119
+ else ["CPUExecutionProvider"],
120
+ )
121
+ # det_size controls detection resolution — larger = finds smaller faces
122
+ self.face_app.prepare(ctx_id=0 if self.device == "cuda" else -1,
123
+ det_size=(640, 640))
124
+ print(f"✅ InsightFace ({model_name}) loaded — ArcFace face lane active")
125
+ except Exception as e:
126
+ print(f"⚠️ InsightFace init failed: {e} — face lane disabled")
127
+ self.face_app = None
128
+ else:
129
+ print("⚠️ InsightFace not available — install: pip install insightface onnxruntime")
130
 
131
+ self._cache = {}
132
+ self._cache_maxsize = 128
133
  print("✅ Models ready!")
134
 
135
+ # ── Object Lane batched embedding ────────────────────────────
136
+ def _embed_crops_batch(self, crops: list) -> list:
137
  if not crops:
138
  return []
 
139
  with torch.no_grad():
140
+ sig_in = self.siglip_processor(images=crops, return_tensors="pt", padding=True)
141
+ sig_in = {k: v.to(self.device) for k, v in sig_in.items()}
142
  if self.device == "cuda":
143
+ sig_in = {k: v.half() if v.dtype == torch.float32 else v
144
+ for k, v in sig_in.items()}
145
+ sig_out = self.siglip_model.get_image_features(**sig_in)
146
+ if hasattr(sig_out, "image_embeds"): sig_out = sig_out.image_embeds
147
+ elif isinstance(sig_out, tuple): sig_out = sig_out[0]
 
 
148
  sig_vecs = F.normalize(sig_out.float(), p=2, dim=1).cpu()
149
 
150
+ dino_in = self.dinov2_processor(images=crops, return_tensors="pt")
151
+ dino_in = {k: v.to(self.device) for k, v in dino_in.items()}
152
  if self.device == "cuda":
153
+ dino_in = {k: v.half() if v.dtype == torch.float32 else v
154
+ for k, v in dino_in.items()}
155
+ dino_out = self.dinov2_model(**dino_in)
156
+ dino_vecs = F.normalize(
157
+ dino_out.last_hidden_state[:, 0, :].float(), p=2, dim=1).cpu()
158
 
159
  fused = F.normalize(torch.cat([sig_vecs, dino_vecs], dim=1), p=2, dim=1)
 
160
  return [fused[i].numpy() for i in range(len(crops))]
161
 
162
+ # ── V3 Face detection + encoding ──────────���──────────────────
163
+ def _detect_and_encode_faces(self, img_np: np.ndarray) -> list:
164
+ """
165
+ Detect ALL faces in image using InsightFace (YuNet + ArcFace).
166
+ Returns list of dicts:
167
+ {
168
+ "type": "face",
169
+ "vector": np.ndarray (512-D ArcFace embedding),
170
+ "face_idx": int,
171
+ "bbox": [x, y, w, h],
172
+ "face_crop": str (base64 JPEG thumbnail),
173
+ "det_score": float (detection confidence)
174
+ }
175
+ """
176
+ if self.face_app is None:
177
+ return []
178
+
179
+ try:
180
+ # InsightFace expects BGR numpy array
181
+ if img_np.shape[2] == 3 and img_np.dtype == np.uint8:
182
+ bgr = img_np[..., ::-1].copy() # RGB → BGR
183
+ else:
184
+ bgr = img_np.copy()
185
+
186
+ faces = self.face_app.get(bgr)
187
+ results = []
188
+
189
+ for idx, face in enumerate(faces):
190
+ if idx >= MAX_FACES_PER_IMAGE:
191
+ break
192
+
193
+ # Get bounding box
194
+ bbox = face.bbox.astype(int) # [x1, y1, x2, y2]
195
+ x1, y1, x2, y2 = bbox
196
+ w, h = x2 - x1, y2 - y1
197
+
198
+ # Skip tiny faces
199
+ if w < MIN_FACE_SIZE or h < MIN_FACE_SIZE:
200
+ continue
201
+
202
+ # Get ArcFace embedding (already L2-normalised by InsightFace)
203
+ if face.embedding is None:
204
+ continue
205
+ vec = face.embedding.astype(np.float32)
206
+ # Re-normalise just to be safe
207
+ norm = np.linalg.norm(vec)
208
+ if norm > 0:
209
+ vec = vec / norm
210
+
211
+ # Generate face crop thumbnail for UI
212
+ face_crop_b64 = _crop_to_b64(
213
+ bgr, [x1, y1, w, h], FACE_CROP_THUMB_SIZE)
214
+
215
+ results.append({
216
+ "type": "face",
217
+ "vector": vec,
218
+ "face_idx": idx,
219
+ "bbox": [int(x1), int(y1), int(w), int(h)],
220
+ "face_crop": face_crop_b64,
221
+ "det_score": float(face.det_score) if hasattr(face, "det_score") else 1.0,
222
+ })
223
+
224
+ print(f"👤 Detected {len(results)} face(s) via InsightFace ArcFace")
225
+ return results
226
+
227
+ except Exception as e:
228
+ print(f"🟠 InsightFace error: {e} — falling back to object lane")
229
+ return []
230
+
231
+ # ── Main process_image ────────────────────────────────────────
232
+ def process_image(
233
+ self,
234
+ image_path: str,
235
+ is_query: bool = False,
236
+ detect_faces: bool = True,
237
+ ) -> list:
238
+ """
239
+ Returns list of vector dicts for upload or search.
240
+
241
+ Upload mode (is_query=False):
242
+ - Face vectors include bbox + face_crop for Pinecone metadata
243
+ - Object vectors include full-image + YOLO crops
244
+
245
+ Query mode (is_query=True):
246
+ - Same structure — main.py handles grouping for search response
247
+ """
248
+ cache_key = f"{_img_hash(image_path)}_{detect_faces}_{is_query}"
249
  if cache_key in self._cache:
250
  print("⚡ Cache hit — skipping inference")
251
  return self._cache[cache_key]
252
 
253
+ extracted = []
254
  original_pil = Image.open(image_path).convert("RGB")
255
+ img_np = np.array(original_pil) # RGB, uint8
 
256
  faces_found = False
257
 
258
+ # ── FACE LANE ────────────────────────────────────────────
259
  if detect_faces:
260
+ # Resize for face detection (640px for small face detection)
261
+ detect_pil = _resize_pil(original_pil, 640)
262
+ detect_np = np.array(detect_pil)
263
+
264
+ face_results = self._detect_and_encode_faces(detect_np)
265
+
266
+ if face_results:
267
+ faces_found = True
268
+ # Scale bbox back to original image size if resized
269
+ scale_x = original_pil.width / detect_pil.width
270
+ scale_y = original_pil.height / detect_pil.height
271
+ for fr in face_results:
272
+ if scale_x != 1.0 or scale_y != 1.0:
273
+ bx, by, bw, bh = fr["bbox"]
274
+ fr["bbox"] = [
275
+ int(bx * scale_x), int(by * scale_y),
276
+ int(bw * scale_x), int(bh * scale_y),
277
+ ]
278
+ extracted.append(fr)
279
+
280
+ # ── OBJECT LANE ──────────────────────────────────────────
281
+ # Always run object laneeven if faces found
282
+ # (image may contain both people and objects)
283
+ crops_pil = [_resize_pil(original_pil, MAX_IMAGE_SIZE)] # full-image always
 
 
 
284
  yolo_results = self.yolo(image_path, conf=0.5, verbose=False)
285
 
286
  for r in yolo_results:
 
287
  if r.masks is not None:
288
  for seg_idx, mask_xy in enumerate(r.masks.xy):
289
  cls_id = int(r.boxes.cls[seg_idx].item())
290
+ # Skip person crops if face lane already handled them
291
  if faces_found and cls_id == YOLO_PERSON_CLASS_ID:
292
+ print("🔵 PERSON crop skipped — face lane active")
293
  continue
294
  polygon = np.array(mask_xy, dtype=np.int32)
295
  if len(polygon) < 3:
 
302
  if len(crops_pil) >= MAX_CROPS + 1:
303
  break
304
  elif r.boxes is not None:
 
305
  for box in r.boxes:
306
  cls_id = int(box.cls.item())
307
  if faces_found and cls_id == YOLO_PERSON_CLASS_ID:
 
314
  if len(crops_pil) >= MAX_CROPS + 1:
315
  break
316
 
 
 
317
  crops = [_resize_pil(c, MAX_IMAGE_SIZE) for c in crops_pil]
318
+ print(f"🧠 Embedding {len(crops)} object crop(s) in one batch …")
319
+ obj_vecs = self._embed_crops_batch(crops)
320
+ for vec in obj_vecs:
 
321
  extracted.append({"type": "object", "vector": vec})
322
 
323
+ # Cache result
324
  if len(self._cache) >= self._cache_maxsize:
325
  oldest = next(iter(self._cache))
326
  del self._cache[oldest]
 
328
 
329
  return extracted
330
 
331
+ async def process_image_async(
332
+ self,
333
+ image_path: str,
334
+ is_query: bool = False,
335
+ detect_faces: bool = True,
336
+ ) -> list:
337
  loop = asyncio.get_event_loop()
338
+ return await loop.run_in_executor(
339
+ None,
340
+ functools.partial(self.process_image, image_path, is_query, detect_faces),
341
+ )