AdarshDRC commited on
Commit
8dbf9ad
·
verified ·
1 Parent(s): 8c6ce56

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +286 -78
main.py CHANGED
@@ -1,111 +1,319 @@
1
- from fastapi import FastAPI, UploadFile, File, Form, HTTPException
2
- from fastapi.middleware.cors import CORSMiddleware
3
- from typing import List
 
4
  import os
5
  import shutil
6
  import uuid
7
  import re
8
- import inflect
 
 
 
 
 
 
 
 
9
  import cloudinary.api
10
- from src.models import AIModelManager
11
- from src.cloud_db import CloudDB
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- app = FastAPI()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  app.add_middleware(
16
  CORSMiddleware,
17
- allow_origins=["*"],
18
  allow_credentials=True,
19
  allow_methods=["*"],
20
  allow_headers=["*"],
21
  )
22
 
23
- print("Loading AI Models and Cloud DB...")
24
- ai = AIModelManager()
25
- db = CloudDB()
26
- p = inflect.engine()
27
- print("Ready!")
28
-
29
  os.makedirs("temp_uploads", exist_ok=True)
30
 
 
 
31
  def standardize_category_name(name: str) -> str:
32
- clean_name = name.strip().lower()
33
- clean_name = re.sub(r'\s+', '_', clean_name)
34
- clean_name = re.sub(r'[^\w\s]', '', clean_name)
35
- singular_name = p.singular_noun(clean_name)
36
- return singular_name if singular_name else clean_name
37
 
38
  def sanitize_filename(filename: str) -> str:
39
- clean_name = re.sub(r'\s+', '_', filename)
40
- clean_name = re.sub(r'[^\w\.\-]', '', clean_name)
41
- return clean_name
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
 
 
 
 
 
 
43
  @app.post("/api/upload")
44
- async def upload_new_images(files: List[UploadFile] = File(...), folder_name: str = Form(...)):
 
 
 
 
 
 
 
 
 
 
45
  uploaded_urls = []
46
- standardized_folder = standardize_category_name(folder_name)
47
- detect_faces: bool = Form(True)
48
-
49
- try:
50
- for file in files:
51
- safe_filename = sanitize_filename(file.filename)
52
- temp_path = f"temp_uploads/{safe_filename}"
53
- with open(temp_path, "wb") as buffer:
54
- shutil.copyfileobj(file.file, buffer)
55
-
56
- image_url = db.upload_image(temp_path, standardized_folder)
57
-
58
- vectors_to_save = ai.process_image(temp_path, is_query=False, detect_faces=detect_faces)
59
-
60
- for vec_dict in vectors_to_save:
61
- image_id = str(uuid.uuid4())
62
- db.add_vector(vec_dict, image_url, image_id)
63
-
64
- os.remove(temp_path)
65
  uploaded_urls.append(image_url)
66
-
67
- return {"message": "Success!", "urls": uploaded_urls}
68
- except Exception as e:
69
- raise HTTPException(status_code=500, detail=str(e))
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  @app.post("/api/search")
72
- async def search_database(file: UploadFile = File(...)):
 
 
 
 
 
 
 
 
 
 
 
73
  try:
74
- safe_filename = sanitize_filename(file.filename)
75
- temp_path = f"temp_uploads/query_{safe_filename}"
76
- detect_faces: bool = Form(True)
77
-
78
- with open(temp_path, "wb") as buffer:
79
- shutil.copyfileobj(file.file, buffer)
80
-
81
- vectors_to_search = ai.process_image(temp_path, is_query=True, detect_faces=detect_faces)
82
-
83
- all_results = []
84
- for vec_dict in vectors_to_search:
85
- results = db.search(vec_dict, top_k=10)
86
- all_results.extend(results)
87
-
88
- os.remove(temp_path)
89
-
90
- unique_results = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  for r in all_results:
92
  url = r["url"]
93
- if url not in unique_results or r["score"] > unique_results[url]["score"]:
94
- unique_results[url] = r
95
-
96
- final_results = sorted(unique_results.values(), key=lambda x: x["score"], reverse=True)
97
-
98
- return {"results": final_results[:10]}
99
  except Exception as e:
100
- print(f"Production Search Error: {str(e)}")
101
- raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
- @app.get("/api/categories")
104
- async def get_categories():
105
  try:
106
- result = cloudinary.api.root_folders()
107
- folders = [folder["name"] for folder in result.get("folders", [])]
 
 
108
  return {"categories": folders}
109
  except Exception as e:
