AdarshDRC commited on
Commit
1feca1e
·
1 Parent(s): 3933e8f

fix : search engine

Browse files
Files changed (4) hide show
  1. Dockerfile +111 -37
  2. requirements.txt +56 -0
  3. src/cloud_db.py +222 -44
  4. src/models.py +122 -130
Dockerfile CHANGED
@@ -1,69 +1,143 @@
1
- # Dockerfile — Enterprise Lens V3
2
- # InsightFace models download on first run (not at build time)
3
- # This avoids build timeout and network issues during Docker build
 
 
 
 
 
 
 
4
 
5
  FROM python:3.10-slim
6
 
7
  WORKDIR /app
8
 
9
- # ── System deps ──────────────────────────────────────────────────
 
 
 
 
10
  RUN apt-get update && apt-get install -y --no-install-recommends \
11
- libgl1 libglib2.0-0 libgomp1 git \
12
- build-essential cmake g++ \
13
- libopenblas-dev liblapack-dev \
14
- wget ca-certificates \
 
15
  && rm -rf /var/lib/apt/lists/*
16
 
17
- # ── Step 1: Build tools (MUST be before insightface) ─────────────
18
- RUN pip install --no-cache-dir \
19
- "numpy<2.0" \
20
- "setuptools>=65" \
21
- wheel \
22
- cython \
23
- scikit-build \
24
- cmake
25
-
26
- # ── Step 2: onnxruntime (MUST be before insightface) ─────────────
27
- RUN pip install --no-cache-dir onnxruntime
28
-
29
- # ── Step 3: insightface ───────────────────────────────────────────
30
- RUN pip install --no-cache-dir --prefer-binary "insightface>=0.7.3"
31
-
32
- # ── Step 4: Remaining requirements ───────────────────────────────
33
  COPY requirements.txt .
34
- RUN pip install --no-cache-dir --prefer-binary -r requirements.txt
35
 
36
- # ── Copy app code ─────────────────────────────────────────────────
37
  COPY . .
 
38
  RUN mkdir -p temp_uploads saved_images && chmod -R 777 temp_uploads saved_images
39
 
40
- # ── Pre-download ONLY transformers + YOLO at build time ──────────
41
- # InsightFace models download on first startup (cached after that)
 
 
 
 
 
 
 
 
42
  RUN python - <<'EOF'
43
- import os
44
- os.environ["TRANSFORMERS_VERBOSITY"] = "error"
45
 
46
- print("Pre-downloading SigLIP...")
 
47
  from transformers import AutoProcessor, AutoModel
48
  AutoProcessor.from_pretrained("google/siglip-base-patch16-224", use_fast=True)
49
  AutoModel.from_pretrained("google/siglip-base-patch16-224")
50
- print("SigLIP done")
51
 
52
- print("Pre-downloading DINOv2...")
 
53
  from transformers import AutoImageProcessor
54
  AutoImageProcessor.from_pretrained("facebook/dinov2-base")
55
  AutoModel.from_pretrained("facebook/dinov2-base")
56
- print("DINOv2 done")
57
 
58
- print("Pre-downloading YOLO seg...")
 
59
  from ultralytics import YOLO
60
  YOLO("yolo11n-seg.pt")
61
- print("YOLO done")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
- print("Build complete! InsightFace models download on first startup.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  EOF
65
 
66
  EXPOSE 7860
 
 
 
 
 
 
67
  ENV WEB_CONCURRENCY=1
68
 
69
  CMD uvicorn main:app \
 
1
+ # Dockerfile — Enterprise Lens V4
2
+ #
3
+ # Changes vs V3:
4
+ # • Removed deepface / GhostFaceNet / RetinaFace entirely
5
+ # • Added insightface + onnxruntime (SCRFD + ArcFace-R100)
6
+ # • Added huggingface_hub for AdaFace weight download
7
+ # • Pre-downloads AdaFace IR-50 WebFace4M weights at build time
8
+ # • Pre-downloads InsightFace buffalo_l pack at build time
9
+ # • Single worker (InsightFace ONNX is NOT thread-safe)
10
+ # • index dimensions: enterprise-faces=1024, enterprise-objects=1536
11
 
12
  FROM python:3.10-slim
13
 
14
  WORKDIR /app
15
 
16
+ # ── System deps ──────────────────────────────────────────────────
17
+ # libGL + libGLib : OpenCV headless
18
+ # libgomp1 : OpenMP (used by ONNX runtime + numpy)
19
+ # git : needed by some HF hub downloads
20
+ # curl : useful for health checks / debug
21
  RUN apt-get update && apt-get install -y --no-install-recommends \
22
+ libgl1 \
23
+ libglib2.0-0 \
24
+ libgomp1 \
25
+ git \
26
+ curl \
27
  && rm -rf /var/lib/apt/lists/*
28
 
29
+ # ── Python deps ───────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  COPY requirements.txt .
31
+ RUN pip install --no-cache-dir --compile -r requirements.txt
32
 
33
+ # ── Copy application code ────────────────────────────────────────
34
  COPY . .
35
+
36
  RUN mkdir -p temp_uploads saved_images && chmod -R 777 temp_uploads saved_images
37
 
38
+ # ── Pre-download ALL AI models at BUILD time ─────────────────────
39
+ # Bakes weights into image layer cold start ~10s instead of ~5min
40
+ #
41
+ # Model sizes (approximate):
42
+ # SigLIP base ~380 MB
43
+ # DINOv2 base ~330 MB
44
+ # YOLO11n-seg ~6 MB
45
+ # InsightFace buffalo_l (SCRFD-10GF + ArcFace-R100) ~280 MB
46
+ # AdaFace IR-50 WebFace4M ~170 MB
47
+ # Total image delta: ~1.2 GB
48
  RUN python - <<'EOF'
49
+ import os, sys
 
50
 
51
+ # ── SigLIP ────────────────────────────────────────────────────────
52
+ print("📦 Pre-downloading SigLIP...")
53
  from transformers import AutoProcessor, AutoModel
54
  AutoProcessor.from_pretrained("google/siglip-base-patch16-224", use_fast=True)
55
  AutoModel.from_pretrained("google/siglip-base-patch16-224")
56
+ print("SigLIP done")
57
 
58
+ # ── DINOv2 ───────────────────────────────────────────────────────
59
+ print("📦 Pre-downloading DINOv2...")
60
  from transformers import AutoImageProcessor
61
  AutoImageProcessor.from_pretrained("facebook/dinov2-base")
62
  AutoModel.from_pretrained("facebook/dinov2-base")
63
+ print("DINOv2 done")
64
 
65
+ # ── YOLO11n-seg ───────────────────────────────────────────────────
66
+ print("📦 Pre-downloading YOLO11n-seg...")
67
  from ultralytics import YOLO
68
  YOLO("yolo11n-seg.pt")
69
+ print("YOLO done")
70
+
71
+ # ── InsightFace buffalo_l ─────────────────────────────────────────
72
+ # buffalo_l = SCRFD-10GF (detector) + ArcFace-R100 (encoder)
73
+ # Handles small faces in group photos (det_size up to 1280x1280)
74
+ print("📦 Pre-downloading InsightFace buffalo_l...")
75
+ import numpy as np
76
+ from insightface.app import FaceAnalysis
77
+ face_app = FaceAnalysis(
78
+ name="buffalo_l",
79
+ providers=["CPUExecutionProvider"],
80
+ )
81
+ face_app.prepare(ctx_id=-1, det_size=(640, 640))
82
+ # Warmup inference to confirm weights loaded
83
+ test = np.zeros((112, 112, 3), dtype=np.uint8)
84
+ face_app.get(test)
85
+ print(" ✅ InsightFace buffalo_l done")
86
+
87
+ # ── AdaFace IR-50 MS1MV2 ─────────────────────────────────────────
88
+ # Repo: minchul/cvlface_adaface_ir50_ms1mv2
89
+ # Loaded via AutoModel + trust_remote_code=True
90
+ # Requires HF_TOKEN build arg (set in HF Space secrets)
91
+ print("📦 Pre-downloading AdaFace IR-50 MS1MV2...")
92
+ import os, sys
93
+ from huggingface_hub import hf_hub_download
94
+ from transformers import AutoModel
95
+
96
+ HF_TOKEN = os.getenv("HF_TOKEN", None)
97
+ REPO_ID = "minchul/cvlface_adaface_ir50_ms1mv2"
98
+ CACHE_PATH = os.path.expanduser("~/.cvlface_cache/minchul/cvlface_adaface_ir50_ms1mv2")
99
+ os.makedirs(CACHE_PATH, exist_ok=True)
100
 
101
+ # Download files.txt manifest
102
+ hf_hub_download(repo_id=REPO_ID, filename="files.txt",
103
+ token=HF_TOKEN, local_dir=CACHE_PATH, local_dir_use_symlinks=False)
104
+
105
+ with open(os.path.join(CACHE_PATH, "files.txt")) as f:
106
+ extra = [x.strip() for x in f.read().split("\n") if x.strip()]
107
+
108
+ for fname in extra + ["config.json", "wrapper.py", "model.safetensors"]:
109
+ fpath = os.path.join(CACHE_PATH, fname)
110
+ if not os.path.exists(fpath):
111
+ hf_hub_download(repo_id=REPO_ID, filename=fname,
112
+ token=HF_TOKEN, local_dir=CACHE_PATH, local_dir_use_symlinks=False)
113
+
114
+ # Load and verify
115
+ cwd = os.getcwd(); os.chdir(CACHE_PATH); sys.path.insert(0, CACHE_PATH)
116
+ try:
117
+ model = AutoModel.from_pretrained(CACHE_PATH, trust_remote_code=True, token=HF_TOKEN)
118
+ finally:
119
+ os.chdir(cwd)
120
+ if CACHE_PATH in sys.path: sys.path.remove(CACHE_PATH)
121
+
122
+ import torch
123
+ with torch.no_grad():
124
+ out = model(torch.zeros(1, 3, 112, 112))
125
+ emb = out if isinstance(out, torch.Tensor) else out.embedding
126
+ print(f" ✅ AdaFace loaded — output dim={emb.shape[-1]}")
127
+
128
+ print("")
129
+ print("✅ All V4 models pre-downloaded and verified!")
130
+ print(" enterprise-faces index dim : 1024 (ArcFace-512 + AdaFace-512)")
131
+ print(" enterprise-objects index dim: 1536 (SigLIP-768 + DINOv2-768)")
132
  EOF
133
 
134
  EXPOSE 7860
135
+
136
+ # ── Single worker — InsightFace ONNX is NOT thread-safe ──────────
137
+ # Each request acquires _face_lock before ONNX inference.
138
+ # Multiple workers would each load their own model copy into RAM
139
+ # (~1.5 GB each) which OOMs free HF Spaces (16 GB limit).
140
+ # If you have a paid GPU Space with >32 GB RAM, set WEB_CONCURRENCY=2.
141
  ENV WEB_CONCURRENCY=1
142
 
143
  CMD uvicorn main:app \
requirements.txt CHANGED
@@ -76,3 +76,59 @@ onnxruntime>=1.16.0
76
  insightface>=0.7.3
77
 
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  insightface>=0.7.3
77
 
78
 
79
+ # requirements.txt — Enterprise Lens V4
80
+ # ════════════════════════════════════════════════════════════════
81
+ # Face Lane : insightface (SCRFD-10GF + ArcFace-R100)
82
+ # + AdaFace IR-50 (custom PyTorch backbone)
83
+ # + huggingface_hub (AdaFace weight download)
84
+ # Object Lane: transformers (SigLIP + DINOv2) + ultralytics (YOLO)
85
+ # API : fastapi + uvicorn + python-multipart
86
+ # Storage : pinecone + cloudinary
87
+ # Utilities : loguru + inflect + aiohttp + python-dotenv
88
+ # ════════════════════════════════════════════════════════════════
89
+
90
+ # ── Web framework ────────────────────────────────────────────────
91
+ fastapi==0.115.6
92
+ uvicorn[standard]==0.32.1
93
+ python-multipart==0.0.20
94
+
95
+ # ── AI / ML core ────────────────────────────────────────────────
96
+ # CPU-only torch — swap index URL for CUDA build on GPU spaces
97
+ torch==2.4.1+cpu
98
+ torchvision==0.19.1+cpu
99
+ --extra-index-url https://download.pytorch.org/whl/cpu
100
+
101
+ # ── HuggingFace — SigLIP, DINOv2, AdaFace weight download ───────
102
+ transformers==4.46.3
103
+ huggingface_hub==0.26.2
104
+ safetensors==0.4.5
105
+ tokenizers==0.20.3
106
+ accelerate==1.1.1
107
+
108
+ # ── InsightFace — SCRFD detection + ArcFace-R100 encoding ────────
109
+ insightface==0.7.3
110
+ onnxruntime==1.19.2 # CPU ONNX runtime for InsightFace models
111
+
112
+ # ── YOLO — object segmentation crops ────────────────────────────
113
+ ultralytics==8.3.27
114
+
115
+ # ── Computer vision utilities ────────────────────────────────────
116
+ opencv-python-headless==4.10.0.84
117
+ Pillow==11.0.0
118
+ numpy==1.26.4
119
+
120
+ # ── Vector database ──────────────────────────────────────────────
121
+ pinecone==5.4.1
122
+
123
+ # ── Image CDN ────────────────────────────────────────────────────
124
+ cloudinary==1.41.0
125
+
126
+ # ── Async HTTP (Supabase logging) ────────────────────────────────
127
+ aiohttp==3.11.9
128
+
129
+ # ── Logging + text utils ─────────────────────────────────────────
130
+ loguru==0.7.2
131
+ inflect==7.4.0
132
+
133
+ # ── Config ───────────────────────────────────────────────────────
134
+ python-dotenv==1.0.1
src/cloud_db.py CHANGED
@@ -1,68 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
 
2
  import cloudinary
3
  import cloudinary.uploader
4
- from pinecone import Pinecone
5
  from dotenv import load_dotenv
6
 
7
  load_dotenv()
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  class CloudDB:
 
 
 
 
 
 
 
 
 
 
 
10
  def __init__(self):
 
11
  cloudinary.config(
12
- cloud_name=os.getenv("CLOUDINARY_CLOUD_NAME"),
13
- api_key=os.getenv("CLOUDINARY_API_KEY"),
14
- api_secret=os.getenv("CLOUDINARY_API_SECRET")
15
  )
16
-
 
17
  self.pc = Pinecone(api_key=os.getenv("PINECONE_API_KEY"))
18
- # Connect to the TWO new indexes
19
- self.index_faces = self.pc.Index("enterprise-faces")
20
- self.index_objects = self.pc.Index("enterprise-objects")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
- def upload_image(self, file_path, folder_name="visual_search"):
 
 
23
  response = cloudinary.uploader.upload(file_path, folder=folder_name)
24
- return response['secure_url']
25
 
26
- def add_vector(self, data_dict, image_url, image_id):
27
- vector_list = data_dict["vector"].tolist() if hasattr(data_dict["vector"], 'tolist') else data_dict["vector"]
28
-
29
- payload = [{
30
- "id": image_id,
31
- "values": vector_list,
32
- "metadata": {"image_url": image_url}
33
- }]
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  if data_dict["type"] == "face":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  self.index_faces.upsert(vectors=payload)
 
37
  else:
 
 
 
 
 
 
 
 
 
 
38
  self.index_objects.upsert(vectors=payload)
39
 
40
- def search(self, query_dict, top_k=10, min_score=0.45):
41
- vector_list = query_dict["vector"].tolist() if hasattr(query_dict["vector"], 'tolist') else query_dict["vector"]
42
- results = []
43
-
 
 
 
 
 
 
 
 
 
 
 
 
44
  if query_dict["type"] == "face":
45
- response = self.index_faces.query(vector=vector_list, top_k=top_k, include_metadata=True)
46
- RAW_THRESHOLD = 0.35
47
-
48
- for match in response['matches']:
49
- raw_score = match['score']
50
- if raw_score >= RAW_THRESHOLD:
51
- ui_score = 0.75 + ((raw_score - RAW_THRESHOLD) / (1.0 - RAW_THRESHOLD)) * 0.24
52
- ui_score = min(0.99, ui_score)
53
- results.append({
54
- "url": match['metadata']['image_url'],
55
- "score": ui_score,
56
- "caption": "👤 Verified Identity Match"
57
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  else:
59
- response = self.index_objects.query(vector=vector_list, top_k=top_k, include_metadata=True)
60
- for match in response['matches']:
61
- if match['score'] >= min_score:
62
- results.append({
63
- "url": match['metadata']['image_url'],
64
- "score": match['score'],
65
- "caption": "🎯 Visual & Semantic Match"
66
- })
67
-
 
 
 
 
 
 
 
68
  return results
 
1
+ # src/cloud_db.py — Enterprise Lens V4
2
+ # ════════════════════════════════════════════════════════════════
3
+ # NOTE: In the production FastAPI app (main.py), ALL Pinecone and
4
+ # Cloudinary operations are performed directly — this class is NOT
5
+ # called by main.py. It exists as a standalone utility / SDK wrapper
6
+ # for scripts, notebooks, or future use outside the API.
7
+ #
8
+ # If you use this class, ensure your Pinecone indexes match V4 dims:
9
+ # enterprise-faces → 1024-D (ArcFace-512 + AdaFace-512, fused)
10
+ # enterprise-objects → 1536-D (SigLIP-768 + DINOv2-768, fused)
11
+ # ════════════════════════════════════════════════════════════════
12
+
13
  import os
14
+ import uuid
15
  import cloudinary
16
  import cloudinary.uploader
17
+ from pinecone import Pinecone, ServerlessSpec
18
  from dotenv import load_dotenv
19
 
20
  load_dotenv()
21
 
22
+ # ── V4 Index constants — MUST match main.py and models.py ────────
23
+ IDX_FACES = "enterprise-faces"
24
+ IDX_OBJECTS = "enterprise-objects"
25
+ IDX_FACES_DIM = 1024 # ArcFace(512) + AdaFace(512) fused, always 1024
26
+ IDX_OBJECTS_DIM = 1536 # SigLIP(768) + DINOv2(768) fused, always 1536
27
+
28
+ # V4 face similarity thresholds (fused 1024-D cosine space)
29
+ # These MUST stay in sync with main.py FACE_THRESHOLD_* constants
30
+ FACE_THRESHOLD_HIGH = 0.40 # high-quality face (det_score >= 0.85)
31
+ FACE_THRESHOLD_LOW = 0.32 # lower-quality face (det_score < 0.85)
32
+ OBJECT_THRESHOLD = 0.45 # object/scene similarity threshold
33
+
34
+
35
  class CloudDB:
36
+ """
37
+ Utility wrapper around Pinecone + Cloudinary for Enterprise Lens V4.
38
+
39
+ Index dimensions:
40
+ enterprise-faces : 1024-D cosine
41
+ enterprise-objects : 1536-D cosine
42
+
43
+ Face vectors: ArcFace(512) + AdaFace(512) concatenated + L2-normalised
44
+ Object vectors: SigLIP(768) + DINOv2(768) concatenated + L2-normalised
45
+ """
46
+
47
  def __init__(self):
48
+ # ── Cloudinary ────────────────────────────────────────────
49
  cloudinary.config(
50
+ cloud_name = os.getenv("CLOUDINARY_CLOUD_NAME"),
51
+ api_key = os.getenv("CLOUDINARY_API_KEY"),
52
+ api_secret = os.getenv("CLOUDINARY_API_SECRET"),
53
  )
54
+
55
+ # ── Pinecone ──────────────────────────────────────────────
56
  self.pc = Pinecone(api_key=os.getenv("PINECONE_API_KEY"))
57
+ self._ensure_indexes()
58
+ self.index_faces = self.pc.Index(IDX_FACES)
59
+ self.index_objects = self.pc.Index(IDX_OBJECTS)
60
+
61
+ def _ensure_indexes(self):
62
+ """
63
+ Create Pinecone indexes at correct V4 dimensions if they don't exist.
64
+ Safe to call multiple times — skips existing indexes.
65
+ """
66
+ existing = {idx.name for idx in self.pc.list_indexes()}
67
+
68
+ if IDX_FACES not in existing:
69
+ print(f"📦 Creating {IDX_FACES} at {IDX_FACES_DIM}-D...")
70
+ self.pc.create_index(
71
+ name = IDX_FACES,
72
+ dimension = IDX_FACES_DIM, # 1024-D — ArcFace+AdaFace
73
+ metric = "cosine",
74
+ spec = ServerlessSpec(cloud="aws", region="us-east-1"),
75
+ )
76
+ print(f" ✅ {IDX_FACES} created at {IDX_FACES_DIM}-D")
77
+ else:
78
+ # Validate existing index has correct dimension
79
+ desc = self.pc.describe_index(IDX_FACES)
80
+ actual_dim = desc.dimension
81
+ if actual_dim != IDX_FACES_DIM:
82
+ raise ValueError(
83
+ f"❌ {IDX_FACES} exists at {actual_dim}-D but V4 needs "
84
+ f"{IDX_FACES_DIM}-D. Go to Settings → Danger Zone → "
85
+ f"Reset Database to recreate at correct dimensions."
86
+ )
87
+
88
+ if IDX_OBJECTS not in existing:
89
+ print(f"📦 Creating {IDX_OBJECTS} at {IDX_OBJECTS_DIM}-D...")
90
+ self.pc.create_index(
91
+ name = IDX_OBJECTS,
92
+ dimension = IDX_OBJECTS_DIM, # 1536-D — SigLIP+DINOv2
93
+ metric = "cosine",
94
+ spec = ServerlessSpec(cloud="aws", region="us-east-1"),
95
+ )
96
+ print(f" ✅ {IDX_OBJECTS} created at {IDX_OBJECTS_DIM}-D")
97
+ else:
98
+ desc = self.pc.describe_index(IDX_OBJECTS)
99
+ actual_dim = desc.dimension
100
+ if actual_dim != IDX_OBJECTS_DIM:
101
+ raise ValueError(
102
+ f"❌ {IDX_OBJECTS} exists at {actual_dim}-D but V4 needs "
103
+ f"{IDX_OBJECTS_DIM}-D. Go to Settings → Danger Zone → "
104
+ f"Reset Database to recreate at correct dimensions."
105
+ )
106
 
107
+ # ── Upload image to Cloudinary ────────────────────────────────
108
+ def upload_image(self, file_path: str, folder_name: str = "visual_search") -> str:
109
+ """Upload image to Cloudinary, return secure_url."""
110
  response = cloudinary.uploader.upload(file_path, folder=folder_name)
111
+ return response["secure_url"]
112
 
113
+ # ── Store vector in correct Pinecone index ────────────────────
114
+ def add_vector(self, data_dict: dict, image_url: str, image_id: str = None):
115
+ """
116
+ Upsert one vector into the correct Pinecone index.
117
+
118
+ data_dict keys:
119
+ type : "face" or "object"
120
+ vector : np.ndarray or list — must match index dimension
121
+ face_crop : str (base64 JPEG thumbnail, face only)
122
+ det_score : float (InsightFace detection confidence, face only)
123
+ face_quality: float (alias for det_score)
124
+ face_width_px: int (face bounding box width in pixels)
125
+ face_idx : int (face index within the source image)
126
+ bbox : list [x, y, w, h]
127
+ folder : str (Cloudinary folder / category name)
128
+ """
129
+ vec_id = image_id or str(uuid.uuid4())
130
+ vec_list = (data_dict["vector"].tolist()
131
+ if hasattr(data_dict["vector"], "tolist")
132
+ else list(data_dict["vector"]))
133
 
134
  if data_dict["type"] == "face":
135
+ # ── V4 face metadata — full set required for UI ───────
136
+ payload = [{
137
+ "id": vec_id,
138
+ "values": vec_list,
139
+ "metadata": {
140
+ "image_url": image_url,
141
+ "url": image_url, # alias for compatibility
142
+ "folder": data_dict.get("folder", ""),
143
+ "face_idx": data_dict.get("face_idx", 0),
144
+ "bbox": str(data_dict.get("bbox", [])),
145
+ "face_crop": data_dict.get("face_crop", ""), # base64 thumb
146
+ "det_score": data_dict.get("det_score", 1.0),
147
+ "face_quality": data_dict.get("face_quality",
148
+ data_dict.get("det_score", 1.0)),
149
+ "face_width_px": data_dict.get("face_width_px", 0),
150
+ },
151
+ }]
152
  self.index_faces.upsert(vectors=payload)
153
+
154
  else:
155
+ # ── V4 object metadata ────────────────────────────────
156
+ payload = [{
157
+ "id": vec_id,
158
+ "values": vec_list,
159
+ "metadata": {
160
+ "image_url": image_url,
161
+ "url": image_url,
162
+ "folder": data_dict.get("folder", ""),
163
+ },
164
+ }]
165
  self.index_objects.upsert(vectors=payload)
166
 
167
+ # ── Search ────────────────────────────────────────────────────
168
+ def search(self, query_dict: dict, top_k: int = 10,
169
+ min_score: float = None) -> list:
170
+ """
171
+ Search the correct Pinecone index for one query vector.
172
+
173
+ For face vectors: uses adaptive threshold based on det_score.
174
+ For object vectors: uses OBJECT_THRESHOLD (default 0.45).
175
+
176
+ Returns list of dicts: {url, score, caption, [face_crop, folder]}
177
+ """
178
+ vec_list = (query_dict["vector"].tolist()
179
+ if hasattr(query_dict["vector"], "tolist")
180
+ else list(query_dict["vector"]))
181
+ results = []
182
+
183
  if query_dict["type"] == "face":
184
+ # ── V4 face search ────────────────────────────────────
185
+ # Adaptive threshold: high-quality faces are stricter
186
+ det_score = query_dict.get("det_score", 1.0)
187
+ threshold = (FACE_THRESHOLD_HIGH if det_score >= 0.85
188
+ else FACE_THRESHOLD_LOW)
189
+ if min_score is not None:
190
+ threshold = min_score
191
+
192
+ response = self.index_faces.query(
193
+ vector=vec_list, top_k=top_k * 3, # over-fetch, filter below
194
+ include_metadata=True,
195
+ )
196
+
197
+ # Deduplicate by image_url — keep best score per image
198
+ image_map = {}
199
+ for match in response.get("matches", []):
200
+ raw = match["score"]
201
+ if raw < threshold:
202
+ continue
203
+ url = (match["metadata"].get("url") or
204
+ match["metadata"].get("image_url", ""))
205
+ if not url:
206
+ continue
207
+ if url not in image_map or raw > image_map[url]["raw"]:
208
+ image_map[url] = {
209
+ "raw": raw,
210
+ "face_crop": match["metadata"].get("face_crop", ""),
211
+ "folder": match["metadata"].get("folder", ""),
212
+ }
213
+
214
+ # Remap raw cosine → UI percentage (75%–99%)
215
+ for url, d in image_map.items():
216
+ lo = FACE_THRESHOLD_LOW
217
+ ui = round(min(0.99, 0.75 + ((d["raw"] - lo) / (1.0 - lo)) * 0.24), 4)
218
+ results.append({
219
+ "url": url,
220
+ "score": ui,
221
+ "raw_score": round(d["raw"], 4),
222
+ "face_crop": d["face_crop"],
223
+ "folder": d["folder"],
224
+ "caption": "👤 Verified Identity Match",
225
+ })
226
+
227
+ results = sorted(results, key=lambda x: x["score"], reverse=True)[:top_k]
228
+
229
  else:
230
+ # ── V4 object search ──────────────────────────────────
231
+ threshold = min_score if min_score is not None else OBJECT_THRESHOLD
232
+ response = self.index_objects.query(
233
+ vector=vec_list, top_k=top_k, include_metadata=True)
234
+
235
+ for match in response.get("matches", []):
236
+ if match["score"] < threshold:
237
+ continue
238
+ results.append({
239
+ "url": (match["metadata"].get("url") or
240
+ match["metadata"].get("image_url", "")),
241
+ "score": round(match["score"], 4),
242
+ "folder": match["metadata"].get("folder", ""),
243
+ "caption": "🎯 Visual & Semantic Match",
244
+ })
245
+
246
  return results
src/models.py CHANGED
@@ -44,15 +44,18 @@ except ImportError:
44
  print(" pip install insightface onnxruntime (linux/win)")
45
 
46
  # ── AdaFace ──────────────────────────────────────────────────────
47
- # AdaFace IR-50 backbone (CVPR 2022) — quality-adaptive margin loss
48
- # Much more robust than ArcFace on low-quality / occluded faces
49
- # Weights auto-downloaded from HuggingFace on first run
 
50
  try:
 
51
  from huggingface_hub import hf_hub_download
 
52
  ADAFACE_WEIGHTS_AVAILABLE = True
53
  except ImportError:
54
  ADAFACE_WEIGHTS_AVAILABLE = False
55
- print("⚠️ huggingface_hub not installed — AdaFace fusion disabled")
56
 
57
  # ── Constants ─────────────────────────────────────────────────────
58
  YOLO_PERSON_CLASS_ID = 0
@@ -70,95 +73,6 @@ ADAFACE_DIM = 512 # AdaFace embedding dimension
70
  FUSED_FACE_DIM = 1024 # ArcFace + AdaFace concatenated
71
 
72
 
73
- # ════════════════════════════════════════════════════════════════
74
- # AdaFace IR-50 Backbone
75
- # Lightweight reimplementation of the IR-50 network head used
76
- # to load pretrained AdaFace weights (WebFace4M checkpoint).
77
- # Only the feature-extraction layers are used — no classifier.
78
- # ════════════════════════════════════════════════════════════════
79
-
80
- def _conv_bn(inp, oup, k, s, p, groups=1):
81
- return nn.Sequential(
82
- nn.Conv2d(inp, oup, k, s, p, groups=groups, bias=False),
83
- nn.BatchNorm2d(oup),
84
- )
85
-
86
- class _IBasicBlock(nn.Module):
87
- """Basic residual block used in IR-50."""
88
- expansion = 1
89
- def __init__(self, inplanes, planes, stride=1, downsample=None):
90
- super().__init__()
91
- self.bn1 = nn.BatchNorm2d(inplanes)
92
- self.conv1 = nn.Conv2d(inplanes, planes, 3, 1, 1, bias=False)
93
- self.bn2 = nn.BatchNorm2d(planes)
94
- self.prelu = nn.PReLU(planes)
95
- self.conv2 = nn.Conv2d(planes, planes, 3, stride, 1, bias=False)
96
- self.bn3 = nn.BatchNorm2d(planes)
97
- self.downsample = downsample
98
- self.stride = stride
99
-
100
- def forward(self, x):
101
- identity = x
102
- out = self.bn1(x)
103
- out = self.conv1(out)
104
- out = self.bn2(out)
105
- out = self.prelu(out)
106
- out = self.conv2(out)
107
- out = self.bn3(out)
108
- if self.downsample is not None:
109
- identity = self.downsample(x)
110
- out += identity
111
- return out
112
-
113
- class AdaFaceIR50(nn.Module):
114
- """
115
- IR-50 backbone for AdaFace.
116
- Produces a 512-D L2-normalised face embedding.
117
- Input: (N, 3, 112, 112) normalised face crop (mean 0.5, std 0.5)
118
- Output: (N, 512) L2-normalised embedding
119
- """
120
- def __init__(self):
121
- super().__init__()
122
- self.input_layer = nn.Sequential(
123
- nn.Conv2d(3, 64, 3, 1, 1, bias=False),
124
- nn.BatchNorm2d(64),
125
- nn.PReLU(64),
126
- )
127
- self.layer1 = self._make_layer(64, 64, 3, stride=2)
128
- self.layer2 = self._make_layer(64, 128, 4, stride=2)
129
- self.layer3 = self._make_layer(128, 256, 14, stride=2)
130
- self.layer4 = self._make_layer(256, 512, 3, stride=2)
131
- self.bn2 = nn.BatchNorm2d(512)
132
- self.dropout = nn.Dropout(p=0.4)
133
- self.fc = nn.Linear(512 * 7 * 7, 512)
134
- self.features = nn.BatchNorm1d(512)
135
-
136
- def _make_layer(self, inplanes, planes, blocks, stride=1):
137
- downsample = None
138
- if stride != 1 or inplanes != planes:
139
- downsample = nn.Sequential(
140
- nn.Conv2d(inplanes, planes, 1, stride, bias=False),
141
- nn.BatchNorm2d(planes),
142
- )
143
- layers = [_IBasicBlock(inplanes, planes, stride, downsample)]
144
- for _ in range(1, blocks):
145
- layers.append(_IBasicBlock(planes, planes))
146
- return nn.Sequential(*layers)
147
-
148
- def forward(self, x):
149
- x = self.input_layer(x)
150
- x = self.layer1(x)
151
- x = self.layer2(x)
152
- x = self.layer3(x)
153
- x = self.layer4(x)
154
- x = self.bn2(x)
155
- x = self.dropout(x)
156
- x = x.flatten(1)
157
- x = self.fc(x)
158
- x = self.features(x)
159
- return F.normalize(x, p=2, dim=1)
160
-
161
-
162
  # ════════════════════════════════════════════════════════════════
163
  # Utility functions
164
  # ════════════════════════════════════════════════════════════════
@@ -302,43 +216,108 @@ class AIModelManager:
302
  self._face_lock = threading.Lock()
303
  self._cache = {}
304
  self._cache_maxsize = 128
305
- print(" All models ready!")
306
- print(f" Face vector dim : {FUSED_FACE_DIM if self.adaface_model else FACE_DIM}")
307
- print(f" Object vector dim: 1536")
 
 
 
 
 
 
 
 
308
 
309
  def _load_adaface(self):
310
- """Download and load AdaFace IR-50 WebFace4M weights."""
 
 
 
 
 
 
311
  if not ADAFACE_WEIGHTS_AVAILABLE:
312
- print("⚠️ AdaFace skipped — huggingface_hub not installed")
313
  return
 
 
 
 
 
 
 
314
  try:
315
- print("📦 Loading AdaFace IR-50 (WebFace4M)...")
316
- # Weights hosted on HuggingFace — ~170MB download on first run
317
- ckpt_path = hf_hub_download(
318
- repo_id = "minchul/adaface_ir50_webface4m",
319
- filename = "adaface_ir50_webface4m.ckpt",
320
- )
321
- model = AdaFaceIR50()
322
- state = torch.load(ckpt_path, map_location="cpu")
323
- # Checkpoint may be wrapped in {"state_dict": ...}
324
- if "state_dict" in state:
325
- state = state["state_dict"]
326
- # Strip any "model." prefix that some checkpoints add
327
- state = {k.replace("model.", ""): v for k, v in state.items()}
328
- # Only load keys that exist in our model
329
- model_keys = set(model.state_dict().keys())
330
- filtered = {k: v for k, v in state.items() if k in model_keys}
331
- missing, _ = model.load_state_dict(filtered, strict=False)
332
- if missing:
333
- print(f" AdaFace: {len(missing)} missing keys (expected for head layers)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  model = model.to(self.device).eval()
335
  if self.device == "cuda":
336
  model = model.half()
 
 
 
 
 
 
 
 
 
 
 
 
337
  self.adaface_model = model
338
- print("✅ AdaFace IR-50 loaded — 1024-D fused face vectors ACTIVE")
 
339
  except Exception as e:
340
- print(f"⚠️ AdaFace load failed: {e} — falling back to ArcFace-only (512-D)")
341
- print(f" Detail: {traceback.format_exc()[-400:]}")
 
342
  self.adaface_model = None
343
 
344
  # ── Object Lane: batched SigLIP + DINOv2 embedding ───────────
@@ -374,8 +353,12 @@ class AIModelManager:
374
  # ── AdaFace embedding for a single face crop ─────────────────
375
  def _adaface_embed(self, face_arr_chw: np.ndarray) -> np.ndarray:
376
  """
377
- Run AdaFace IR-50 on a preprocessed (3,112,112) float32 array.
378
- Returns 512-D L2-normalised numpy embedding.
 
 
 
 
379
  """
380
  if self.adaface_model is None or face_arr_chw is None:
381
  return None
@@ -385,8 +368,11 @@ class AIModelManager:
385
  if self.device == "cuda":
386
  t = t.half()
387
  with torch.no_grad():
388
- emb = self.adaface_model(t) # (1,512)
389
- return emb[0].float().cpu().numpy()
 
 
 
390
  except Exception as e:
391
  print(f"⚠️ AdaFace inference error: {e}")
392
  return None
@@ -461,15 +447,21 @@ class AIModelManager:
461
  adaface_vec = self._adaface_embed(face_chw)
462
 
463
  # ── Fuse: ArcFace + AdaFace → 1024-D ─────────────
 
 
464
  if adaface_vec is not None:
 
465
  fused_raw = np.concatenate([arcface_vec, adaface_vec])
466
- n2 = np.linalg.norm(fused_raw)
467
- final_vec = (fused_raw / n2) if n2 > 0 else fused_raw
468
- vec_dim = FUSED_FACE_DIM
469
  else:
470
- # AdaFace unavailable — fall back to ArcFace only
471
- final_vec = arcface_vec
472
- vec_dim = FACE_DIM
 
 
 
 
 
 
473
 
474
  # ── Face crop thumbnail for UI ─────────────────────
475
  face_crop_b64 = _crop_to_b64(bgr, x1, y1, x2, y2)
 
44
  print(" pip install insightface onnxruntime (linux/win)")
45
 
46
  # ── AdaFace ──────────────────────────────────────────────────────
47
+ # AdaFace IR-50 MS1MV2 (CVPR 2022) — quality-adaptive margin loss
48
+ # Repo : minchul/cvlface_adaface_ir50_ms1mv2 (HuggingFace)
49
+ # Loaded : AutoModel + trust_remote_code=True (custom_code repo)
50
+ # Needs : HF_TOKEN env var set in HF Space secrets
51
  try:
52
+ import shutil as _shutil
53
  from huggingface_hub import hf_hub_download
54
+ from transformers import AutoModel as _HF_AutoModel
55
  ADAFACE_WEIGHTS_AVAILABLE = True
56
  except ImportError:
57
  ADAFACE_WEIGHTS_AVAILABLE = False
58
+ print("⚠️ huggingface_hub / transformers not installed — AdaFace fusion disabled")
59
 
60
  # ── Constants ─────────────────────────────────────────────────────
61
  YOLO_PERSON_CLASS_ID = 0
 
73
  FUSED_FACE_DIM = 1024 # ArcFace + AdaFace concatenated
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  # ════════════════════════════════════════════════════════════════
77
  # Utility functions
78
  # ════════════════════════════════════════════════════════════════
 
216
  self._face_lock = threading.Lock()
217
  self._cache = {}
218
  self._cache_maxsize = 128
219
+ adaface_status = "FULL FUSION u2705" if self.adaface_model else "ZERO-PADDED u26a0ufe0f (AdaFace weights missing)"
220
+ print("")
221
+ print("u2705 Enterprise Lens V4 u2014 Models Ready")
222
+ print(f" Device : {self.device.upper()}")
223
+ print(f" InsightFace : buffalo_l (SCRFD-10GF + ArcFace-R100)")
224
+ print(f" AdaFace : {adaface_status}")
225
+ print(f" Face vector dim : {FUSED_FACE_DIM} <- enterprise-faces MUST be {FUSED_FACE_DIM}-D")
226
+ print(f" Object vector dim : 1536 <- enterprise-objects MUST be 1536-D")
227
+ print(f" Quality gate : det_score >= {FACE_QUALITY_GATE}, face_px >= {MIN_FACE_SIZE}")
228
+ print(f" Detection size : {DET_SIZE_PRIMARY}")
229
+ print("")
230
 
231
  def _load_adaface(self):
232
+ """
233
+ Load AdaFace IR-50 MS1MV2 from HuggingFace.
234
+ Repo : minchul/cvlface_adaface_ir50_ms1mv2
235
+ Method : AutoModel + trust_remote_code (repo has custom_code)
236
+ Token : HF_TOKEN env var (required for custom_code repos)
237
+ Output : 512-D L2-normalised embedding per face crop
238
+ """
239
  if not ADAFACE_WEIGHTS_AVAILABLE:
240
+ print("⚠️ AdaFace skipped — huggingface_hub / transformers not installed")
241
  return
242
+
243
+ import os, sys
244
+
245
+ REPO_ID = "minchul/cvlface_adaface_ir50_ms1mv2"
246
+ HF_TOKEN = os.getenv("HF_TOKEN", None)
247
+ CACHE_PATH = os.path.expanduser("~/.cvlface_cache/minchul/cvlface_adaface_ir50_ms1mv2")
248
+
249
  try:
250
+ print("📦 Loading AdaFace IR-50 MS1MV2 from HuggingFace...")
251
+ if HF_TOKEN:
252
+ print(" HF_TOKEN found ✅")
253
+ else:
254
+ print(" ⚠️ HF_TOKEN not set — may fail on gated/custom_code repos")
255
+
256
+ # ── Step 1: Download all repo files ──────────────────
257
+ os.makedirs(CACHE_PATH, exist_ok=True)
258
+
259
+ # Download files.txt manifest first
260
+ files_txt = os.path.join(CACHE_PATH, "files.txt")
261
+ if not os.path.exists(files_txt):
262
+ hf_hub_download(
263
+ repo_id=REPO_ID, filename="files.txt",
264
+ token=HF_TOKEN, local_dir=CACHE_PATH,
265
+ local_dir_use_symlinks=False,
266
+ )
267
+
268
+ # Read manifest and download each listed file
269
+ with open(files_txt, "r") as f:
270
+ extra_files = [x.strip() for x in f.read().split("\n") if x.strip()]
271
+
272
+ for fname in extra_files + ["config.json", "wrapper.py", "model.safetensors"]:
273
+ fpath = os.path.join(CACHE_PATH, fname)
274
+ if not os.path.exists(fpath):
275
+ print(f" Downloading {fname}...")
276
+ hf_hub_download(
277
+ repo_id=REPO_ID, filename=fname,
278
+ token=HF_TOKEN, local_dir=CACHE_PATH,
279
+ local_dir_use_symlinks=False,
280
+ )
281
+
282
+ # ── Step 2: Load model from local cache ──────────────
283
+ # Must chdir + add to sys.path because the repo uses
284
+ # trust_remote_code with relative imports in wrapper.py
285
+ cwd = os.getcwd()
286
+ os.chdir(CACHE_PATH)
287
+ sys.path.insert(0, CACHE_PATH)
288
+ try:
289
+ model = _HF_AutoModel.from_pretrained(
290
+ CACHE_PATH,
291
+ trust_remote_code=True,
292
+ token=HF_TOKEN,
293
+ )
294
+ finally:
295
+ os.chdir(cwd)
296
+ if CACHE_PATH in sys.path:
297
+ sys.path.remove(CACHE_PATH)
298
+
299
  model = model.to(self.device).eval()
300
  if self.device == "cuda":
301
  model = model.half()
302
+
303
+ # ── Step 3: Verify output shape ───────────────────────
304
+ with torch.no_grad():
305
+ dummy = torch.zeros(1, 3, 112, 112).to(self.device)
306
+ out = model(dummy)
307
+ # Model may return tensor directly or an object with .embedding
308
+ out_vec = out if isinstance(out, torch.Tensor) else out.embedding
309
+ out_dim = out_vec.shape[-1]
310
+ if out_dim != ADAFACE_DIM:
311
+ raise ValueError(
312
+ f"AdaFace output dim={out_dim}, expected {ADAFACE_DIM}")
313
+
314
  self.adaface_model = model
315
+ print(f"✅ AdaFace IR-50 MS1MV2 loaded — output dim={out_dim} — 1024-D fusion ACTIVE")
316
+
317
  except Exception as e:
318
+ print(f"⚠️ AdaFace load failed: {e}")
319
+ print(f" Detail: {traceback.format_exc()[-500:]}")
320
+ print(" Falling back to ArcFace-only (zero-padded to 1024-D)")
321
  self.adaface_model = None
322
 
323
  # ── Object Lane: batched SigLIP + DINOv2 embedding ───────────
 
353
  # ── AdaFace embedding for a single face crop ─────────────────
354
  def _adaface_embed(self, face_arr_chw: np.ndarray) -> np.ndarray:
355
  """
356
+ Run AdaFace IR-50 MS1MV2 on a preprocessed (3,112,112) float32 array.
357
+ Input : CHW float32, normalised to [-1, 1]
358
+ Output: 512-D L2-normalised numpy embedding, or None on failure.
359
+
360
+ The cvlface model may return a tensor directly or an object
361
+ with an .embedding attribute — both cases handled.
362
  """
363
  if self.adaface_model is None or face_arr_chw is None:
364
  return None
 
368
  if self.device == "cuda":
369
  t = t.half()
370
  with torch.no_grad():
371
+ out = self.adaface_model(t)
372
+ # Handle both raw tensor and object-with-embedding outputs
373
+ emb = out if isinstance(out, torch.Tensor) else out.embedding
374
+ emb = F.normalize(emb.float(), p=2, dim=1)
375
+ return emb[0].cpu().numpy()
376
  except Exception as e:
377
  print(f"⚠️ AdaFace inference error: {e}")
378
  return None
 
447
  adaface_vec = self._adaface_embed(face_chw)
448
 
449
  # ── Fuse: ArcFace + AdaFace → 1024-D ─────────────
450
+ # ALWAYS output FUSED_FACE_DIM (1024) so Pinecone index
451
+ # dimension never mismatches, regardless of AdaFace status.
452
  if adaface_vec is not None:
453
+ # Full fusion: ArcFace(512) + AdaFace(512) → 1024-D
454
  fused_raw = np.concatenate([arcface_vec, adaface_vec])
 
 
 
455
  else:
456
+ # AdaFace unavailable — pad with zeros to maintain 1024-D
457
+ # The ArcFace half still carries full identity signal;
458
+ # zero padding is neutral and doesn't corrupt similarity.
459
+ print(" ⚠️ AdaFace unavailable — padding to 1024-D")
460
+ fused_raw = np.concatenate([arcface_vec,
461
+ np.zeros(ADAFACE_DIM, dtype=np.float32)])
462
+ n2 = np.linalg.norm(fused_raw)
463
+ final_vec = (fused_raw / n2) if n2 > 0 else fused_raw
464
+ vec_dim = FUSED_FACE_DIM # always 1024
465
 
466
  # ── Face crop thumbnail for UI ─────────────────────
467
  face_crop_b64 = _crop_to_b64(bgr, x1, y1, x2, y2)