File size: 15,563 Bytes
ef87e19
 
 
 
 
 
 
 
 
 
58c92f2
ef87e19
362d86f
 
ef87e19
362d86f
ef87e19
 
362d86f
c96096b
 
68c0f26
ef87e19
 
3e805ab
ef87e19
3e805ab
c96096b
ef87e19
 
 
 
 
 
 
 
 
 
c96096b
ef87e19
 
 
 
 
 
362d86f
 
 
 
 
 
 
 
 
ef87e19
362d86f
 
 
 
 
c96096b
ef87e19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3e805ab
 
362d86f
 
 
 
c96096b
 
ef87e19
 
 
 
 
362d86f
 
ef87e19
 
362d86f
 
 
 
 
ef87e19
 
362d86f
ef87e19
 
4d03437
ef87e19
 
4d03437
 
ef87e19
4d03437
68c0f26
 
 
 
 
4d03437
 
 
 
 
ef87e19
4d03437
 
 
ef87e19
 
4d03437
362d86f
ef87e19
 
68c0f26
 
 
362d86f
 
ef87e19
 
362d86f
 
3e805ab
ef87e19
 
362d86f
ef87e19
 
 
 
 
58c92f2
362d86f
ef87e19
 
362d86f
ef87e19
 
 
 
 
362d86f
 
 
 
ef87e19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d03437
ef87e19
 
 
4d03437
ef87e19
 
 
 
 
 
68c0f26
 
4d03437
ef87e19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362d86f
 
 
 
ef87e19
362d86f
ef87e19
362d86f
c96096b
ef87e19
8c6ce56
ef87e19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d03437
 
 
362d86f
c96096b
 
5d013dc
 
 
4d03437
5d013dc
ef87e19
5d013dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362d86f
 
5d013dc
ef87e19
 
 
362d86f
 
ef87e19
362d86f
 
 
 
 
 
 
ef87e19
 
 
 
 
 
362d86f
ef87e19
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# src/models.py  —  Enterprise Lens V3
# ════════════════════════════════════════════════════════════════════
# Face Lane  : InsightFace (YuNet detection + ArcFace 512-D encoding)
#              • Replaces DeepFace + RetinaFace + GhostFaceNet entirely
#              • 3-5x faster on CPU, handles small faces (≥20×20 px)
#              • Stores one 512-D vector PER face (not per image)
#              • Each vector carries a base64 face-crop thumbnail
# Object Lane: SigLIP + DINOv2 fused 1536-D (unchanged from V2)
# ════════════════════════════════════════════════════════════════════

import os
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"

import asyncio
import base64
import functools
import hashlib
import io

import cv2
import numpy as np
import threading
import torch
import torch.nn.functional as F
from PIL import Image
from transformers import AutoImageProcessor, AutoModel, AutoProcessor
from ultralytics import YOLO

# ── InsightFace ───────────────────────────────────────────────────
try:
    import insightface
    from insightface.app import FaceAnalysis
    INSIGHTFACE_AVAILABLE = True
except ImportError:
    INSIGHTFACE_AVAILABLE = False
    print("⚠️  insightface not installed — face lane disabled")

# ── Constants ─────────────────────────────────────────────────────
YOLO_PERSON_CLASS_ID = 0
MIN_FACE_SIZE        = 20     # minimum face width/height in pixels
MAX_FACES_PER_IMAGE  = 10     # cap faces per image for upload
MAX_CROPS            = 6      # max YOLO object crops per image
MAX_IMAGE_SIZE       = 640    # resize longest edge before inference (V3: 640 vs V2: 512)
FACE_CROP_THUMB_SIZE = 112    # face thumbnail size stored in Pinecone metadata
FACE_CROP_QUALITY    = 75     # JPEG quality for stored thumbnails


def _resize_pil(img: Image.Image, max_side: int = MAX_IMAGE_SIZE) -> Image.Image:
    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:
    h = hashlib.md5()
    with open(image_path, "rb") as f:
        h.update(f.read(65536))
    return h.hexdigest()