110
- print(f"Error fetching categories from Cloudinary: {e}")
111
- return {"categories": []}
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from contextlib import asynccontextmanager
3
+ from collections import OrderedDict
4
+ import asyncio
5
  import os
6
  import shutil
7
  import uuid
8
  import re
9
+ import inflect
10
+ from urllib.parse import urlparse
11
+ from typing import List
12
+
13
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+
16
+ import cloudinary
17
+ import cloudinary.uploader
18
  import cloudinary.api
19
+ from pinecone import Pinecone, ServerlessSpec
20
+
21
+ # ── Deferred imports so startup prints appear in order ────────────
22
+ ai = None # set in lifespan
23
+ p = inflect.engine()
24
+
25
+ # ── Semaphore: max concurrent AI inference jobs ────────────────────
26
+ MAX_CONCURRENT_INFERENCES = int(os.getenv("MAX_CONCURRENT_INFERENCES", "6"))
27
+ _inference_sem: asyncio.Semaphore
28
+
29
+ # ── Simple LRU connection pools ───────────────────────────────────
30
+ _pinecone_pool: OrderedDict = OrderedDict()
31
+ _cloudinary_pool: dict = {}
32
+ _POOL_MAX = 64
33
+
34
+
35
+ def _get_pinecone(api_key: str) -> Pinecone:
36
+ """Return a cached Pinecone client, creating one if needed."""
37
+ if api_key not in _pinecone_pool:
38
+ if len(_pinecone_pool) >= _POOL_MAX:
39
+ _pinecone_pool.popitem(last=False) # evict oldest
40
+ _pinecone_pool[api_key] = Pinecone(api_key=api_key)
41
+ _pinecone_pool.move_to_end(api_key) # refresh LRU order
42
+ return _pinecone_pool[api_key]
43
+
44
+
45
+ def _configure_cloudinary(creds: dict) -> None:
46
+ """Configure cloudinary module only when needed, with simple caching."""
47
+ key = creds["cloud_name"]
48
+ if key not in _cloudinary_pool:
49
+ cloudinary.config(
50
+ cloud_name=creds["cloud_name"],
51
+ api_key=creds["api_key"],
52
+ api_secret=creds["api_secret"],
53
+ )
54
+ _cloudinary_pool[key] = True
55
 
56
+
57
+ # ── Lifespan: load models once at startup ─────────────────────────
58
+ @asynccontextmanager
59
+ async def lifespan(app: FastAPI):
60
+ global ai, _inference_sem
61
+ from src.models import AIModelManager
62
+
63
+ print("⏳ Loading AI models …")
64
+ loop = asyncio.get_event_loop()
65
+ ai = await loop.run_in_executor(None, AIModelManager)
66
+ _inference_sem = asyncio.Semaphore(MAX_CONCURRENT_INFERENCES)
67
+ print(f"✅ Ready! Max concurrent inference slots: {MAX_CONCURRENT_INFERENCES}")
68
+ yield
69
+ print("👋 Shutting down")
70
+
71
+
72
+ app = FastAPI(lifespan=lifespan)
73
 
74
  app.add_middleware(
75
  CORSMiddleware,
76
+ allow_origins=["*"], # tighten to your Vercel domain in production
77
  allow_credentials=True,
78
  allow_methods=["*"],
79
  allow_headers=["*"],
80
  )
81
 
 
 
 
 
 
 
82
  os.makedirs("temp_uploads", exist_ok=True)
83
 
84
+
85
+ # ── Helpers ────────────────────────────────────────────────────────
86
  def standardize_category_name(name: str) -> str:
87
+ clean = re.sub(r'\s+', '_', name.strip().lower())
88
+ clean = re.sub(r'[^\w]', '', clean)
89
+ return p.singular_noun(clean) or clean
90
+
 
91
 
92
  def sanitize_filename(filename: str) -> str:
