AdarshDRC commited on
Commit
79e267a
·
verified ·
1 Parent(s): 53808b4

Rename src/cloud_db.py to src/db.py

Browse files
Files changed (2) hide show
  1. src/cloud_db.py +0 -246
  2. src/db.py +356 -0
src/cloud_db.py DELETED
@@ -1,246 +0,0 @@
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/db.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ src/db.py — All external service I/O lives here.
3
+
4
+ Two responsibilities:
5
+ 1. PineconePool — LRU cache of Pinecone client objects (avoids per-request TCP overhead)
6
+ 2. Cloudinary helpers — thin wrappers that always inject credentials explicitly
7
+ (stateless; no global cloudinary.config() call needed)
8
+
9
+ Search helpers (search_faces, search_objects) live here too so main.py
10
+ stays thin and the query logic is testable in isolation.
11
+ """
12
+
13
+ import io
14
+ from collections import OrderedDict
15
+ from typing import Optional
16
+
17
+ import cloudinary
18
+ import cloudinary.api
19
+ import cloudinary.uploader
20
+ from pinecone import Pinecone, ServerlessSpec
21
+
22
+ from .config import (
23
+ PINECONE_POOL_MAX,
24
+ IDX_FACES, IDX_OBJECTS,
25
+ IDX_FACES_DIM, IDX_OBJECTS_DIM,
26
+ FACE_THRESHOLD_HIGH, FACE_THRESHOLD_LOW, FACE_DET_SCORE_HQ_SPLIT,
27
+ FACE_TOP_K_FETCH,
28
+ OBJECT_SCORE_THRESHOLD, OBJECT_TOP_K,
29
+ )
30
+ from .utils import to_list, face_ui_score
31
+
32
+
33
+ # ════════════════════════════════════════════════════════════════════
34
+ # PINECONE LRU CONNECTION POOL
35
+ # ════════════════════════════════════════════════════════════════════
36
+
37
+ class PineconePool:
38
+ """
39
+ LRU cache of Pinecone() client objects keyed by API key.
40
+
41
+ Why pool? Creating a Pinecone() client opens a TCP connection and
42
+ authenticates — adds ~200 ms on first use. Re-using clients across
43
+ requests eliminates that overhead for repeat users.
44
+
45
+ Implementation: OrderedDict + move_to_end = O(1) LRU without any
46
+ extra dependency. On overflow, popitem(last=False) evicts the
47
+ least-recently-used entry.
48
+ """
49
+
50
+ def __init__(self, maxsize: int = PINECONE_POOL_MAX):
51
+ self._pool: OrderedDict[str, Pinecone] = OrderedDict()
52
+ self._maxsize = maxsize
53
+
54
+ def get(self, api_key: str) -> Pinecone:
55
+ if api_key not in self._pool:
56
+ if len(self._pool) >= self._maxsize:
57
+ self._pool.popitem(last=False)
58
+ self._pool[api_key] = Pinecone(api_key=api_key)
59
+ self._pool.move_to_end(api_key)
60
+ return self._pool[api_key]
61
+
62
+
63
+ # Module-level singleton — shared across all requests
64
+ pinecone_pool = PineconePool()
65
+
66
+
67
+ # ════════════════════════════════════════════════════════════════════
68
+ # CLOUDINARY WRAPPERS
69
+ # All functions accept a `creds` dict — no global state.
70
+ # Call via asyncio.to_thread() from async endpoints.
71
+ # ════════════════════════════════════════════════════════════════════
72
+
73
+ def cld_upload(file_obj: io.BytesIO, folder: str, creds: dict) -> dict:
74
+ """Upload an in-memory file to Cloudinary. Returns the full API response."""
75
+ return cloudinary.uploader.upload(
76
+ file_obj, folder=folder,
77
+ api_key=creds["api_key"], api_secret=creds["api_secret"],
78
+ cloud_name=creds["cloud_name"],
79
+ )
80
+
81
+
82
+ def cld_ping(creds: dict) -> dict:
83
+ """Lightweight connectivity check — verifies credentials are valid."""
84
+ return cloudinary.api.ping(
85
+ api_key=creds["api_key"], api_secret=creds["api_secret"],
86
+ cloud_name=creds["cloud_name"],
87
+ )
88
+
89
+
90
+ def cld_root_folders(creds: dict) -> dict:
91
+ """List all top-level folders in the account."""
92
+ return cloudinary.api.root_folders(
93
+ api_key=creds["api_key"], api_secret=creds["api_secret"],
94
+ cloud_name=creds["cloud_name"],
95
+ )
96
+
97
+
98
+ def cld_list_folder_images(
99
+ folder: str,
100
+ creds: dict,
101
+ next_cursor: Optional[str] = None,
102
+ max_results: int = 100,
103
+ ) -> dict:
104
+ """Paginated listing of resources under a folder prefix."""
105
+ kwargs = dict(
106
+ type="upload",
107
+ prefix=f"{folder}/",
108
+ max_results=min(max_results, 100), # Cloudinary hard-caps at 100
109
+ api_key=creds["api_key"],
110
+ api_secret=creds["api_secret"],
111
+ cloud_name=creds["cloud_name"],
112
+ )
113
+ if next_cursor:
114
+ kwargs["next_cursor"] = next_cursor
115
+ return cloudinary.api.resources(**kwargs)
116
+
117
+
118
+ def cld_delete_resource(public_id: str, creds: dict) -> dict:
119
+ """Delete a single resource by public_id."""
120
+ return cloudinary.uploader.destroy(
121
+ public_id,
122
+ api_key=creds["api_key"], api_secret=creds["api_secret"],
123
+ cloud_name=creds["cloud_name"],
124
+ )
125
+
126
+
127
+ def cld_delete_folder_resources(folder: str, creds: dict) -> dict:
128
+ """Delete all resources under a folder prefix (not the folder record itself)."""
129
+ return cloudinary.api.delete_resources_by_prefix(
130
+ f"{folder}/",
131
+ api_key=creds["api_key"], api_secret=creds["api_secret"],
132
+ cloud_name=creds["cloud_name"],
133
+ )
134
+
135
+
136
+ def cld_remove_folder(folder: str, creds: dict):
137
+ """Delete the folder record itself. Silently ignores errors (already gone)."""
138
+ try:
139
+ return cloudinary.api.delete_folder(
140
+ folder,
141
+ api_key=creds["api_key"], api_secret=creds["api_secret"],
142
+ cloud_name=creds["cloud_name"],
143
+ )
144
+ except Exception:
145
+ pass
146
+
147
+
148
+ def cld_delete_all_paginated(creds: dict) -> int:
149
+ """
150
+ Delete ALL resources in a Cloudinary account, paginating in batches of 100.
151
+ Returns the total number of resources deleted.
152
+ Used only by the destructive /api/reset-database and /api/delete-account endpoints.
153
+ """
154
+ deleted = 0
155
+ while True:
156
+ try:
157
+ res = cloudinary.api.resources(
158
+ type="upload", max_results=100,
159
+ api_key=creds["api_key"], api_secret=creds["api_secret"],
160
+ cloud_name=creds["cloud_name"],
161
+ )
162
+ resources = res.get("resources", [])
163
+ if not resources:
164
+ break
165
+ public_ids = [r["public_id"] for r in resources]
166
+ cloudinary.api.delete_resources(
167
+ public_ids,
168
+ api_key=creds["api_key"], api_secret=creds["api_secret"],
169
+ cloud_name=creds["cloud_name"],
170
+ )
171
+ deleted += len(public_ids)
172
+ if not res.get("next_cursor"):
173
+ break
174
+ except Exception:
175
+ break
176
+ return deleted
177
+
178
+
179
+ # ════════════════════════════════════════════════════════════════════
180
+ # PINECONE INDEX MANAGEMENT
181
+ # ════════════════════════════════════════════════════════════════════
182
+
183
+ def ensure_indexes(pc: Pinecone) -> list[str]:
184
+ """
185
+ Create enterprise-faces and enterprise-objects indexes if they don't exist.
186
+ Returns a list of index names that were freshly created (empty list if both existed).
187
+ """
188
+ existing = {idx.name for idx in pc.list_indexes()}
189
+ created = []
190
+ spec = ServerlessSpec(cloud="aws", region="us-east-1")
191
+
192
+ if IDX_OBJECTS not in existing:
193
+ pc.create_index(name=IDX_OBJECTS, dimension=IDX_OBJECTS_DIM, metric="cosine", spec=spec)
194
+ created.append(IDX_OBJECTS)
195
+
196
+ if IDX_FACES not in existing:
197
+ pc.create_index(name=IDX_FACES, dimension=IDX_FACES_DIM, metric="cosine", spec=spec)
198
+ created.append(IDX_FACES)
199
+
200
+ return created
201
+
202
+
203
+ def delete_and_recreate_indexes(pc: Pinecone):
204
+ """
205
+ Destroy both indexes and recreate them empty.
206
+ Used by /api/reset-database. Caller is responsible for ensuring
207
+ this is not called on the shared demo database.
208
+ """
209
+ import asyncio, time
210
+ existing = {idx.name for idx in pc.list_indexes()}
211
+ for name in [IDX_OBJECTS, IDX_FACES]:
212
+ if name in existing:
213
+ pc.delete_index(name)
214
+ time.sleep(3) # Pinecone needs a moment to fully delete before recreating
215
+ spec = ServerlessSpec(cloud="aws", region="us-east-1")
216
+ pc.create_index(name=IDX_OBJECTS, dimension=IDX_OBJECTS_DIM, metric="cosine", spec=spec)
217
+ pc.create_index(name=IDX_FACES, dimension=IDX_FACES_DIM, metric="cosine", spec=spec)
218
+
219
+
220
+ # ════════════════════════════════════════════════════════════════════
221
+ # PINECONE SEARCH HELPERS
222
+ # ════════════════════════════════════════════════════════════════════
223
+
224
+ def search_faces(idx_face, vec: list, det_score: float) -> dict:
225
+ """
226
+ Query the faces index for one face vector.
227
+
228
+ Threshold selection:
229
+ - High-quality face (det_score >= FACE_DET_SCORE_HQ_SPLIT) → FACE_THRESHOLD_HIGH
230
+ - Lower-quality face → FACE_THRESHOLD_LOW
231
+ Adaptive thresholding prevents low-quality query faces from flooding results
232
+ with false positives while still matching when confidence warrants it.
233
+
234
+ Returns: { image_url → { raw_score, face_crop, folder, face_width_px } }
235
+ De-duplicated per image URL — only the highest-scoring face match per image
236
+ is retained (multiple face vectors can be stored per source image during upload).
237
+ """
238
+ threshold = (
239
+ FACE_THRESHOLD_HIGH
240
+ if det_score >= FACE_DET_SCORE_HQ_SPLIT
241
+ else FACE_THRESHOLD_LOW
242
+ )
243
+ response = idx_face.query(vector=vec, top_k=FACE_TOP_K_FETCH, include_metadata=True)
244
+ image_map = {}
245
+
246
+ for match in response.get("matches", []):
247
+ raw = match["score"]
248
+ if raw < threshold:
249
+ continue
250
+ url = match["metadata"].get("url", "")
251
+ if not url:
252
+ continue
253
+ if url not in image_map or raw > image_map[url]["raw_score"]:
254
+ image_map[url] = {
255
+ "raw_score": raw,
256
+ "face_crop": match["metadata"].get("face_crop", ""),
257
+ "folder": match["metadata"].get("folder", ""),
258
+ "face_width_px": int(match["metadata"].get("face_width_px", 0)),
259
+ }
260
+ return image_map
261
+
262
+
263
+ def search_objects(idx_obj, vec: list) -> list:
264
+ """
265
+ Query the objects index for one object vector.
266
+
267
+ Returns list of { url, score, folder } dicts for matches above
268
+ OBJECT_SCORE_THRESHOLD, sorted by score descending.
269
+ """
270
+ response = idx_obj.query(vector=vec, top_k=OBJECT_TOP_K, include_metadata=True)
271
+ results = []
272
+ for match in response.get("matches", []):
273
+ if match["score"] < OBJECT_SCORE_THRESHOLD:
274
+ continue
275
+ url = match["metadata"].get("url", "")
276
+ if url:
277
+ results.append({
278
+ "url": url,
279
+ "score": round(match["score"], 4),
280
+ "folder": match["metadata"].get("folder", ""),
281
+ })
282
+ return sorted(results, key=lambda x: x["score"], reverse=True)
283
+
284
+
285
+ def merge_face_results(raw_groups: list[dict]) -> list[dict]:
286
+ """
287
+ Merge per-face search results from multiple query faces into one ranked list.
288
+
289
+ Algorithm:
290
+ 1. Build a global image_url → {best_score, matched_face_indices, ...} map.
291
+ 2. An image is included if ANY query face matched it.
292
+ 3. Score = best raw score across all matching faces, remapped to UI score.
293
+ 4. Multi-face boost: +5 % per additional matched face (capped at 0.99).
294
+ Rationale: an image containing 3 of your 3 searched people should rank
295
+ above one containing only 1 of 3.
296
+
297
+ Args:
298
+ raw_groups: list of dicts, each containing '_image_map' from search_faces()
299
+
300
+ Returns: sorted list of merged result dicts (best first), max 20 items.
301
+ """
302
+ global_map: dict[str, dict] = {}
303
+
304
+ for gi, group in enumerate(raw_groups):
305
+ for url, d in group["_image_map"].items():
306
+ raw = d["raw_score"]
307
+ if url not in global_map:
308
+ global_map[url] = {
309
+ "raw_score": raw,
310
+ "face_crop": d["face_crop"],
311
+ "folder": d["folder"],
312
+ "face_width_px": d["face_width_px"],
313
+ "matched_faces": [gi],
314
+ }
315
+ else:
316
+ entry = global_map[url]
317
+ entry["matched_faces"].append(gi)
318
+ if raw > entry["raw_score"]:
319
+ entry["raw_score"] = raw
320
+ entry["face_crop"] = d["face_crop"]
321
+ entry["face_width_px"] = d["face_width_px"]
322
+
323
+ results = []
324
+ for url, d in global_map.items():
325
+ n = len(d["matched_faces"])
326
+ results.append({
327
+ "url": url,
328
+ "score": face_ui_score(d["raw_score"], n),
329
+ "raw_score": round(d["raw_score"], 4),
330
+ "face_crop": d["face_crop"],
331
+ "folder": d["folder"],
332
+ "face_width_px": d["face_width_px"],
333
+ "matched_faces": d["matched_faces"],
334
+ "caption": f"👥 {n} faces matched" if n > 1 else "👤 Verified Identity",
335
+ })
336
+
337
+ return sorted(results, key=lambda x: x["score"], reverse=True)[:20]
338
+
339
+
340
+ def merge_object_results(nested: list[list[dict]]) -> list[dict]:
341
+ """
342
+ Merge object search results from multiple crops into one ranked list.
343
+
344
+ When YOLO produces N crops, each gets its own embedding and Pinecone query.
345
+ The same image URL may appear in multiple crop results; only the highest
346
+ score per URL is kept.
347
+
348
+ Returns: sorted list, max OBJECT_TOP_K items.
349
+ """
350
+ seen: dict[str, dict] = {}
351
+ for results in nested:
352
+ for r in results:
353
+ url = r["url"]
354
+ if url and (url not in seen or r["score"] > seen[url]["score"]):
355
+ seen[url] = r
356
+ return sorted(seen.values(), key=lambda x: x["score"], reverse=True)[:OBJECT_TOP_K]