def _crop_to_b64(img_np: np.ndarray, bbox: list, thumb_size: int = FACE_CROP_THUMB_SIZE) -> str:
    """Crop face from image, resize to thumbnail, return as base64 JPEG string."""
    x, y, w, h = bbox
    x, y = max(0, x), max(0, y)
    # Add 20% padding for more natural face crop
    pad_x = int(w * 0.2)
    pad_y = int(h * 0.2)
    x1 = max(0, x - pad_x)
    y1 = max(0, y - pad_y)
    x2 = min(img_np.shape[1], x + w + pad_x)
    y2 = min(img_np.shape[0], y + h + pad_y)
    face_crop = img_np[y1:y2, x1:x2]
    if face_crop.size == 0:
        return ""
    # Resize to thumbnail
    face_pil  = Image.fromarray(face_crop[..., ::-1])  # BGR → RGB
    face_pil  = face_pil.resize((thumb_size, thumb_size), Image.LANCZOS)
    buf = io.BytesIO()
    face_pil.save(buf, format="JPEG", quality=FACE_CROP_QUALITY)
    return base64.b64encode(buf.getvalue()).decode()


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()}...")

        # ── Object Lane: SigLIP + DINOv2 (unchanged) ─────────────
        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()

        self.dinov2_processor = AutoImageProcessor.from_pretrained("facebook/dinov2-base")
        self.dinov2_model = AutoModel.from_pretrained(
            "facebook/dinov2-base").to(self.device).eval()

        if self.device == "cuda":
            self.siglip_model = self.siglip_model.half()
            self.dinov2_model = self.dinov2_model.half()

        # ── YOLO for object segmentation ─────────────────────────
        self.yolo = YOLO("yolo11n-seg.pt")

        # ── Face Lane: InsightFace (YuNet + ArcFace) ─────────────
        self.face_app = None
        print(f"🔍 INSIGHTFACE_AVAILABLE = {INSIGHTFACE_AVAILABLE}")
        if INSIGHTFACE_AVAILABLE:
            try:
                import insightface
                print(f"🔍 InsightFace version: {insightface.__version__}")
                model_name = "buffalo_l" if self.device == "cuda" else "buffalo_sc"
                print(f"🔍 Loading InsightFace model: {model_name}")
                self.face_app = FaceAnalysis(name=model_name)
                self.face_app.prepare(
                    ctx_id=0 if self.device == "cuda" else -1,
                    det_size=(640, 640),
                )
                # Test with a blank image to confirm models loaded
                import numpy as _np
                test_img = _np.zeros((112, 112, 3), dtype=_np.uint8)
                _ = self.face_app.get(test_img)
                print(f"✅ InsightFace ({model_name}) loaded — ArcFace face lane ACTIVE")
            except Exception as e:
                import traceback
                print(f"❌ InsightFace init FAILED: {e}")
                print(traceback.format_exc())
                self.face_app = None
        else:
            print("❌ InsightFace NOT installed — run: pip install insightface onnxruntime")

        self._cache          = {}
        self._cache_maxsize  = 128
        # InsightFace ONNX runtime is NOT thread-safe
        # This lock ensures only one inference runs at a time
        self._face_lock      = threading.Lock()
        print("✅ Models ready!")

    # ── Object Lane batched embedding ────────────────────────────
    def _embed_crops_batch(self, crops: list) -> list:
        if not crops:
            return []
        with torch.no_grad():
            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)
            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()

            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))]

    # ── V3 Face detection + encoding ─────────────────────────────
    def _detect_and_encode_faces(self, img_np: np.ndarray) -> list:
        """
        Detect ALL faces in image using InsightFace (YuNet + ArcFace).
        Returns list of dicts:
        {
          "type":       "face",
          "vector":     np.ndarray (512-D ArcFace embedding),
          "face_idx":   int,
          "bbox":       [x, y, w, h],
          "face_crop":  str (base64 JPEG thumbnail),
          "det_score":  float (detection confidence)
        }
        """
        if self.face_app is None:
            print("⚠️  face_app is None — InsightFace not loaded!")
            return []

        try:
            print(f"🔍 Running InsightFace on image shape: {img_np.shape}")
            # InsightFace expects BGR numpy array
            if img_np.shape[2] == 3 and img_np.dtype == np.uint8:
                bgr = img_np[..., ::-1].copy()  # RGB → BGR
            else:
                bgr = img_np.copy()

            with self._face_lock:
                faces = self.face_app.get(bgr)
            print(f"🔍 InsightFace raw detection: {len(faces)} faces found")
            results = []

            for idx, face in enumerate(faces):
                if idx >= MAX_FACES_PER_IMAGE:
                    break

                # Get bounding box
                bbox = face.bbox.astype(int)  # [x1, y1, x2, y2]
                x1, y1, x2, y2 = bbox
                w, h = x2 - x1, y2 - y1

                # Skip tiny faces
                if w < MIN_FACE_SIZE or h < MIN_FACE_SIZE:
                    continue

                # Get ArcFace embedding (already L2-normalised by InsightFace)
                if face.embedding is None:
                    continue
                vec = face.embedding.astype(np.float32)
                # Re-normalise just to be safe
                norm = np.linalg.norm(vec)
                if norm > 0:
                    vec = vec / norm

                # Generate face crop thumbnail for UI
                face_crop_b64 = _crop_to_b64(
                    bgr, [x1, y1, w, h], FACE_CROP_THUMB_SIZE)

                results.append({
                    "type":      "face",
                    "vector":    vec,
                    "face_idx":  idx,
                    "bbox":      [int(x1), int(y1), int(w), int(h)],
                    "face_crop": face_crop_b64,
                    "det_score": float(face.det_score) if hasattr(face, "det_score") else 1.0,
                })

            print(f"👤 Detected {len(results)} face(s) via InsightFace ArcFace")
            return results

        except Exception as e:
            print(f"🟠 InsightFace error: {e} — falling back to object lane")
            return []

    # ── Main process_image ────────────────────────────────────────
    def process_image(
        self,
        image_path: str,
        is_query: bool = False,
        detect_faces: bool = True,
    ) -> list:
        """
        Returns list of vector dicts for upload or search.

        Upload mode (is_query=False):
          - Face vectors include bbox + face_crop for Pinecone metadata
          - Object vectors include full-image + YOLO crops

        Query mode (is_query=True):
          - Same structure — main.py handles grouping for search response
        """
        cache_key = f"{_img_hash(image_path)}_{detect_faces}_{is_query}"
        if cache_key in self._cache:
            print("⚡ Cache hit — skipping inference")
            return self._cache[cache_key]

        extracted    = []
        original_pil = Image.open(image_path).convert("RGB")
        img_np       = np.array(original_pil)  # RGB, uint8
        faces_found  = False

        # ── FACE LANE ────────────────────────────────────────────
        if detect_faces:
            # Resize for face detection (640px for small face detection)
            detect_pil = _resize_pil(original_pil, 640)
            detect_np  = np.array(detect_pil)

            face_results = self._detect_and_encode_faces(detect_np)

            if face_results:
                faces_found = True
                # Scale bbox back to original image size if resized
                scale_x = original_pil.width  / detect_pil.width
                scale_y = original_pil.height / detect_pil.height
                for fr in face_results:
                    if scale_x != 1.0 or scale_y != 1.0:
                        bx, by, bw, bh = fr["bbox"]
                        fr["bbox"] = [
                            int(bx * scale_x), int(by * scale_y),
                            int(bw * scale_x), int(bh * scale_y),
                        ]
                    extracted.append(fr)

        # ── OBJECT LANE ──────────────────────────────────────────
        # Always run object lane — even if faces found
        # (image may contain both people and objects)
        crops_pil    = [_resize_pil(original_pil, MAX_IMAGE_SIZE)]  # full-image always
        yolo_results = self.yolo(image_path, conf=0.5, 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())
                    # Skip person crops if face lane already handled them
                    if faces_found and cls_id == YOLO_PERSON_CLASS_ID:
                        print("🔵 PERSON crop skipped — face lane active")
                        continue
                    polygon = np.array(mask_xy, dtype=np.int32)
                    if len(polygon) < 3:
                        continue
                    x, y, w, h = cv2.boundingRect(polygon)
                    if w < 30 or h < 30:
                        continue
                    crop = original_pil.crop((x, y, x + w, y + h))
                    crops_pil.append(crop)
                    if len(crops_pil) >= MAX_CROPS + 1:
                        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) < 30 or (y2 - y1) < 30:
                        continue
                    crop = original_pil.crop((x1, y1, x2, y2))
                    crops_pil.append(crop)
            if len(crops_pil) >= MAX_CROPS + 1:
                break

        crops = [_resize_pil(c, MAX_IMAGE_SIZE) for c in crops_pil]
        print(f"🧠 Embedding {len(crops)} object crop(s) in one batch …")
        obj_vecs = self._embed_crops_batch(crops)
        for vec in obj_vecs:
            extracted.append({"type": "object", "vector": vec})

        # Cache result
        if len(self._cache) >= self._cache_maxsize:
            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,
        is_query: bool = False,
        detect_faces: bool = True,
    ) -> list:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(
            None,
            functools.partial(self.process_image, image_path, is_query, detect_faces),
        )