93
+ clean = re.sub(r'\s+', '_', filename)
94
+ return re.sub(r'[^\w.\-]', '', clean)
95
+
96
+
97
+ def get_cloudinary_creds(env_url: str) -> dict:
98
+ parsed = urlparse(env_url)
99
+ return {
100
+ "api_key": parsed.username,
101
+ "api_secret": parsed.password,
102
+ "cloud_name": parsed.hostname,
103
+ }
104
+
105
+
106
+ # ══════════════════════════════════════════════════════════════════
107
+ # 1. VERIFY KEYS & AUTO-BUILD INDEXES
108
+ # ══════════════════════════════════════════════════════════════════
109
+ @app.post("/api/verify-keys")
110
+ async def verify_keys(
111
+ pinecone_key: str = Form(""),
112
+ cloudinary_url: str = Form(""),
113
+ ):
114
+ if cloudinary_url:
115
+ try:
116
+ creds = get_cloudinary_creds(cloudinary_url)
117
+ _configure_cloudinary(creds)
118
+ await asyncio.to_thread(cloudinary.api.ping)
119
+ except Exception:
120
+ raise HTTPException(400, "Invalid Cloudinary Environment URL.")
121
+
122
+ if pinecone_key:
123
+ try:
124
+ pc = _get_pinecone(pinecone_key)
125
+ existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
126
+
127
+ tasks = []
128
+ if "lens-objects" not in existing:
129
+ tasks.append(asyncio.to_thread(
130
+ pc.create_index,
131
+ name="lens-objects", dimension=1536, metric="cosine",
132
+ spec=ServerlessSpec(cloud="aws", region="us-east-1"),
133
+ ))
134
+ if "lens-faces" not in existing:
135
+ tasks.append(asyncio.to_thread(
136
+ pc.create_index,
137
+ name="lens-faces", dimension=512, metric="cosine",
138
+ spec=ServerlessSpec(cloud="aws", region="us-east-1"),
139
+ ))
140
+
141
+ if tasks:
142
+ await asyncio.gather(*tasks)
143
+
144
+ except HTTPException:
145
+ raise
146
+ except Exception as e:
147
+ raise HTTPException(400, f"Pinecone Error: {e}")
148
 
149
+ return {"message": "Keys verified and indexes ready!"}
150
+
151
+
152
+ # ══════════════════════════════════════════════════════════════════
153
+ # 2. UPLOAD (Cloudinary + Pinecone Only)
154
+ # ══════════════════════════════════════════════════════════════════
155
  @app.post("/api/upload")
156
+ async def upload_new_images(
157
+ files: List[UploadFile] = File(...),
158
+ folder_name: str = Form(...),
159
+ detect_faces: bool = Form(True),
160
+ user_pinecone_key: str = Form(""),
161
+ user_cloudinary_url: str = Form(""),
162
+ ):
163
+ if not user_pinecone_key or not user_cloudinary_url:
164
+ raise HTTPException(status_code=400, detail="Cloudinary URL and Pinecone API Key are required to upload.")
165
+
166
+ folder = standardize_category_name(folder_name)
167
  uploaded_urls = []
168
+
169
+ cld_creds = get_cloudinary_creds(user_cloudinary_url)
170
+ _configure_cloudinary(cld_creds)
171
+ pc = _get_pinecone(user_pinecone_key)
172
+ idx_obj = pc.Index("lens-objects")
173
+ idx_face = pc.Index("lens-faces")
174
+
175
+ for file in files:
176
+ safe_name = sanitize_filename(file.filename)
177
+ tmp_path = f"temp_uploads/{uuid.uuid4().hex}_{safe_name}"
178
+
179
+ try:
180
+ with open(tmp_path, "wb") as buf:
181
+ shutil.copyfileobj(file.file, buf)
182
+
183
+ # Upload image to CDN
184
+ result = await asyncio.to_thread(cloudinary.uploader.upload, tmp_path, folder=folder)
185
+ image_url = result["secure_url"]
 
186
  uploaded_urls.append(image_url)
 
 
 
 
187
 
188
+ # AI inference
189
+ async with _inference_sem:
190
+ vectors = await ai.process_image_async(tmp_path, is_query=False, detect_faces=detect_faces)
191
+
192
+ # Save vectors
193
+ face_upserts = []
194
+ object_upserts = []
195
+
196
+ for v in vectors:
197
+ vec_list = v["vector"].tolist() if hasattr(v["vector"], "tolist") else v["vector"]
198
+ record = {
199
+ "id": str(uuid.uuid4()),
200
+ "values": vec_list,
201
+ "metadata": {"url": image_url, "folder": folder},
202
+ }
203
+ (face_upserts if v["type"] == "face" else object_upserts).append(record)
204
+
205
+ # Fire both upserts concurrently
206
+ upsert_tasks = []
207
+ if face_upserts:
208
+ upsert_tasks.append(asyncio.to_thread(idx_face.upsert, vectors=face_upserts))
209
+ if object_upserts:
210
+ upsert_tasks.append(asyncio.to_thread(idx_obj.upsert, vectors=object_upserts))
211
+ if upsert_tasks:
212
+ await asyncio.gather(*upsert_tasks)
213
+
214
+ except Exception as e:
215
+ print(f"❌ Upload error for {file.filename}: {e}")
216
+ # Continue with the next file instead of aborting the whole batch
217
+ finally:
218
+ if os.path.exists(tmp_path):
219
+ os.remove(tmp_path)
220
+
221
+ return {"message": "Done!", "urls": uploaded_urls}
222
+
223
+
224
+ # ══════════════════════════════════════════════════════════════════
225
+ # 3. SEARCH (Pinecone Only)
226
+ # ══════════════════════════════════════════════════════════════════
227
  @app.post("/api/search")
228
+ async def search_database(
229
+ file: UploadFile = File(...),
230
+ detect_faces: bool = Form(True),
231
+ user_pinecone_key: str = Form(""),
232
+ user_cloudinary_url: str = Form(""), # Kept to match frontend form payload
233
+ ):
234
+ if not user_pinecone_key:
235
+ raise HTTPException(status_code=400, detail="Pinecone API Key is required to search.")
236
+
237
+ safe_name = sanitize_filename(file.filename)
238
+ tmp_path = f"temp_uploads/query_{uuid.uuid4().hex}_{safe_name}"
239
+
240
  try:
241
+ with open(tmp_path, "wb") as buf:
242
+ shutil.copyfileobj(file.file, buf)
243
+
244
+ # AI inference
245
+ async with _inference_sem:
246
+ vectors = await ai.process_image_async(tmp_path, is_query=True, detect_faces=detect_faces)
247
+
248
+ pc = _get_pinecone(user_pinecone_key)
249
+ idx_obj = pc.Index("lens-objects")
250
+ idx_face = pc.Index("lens-faces")
251
+
252
+ # Fire ALL vector queries in parallel
253
+ async def _query_one(vec_dict: dict) -> list[dict]:
254
+ vec_list = (vec_dict["vector"].tolist() if hasattr(vec_dict["vector"], "tolist") else vec_dict["vector"])
255
+ target_idx = idx_face if vec_dict["type"] == "face" else idx_obj
256
+ res = await asyncio.to_thread(
257
+ target_idx.query,
258
+ vector=vec_list, top_k=10, include_metadata=True,
259
+ )
260
+ out = []
261
+ for match in res.get("matches", []):
262
+ caption = ("👤 Verified Identity" if vec_dict["type"] == "face" else match["metadata"].get("folder", "🎯 Object Match"))
263
+ out.append({
264
+ "url": match["metadata"].get("url", ""),
265
+ "score": match["score"],
266
+ "caption": caption,
267
+ })
268
+ return out
269
+
270
+ nested = await asyncio.gather(*[_query_one(v) for v in vectors])
271
+ all_results = [r for sub in nested for r in sub]
272
+
273
+ # Deduplicate, keep best score per URL
274
+ seen: dict[str, dict] = {}
275
  for r in all_results:
276
  url = r["url"]
277
+ if url not in seen or r["score"] > seen[url]["score"]:
278
+ seen[url] = r
279
+
280
+ final = sorted(seen.values(), key=lambda x: x["score"], reverse=True)[:10]
281
+ return {"results": final}
282
+
283
  except Exception as e:
284
+ print(f" Search error: {e}")
285
+ raise HTTPException(500, str(e))
286
+ finally:
287
+ if os.path.exists(tmp_path):
288
+ os.remove(tmp_path)
289
+
290
+
291
+ # ══════════════════════════════════════════════════════════════════
292
+ # 4. CATEGORIES (Cloudinary Folders Only)
293
+ # ══════════════════════════════════════════════════════════════════
294
+ @app.post("/api/categories")
295
+ async def get_categories(user_cloudinary_url: str = Form("")):
296
+ if not user_cloudinary_url:
297
+ return {"categories": []}
298
 
 
 
299
  try:
300
+ creds = get_cloudinary_creds(user_cloudinary_url)
301
+ _configure_cloudinary(creds)
302
+ result = await asyncio.to_thread(cloudinary.api.root_folders)
303
+ folders = [f["name"] for f in result.get("folders", [])]
304
  return {"categories": folders}
305
  except Exception as e:
306
+ print(f"Category fetch error: {e}")
307
+ return {"categories": []}
308
+
309
+
310
+ # ══════════════════════════════════════════════════════════════════
311
+ # 5. HEALTH CHECK
312
+ # ══════════════════════════════════════════════════════════════════
313
+ @app.get("/api/health")
314
+ async def health():
315
+ return {
316
+ "status": "ok",
317
+ "device": ai.device if ai else "loading",
318
+ "sem_slots": _inference_sem._value if _inference_sem else 0,
319
+ }