Pepguy commited on
Commit
a4fa12e
·
verified ·
1 Parent(s): b42ec7f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +626 -545
app.py CHANGED
@@ -1,4 +1,4 @@
1
- # Update the backend to send a title that's within our array of categories for good filtration on frontend and nice classification instead of the generic top or unknown it gives most times, you can see the categories array in the frontend code, now update this backend: # server_gemini_seg.py
2
 
3
  import os
4
  import io
@@ -7,6 +7,7 @@ import base64
7
  import logging
8
  import uuid
9
  import time
 
10
  from typing import List, Dict, Any, Tuple, Optional
11
 
12
  from flask import Flask, request, jsonify
@@ -15,591 +16,671 @@ from PIL import Image, ImageOps
15
  import numpy as np
16
  import cv2
17
 
18
- genai client
19
-
20
  from google import genai
21
  from google.genai import types
22
 
23
- Firebase Admin (in-memory JSON init)
24
-
25
  try:
26
- import firebase_admin
27
- from firebase_admin import credentials as fb_credentials, storage as fb_storage
28
- FIREBASE_ADMIN_AVAILABLE = True
 
29
  except Exception:
30
- firebase_admin = None
31
- fb_credentials = None
32
- fb_storage = None
33
- FIREBASE_ADMIN_AVAILABLE = False
34
 
35
  logging.basicConfig(level=logging.INFO)
36
  log = logging.getLogger("wardrobe-server")
37
 
38
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
39
  if not GEMINI_API_KEY:
40
- log.warning("GEMINI_API_KEY not set — gemini calls will fail (but fallback still works).")
41
 
42
  client = genai.Client(api_key=GEMINI_API_KEY) if GEMINI_API_KEY else None
43
 
44
- Firebase config (read service account JSON from env)
45
-
46
  FIREBASE_ADMIN_JSON = os.getenv("FIREBASE_ADMIN_JSON", "").strip()
47
  FIREBASE_STORAGE_BUCKET = os.getenv("FIREBASE_STORAGE_BUCKET", "").strip() # optional override
48
 
49
  if FIREBASE_ADMIN_JSON and not FIREBASE_ADMIN_AVAILABLE:
50
- log.warning("FIREBASE_ADMIN_JSON provided but firebase-admin SDK is not installed. Install firebase-admin.")
51
 
52
- app = Flask(name)
53
  CORS(app)
54
 
55
- ---------- Firebase init helpers ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  _firebase_app = None
58
 
 
59
  def init_firebase_admin_if_needed():
60
- global _firebase_app
61
- if _firebase_app is not None:
62
- return _firebase_app
63
- if not FIREBASE_ADMIN_JSON:
64
- log.info("No FIREBASE_ADMIN_JSON env var set; skipping Firebase admin init.")
65
- return None
66
- if not FIREBASE_ADMIN_AVAILABLE:
67
- raise RuntimeError("firebase-admin not installed (pip install firebase-admin)")
68
- try:
69
- sa_obj = json.loads(FIREBASE_ADMIN_JSON)
70
- except Exception as e:
71
- log.exception("Failed parsing FIREBASE_ADMIN_JSON: %s", e)
72
- raise
73
- bucket_name = FIREBASE_STORAGE_BUCKET or (sa_obj.get("project_id") and f"{sa_obj.get('project_id')}.appspot.com")
74
- if not bucket_name:
75
- raise RuntimeError("Could not determine storage bucket. Set FIREBASE_STORAGE_BUCKET or include project_id in service account JSON.")
76
- try:
77
- cred = fb_credentials.Certificate(sa_obj)
78
- _firebase_app = firebase_admin.initialize_app(cred, {"storageBucket": bucket_name})
79
- log.info("Initialized firebase admin with bucket: %s", bucket_name)
80
- return _firebase_app
81
- except Exception as e:
82
- log.exception("Failed to initialize firebase admin: %s", e)
83
- raise
 
 
 
84
 
85
  def upload_b64_to_firebase(base64_str: str, path: str, content_type="image/jpeg", metadata: dict = None) -> str:
86
- """
87
- Upload base64 string to Firebase Storage at path.
88
- Optionally attach metadata dict (custom metadata).
89
- Returns a public URL when possible, otherwise returns gs://<bucket>/<path>.
90
- """
91
- if not FIREBASE_ADMIN_JSON:
92
- raise RuntimeError("FIREBASE_ADMIN_JSON not set")
93
- init_firebase_admin_if_needed()
94
- if not FIREBASE_ADMIN_AVAILABLE:
95
- raise RuntimeError("firebase-admin not available")
96
-
97
- raw = base64_str
98
- if raw.startswith("data:"):
99
- raw = raw.split(",", 1)[1]
100
- raw = raw.replace("\n", "").replace("\r", "")
101
- data = base64.b64decode(raw)
102
-
103
- try:
104
- bucket = fb_storage.bucket()
105
- blob = bucket.blob(path)
106
- blob.upload_from_string(data, content_type=content_type)
107
- # attach metadata if provided (values must be strings)
108
- if metadata:
109
- try:
110
- blob.metadata = {k: (json.dumps(v) if not isinstance(v, str) else v) for k, v in metadata.items()}
111
- blob.patch()
112
- except Exception as me:
113
- log.warning("Failed to patch metadata for %s: %s", path, me)
114
- try:
115
- blob.make_public()
116
- return blob.public_url
117
- except Exception as e:
118
- log.warning("Could not make blob public: %s", e)
119
- return f"gs://{bucket.name}/{path}"
120
- except Exception as e:
121
- log.exception("Firebase upload error for path %s: %s", path, e)
122
- raise
123
-
124
- ---------- Image helpers (with EXIF transpose) ----------
125
 
126
  def read_image_bytes(file_storage) -> Tuple[np.ndarray, int, int, bytes]:
127
- """
128
- Read bytes, apply EXIF orientation, return BGR numpy, width, height and raw bytes.
129
- """
130
- data = file_storage.read()
131
- img = Image.open(io.BytesIO(data))
132
- # apply EXIF orientation so photos from phones are upright
133
- try:
134
- img = ImageOps.exif_transpose(img)
135
- except Exception:
136
- pass
137
- img = img.convert("RGB")
138
- w, h = img.size
139
- arr = np.array(img)[:, :, ::-1] # RGB -> BGR for OpenCV
140
- return arr, w, h, data
141
 
142
  def crop_and_b64(bgr_img: np.ndarray, x: int, y: int, w: int, h: int, max_side=512) -> str:
143
- h_img, w_img = bgr_img.shape[:2]
144
- x = max(0, int(x)); y = max(0, int(y))
145
- x2 = min(w_img, int(x + w)); y2 = min(h_img, int(y + h))
146
- crop = bgr_img[y:y2, x:x2]
147
- if crop.size == 0:
148
- return ""
149
- max_dim = max(crop.shape[0], crop.shape[1])
150
- if max_dim > max_side:
151
- scale = max_side / max_dim
152
- crop = cv2.resize(crop, (int(crop.shape[1] * scale), int(crop.shape[0] * scale)), interpolation=cv2.INTER_AREA)
153
- _, jpeg = cv2.imencode(".jpg", crop, [int(cv2.IMWRITE_JPEG_QUALITY), 82])
154
- return base64.b64encode(jpeg.tobytes()).decode("ascii")
 
 
 
155
 
156
  def fallback_contour_crops(bgr_img, max_items=8) -> List[Dict[str, Any]]:
157
- gray = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2GRAY)
158
- blur = cv2.GaussianBlur(gray, (7,7), 0)
159
- thresh = cv2.adaptiveThreshold(blur,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,cv2.THRESH_BINARY_INV,15,6)
160
- kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9,9))
161
- closed = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
162
- contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
163
- h_img, w_img = bgr_img.shape[:2]
164
- min_area = (w_imgh_img) * 0.005
165
- items = []
166
- for cnt in sorted(contours, key=cv2.contourArea, reverse=True):
167
- if len(items) >= max_items:
168
- break
169
- area = cv2.contourArea(cnt)
170
- if area < min_area:
171
- continue
172
- x,y,w,h = cv2.boundingRect(cnt)
173
- pad_x, pad_y = int(w0.07), int(h0.07)
174
- x = max(0, x - pad_x); y = max(0, y - pad_y)
175
- w = min(w_img - x, w + pad_x2); h = min(h_img - y, h + pad_y2)
176
- b64 = crop_and_b64(bgr_img, x, y, w, h)
177
- if not b64:
178
- continue
179
- items.append({
180
- "id": str(uuid.uuid4()),
181
- "label": "unknown",
182
- "confidence": min(0.95, max(0.25, area/(w_imgh_img))),
183
- "bbox": {"x": x, "y": y, "w": w, "h": h},
184
- "thumbnail_b64": b64,
185
- "source": "fallback"
186
- })
187
- if not items:
188
- h_half, w_half = h_img//2, w_img//2
189
- rects = [
190
- (0,0,w_half,h_half), (w_half,0,w_half,h_half),
191
- (0,h_half,w_half,h_half), (w_half,h_half,w_half,h_half)
192
- ]
193
- for r in rects:
194
- b64 = crop_and_b64(bgr_img, r[0], r[1], r[2], r[3])
195
- if b64:
196
- items.append({
197
- "id": str(uuid.uuid4()),
198
- "label": "unknown",
199
- "confidence": 0.3,
200
- "bbox": {"x": r[0], "y": r[1], "w": r[2], "h": r[3]},
201
- "thumbnail_b64": b64,
202
- "source": "fallback-grid"
203
- })
204
- return items
205
-
206
- ---------- AI analysis helper ----------
 
 
 
 
 
207
 
208
  def analyze_crop_with_gemini(jpeg_b64: str) -> Dict[str, Any]:
209
- """
210
- Run Gemini on the cropped image bytes to extract:
211
- type (one-word category like 'shoe', 'jacket', 'dress'),
212
- summary (single-line description),
213
- brand (string or empty),
214
- tags (array of short descriptors)
215
- Returns dict, falls back to empty/defaults on error or missing key.
216
- """
217
- if not client:
218
- return {"type": "unknown", "summary": "", "brand": "", "tags": []}
219
- try:
220
- # prepare prompt
221
- prompt = (
222
- "You are an assistant that identifies clothing item characteristics from an image. "
223
- "Return only a JSON object with keys: type (single word like 'shoe','top','jacket'), "
224
- "summary (a single short sentence, one line), brand (brand name if visible else empty string), "
225
- "tags (an array of short single-word tags describing visible attributes, e.g. ['striped','leather','white']). "
226
- "Keep values short and concise."
227
- )
228
-
229
- contents = [
230
- types.Content(role="user", parts=[types.Part.from_text(text=prompt)])
231
- ]
232
-
233
- # attach the image bytes
234
- image_bytes = base64.b64decode(jpeg_b64)
235
- contents.append(types.Content(role="user", parts=[types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")]))
236
-
237
- schema = {
238
- "type": "object",
239
- "properties": {
240
- "type": {"type": "string"},
241
- "summary": {"type": "string"},
242
- "brand": {"type": "string"},
243
- "tags": {"type": "array", "items": {"type": "string"}}
244
- },
245
- "required": ["type", "summary"]
246
- }
247
- cfg = types.GenerateContentConfig(response_mime_type="application/json", response_schema=schema)
248
-
249
- # call model (use the same model family you used before)
250
- resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents, config=cfg)
251
- text = resp.text or ""
252
- parsed = {}
253
- try:
254
- parsed = json.loads(text)
255
- # coerce expected shapes
256
- parsed["type"] = str(parsed.get("type", "")).strip()
257
- parsed["summary"] = str(parsed.get("summary", "")).strip()
258
- parsed["brand"] = str(parsed.get("brand", "")).strip()
259
- tags = parsed.get("tags", [])
260
- if not isinstance(tags, list):
261
- tags = []
262
- parsed["tags"] = [str(t).strip() for t in tags if str(t).strip()]
263
- except Exception as e:
264
- log.warning("Failed parsing Gemini analysis JSON: %s — raw: %s", e, (text[:300] if text else ""))
265
- parsed = {"type": "unknown", "summary": "", "brand": "", "tags": []}
266
- return {
267
- "type": parsed.get("type", "unknown") or "unknown",
268
- "summary": parsed.get("summary", "") or "",
269
- "brand": parsed.get("brand", "") or "",
270
- "tags": parsed.get("tags", []) or []
271
- }
272
- except Exception as e:
273
- log.exception("analyze_crop_with_gemini failure: %s", e)
274
- return {"type": "unknown", "summary": "", "brand": "", "tags": []}
275
-
276
- ---------- Main / processing ----------
277
 
278
  @app.route("/process", methods=["POST"])
279
  def process_image():
280
- if "photo" not in request.files:
281
- return jsonify({"error": "missing photo"}), 400
282
- file = request.files["photo"]
283
-
284
- uid = (request.form.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
285
-
286
- try:
287
- bgr_img, img_w, img_h, raw_bytes = read_image_bytes(file)
288
- except Exception as e:
289
- log.error("invalid image: %s", e)
290
- return jsonify({"error": "invalid image"}), 400
291
-
292
- session_id = str(uuid.uuid4())
293
-
294
- # Detection prompt (same as before)
295
- user_prompt = (
296
- "You are an assistant that extracts clothing detections from a single image. "
297
- "Return a JSON object with a single key 'items' which is an array. Each item must have: "
298
- "label (string, short like 'top','skirt','sneakers'), "
299
- "bbox with normalized coordinates between 0 and 1: {x, y, w, h} where x,y are top-left relative to width/height, "
300
- "confidence (0-1). Example output: {\"items\":[{\"label\":\"top\",\"bbox\":{\"x\":0.1,\"y\":0.2,\"w\":0.3,\"h\":0.4},\"confidence\":0.95}]} "
301
- "Output ONLY valid JSON. If you cannot detect any clothing confidently, return {\"items\":[]}."
302
- )
303
-
304
- try:
305
- contents = [
306
- types.Content(role="user", parts=[types.Part.from_text(text=user_prompt)])
307
- ]
308
- contents.append(types.Content(role="user", parts=[types.Part.from_bytes(data=raw_bytes, mime_type="image/jpeg")]))
309
-
310
- schema = {
311
- "type": "object",
312
- "properties": {
313
- "items": {
314
- "type": "array",
315
- "items": {
316
- "type": "object",
317
- "properties": {
318
- "label": {"type": "string"},
319
- "bbox": {
320
- "type": "object",
321
- "properties": {
322
- "x": {"type": "number"},
323
- "y": {"type": "number"},
324
- "w": {"type": "number"},
325
- "h": {"type": "number"}
326
- },
327
- "required": ["x","y","w","h"]
328
- },
329
- "confidence": {"type": "number"}
330
- },
331
- "required": ["label","bbox","confidence"]
332
- }
333
- }
334
- },
335
- "required": ["items"]
336
- }
337
-
338
- cfg = types.GenerateContentConfig(response_mime_type="application/json", response_schema=schema)
339
-
340
- log.info("Calling Gemini model for detection (gemini-2.5-flash-lite)...")
341
- model_resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents, config=cfg) if client else None
342
- raw_text = (model_resp.text or "") if model_resp else ""
343
- log.info("Gemini raw response length: %d", len(raw_text))
344
-
345
- parsed = None
346
- try:
347
- parsed = json.loads(raw_text) if raw_text else None
348
- except Exception as e:
349
- log.warning("Could not parse Gemini JSON: %s", e)
350
- parsed = None
351
-
352
- items_out: List[Dict[str, Any]] = []
353
- if parsed and isinstance(parsed.get("items"), list) and len(parsed["items"])>0:
354
- for it in parsed["items"]:
355
- try:
356
- label = str(it.get("label","unknown"))[:48]
357
- bbox = it.get("bbox",{})
358
- nx = float(bbox.get("x",0))
359
- ny = float(bbox.get("y",0))
360
- nw = float(bbox.get("w",0))
361
- nh = float(bbox.get("h",0))
362
- nx = max(0.0, min(1.0, nx)); ny = max(0.0,min(1.0,ny))
363
- nw = max(0.0, min(1.0, nw)); nh = max(0.0, min(1.0, nh))
364
- px = int(nx * img_w); py = int(ny * img_h)
365
- pw = int(nw * img_w); ph = int(nh * img_h)
366
- if pw <= 8 or ph <= 8:
367
- continue
368
- b64 = crop_and_b64(bgr_img, px, py, pw, ph)
369
- if not b64:
370
- continue
371
- items_out.append({
372
- "id": str(uuid.uuid4()),
373
- "label": label,
374
- "confidence": float(it.get("confidence", 0.5)),
375
- "bbox": {"x": px, "y": py, "w": pw, "h": ph},
376
- "thumbnail_b64": b64,
377
- "source": "gemini"
378
- })
379
- except Exception as e:
380
- log.warning("skipping item due to error: %s", e)
381
- else:
382
- log.info("Gemini returned no items or parse failed — using fallback contour crops.")
383
- items_out = fallback_contour_crops(bgr_img, max_items=8)
384
-
385
- # Perform AI analysis per crop (if possible) and auto-upload to firebase with metadata (tmp + session)
386
- if FIREBASE_ADMIN_JSON and FIREBASE_ADMIN_AVAILABLE:
387
- try:
388
- init_firebase_admin_if_needed()
389
- bucket = fb_storage.bucket()
390
- except Exception as e:
391
- log.exception("Firebase admin init for upload failed: %s", e)
392
- bucket = None
393
-
394
- safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
395
- for itm in items_out:
396
- b64 = itm.get("thumbnail_b64")
397
- if not b64:
398
- continue
399
- # analyze
400
- try:
401
- analysis = analyze_crop_with_gemini(b64) if client else {"type":"unknown","summary":"","brand":"","tags":[]}
402
- except Exception as ae:
403
- log.warning("analysis failed: %s", ae)
404
- analysis = {"type":"unknown","summary":"","brand":"","tags":[]}
405
-
406
- itm["analysis"] = analysis
407
-
408
- item_id = itm.get("id") or str(uuid.uuid4())
409
- path = f"detected/{safe_uid}/{item_id}.jpg"
410
- try:
411
- metadata = {
412
- "tmp": "true",
413
- "session_id": session_id,
414
- "uploaded_by": safe_uid,
415
- "uploaded_at": str(int(time.time())),
416
- # store AI fields as JSON strings for later inspection
417
- "ai_type": analysis.get("type",""),
418
- "ai_brand": analysis.get("brand",""),
419
- "ai_summary": analysis.get("summary",""),
420
- "ai_tags": json.dumps(analysis.get("tags", [])),
421
- }
422
- url = upload_b64_to_firebase(b64, path, content_type="image/jpeg", metadata=metadata)
423
- itm["thumbnail_url"] = url
424
- itm["thumbnail_path"] = path
425
- itm.pop("thumbnail_b64", None)
426
- itm["_session_id"] = session_id
427
- log.debug("Auto-uploaded thumbnail for %s -> %s (session=%s)", item_id, url, session_id)
428
- except Exception as up_e:
429
- log.warning("Auto-upload failed for %s: %s", item_id, up_e)
430
- # keep thumbnail_b64 and analysis for client fallback
431
- else:
432
- if not FIREBASE_ADMIN_JSON:
433
- log.info("FIREBASE_ADMIN_JSON not set; skipping server-side thumbnail upload.")
434
- else:
435
- log.info("Firebase admin SDK not available; skipping server-side thumbnail upload.")
436
-
437
- return jsonify({"ok": True, "items": items_out, "session_id": session_id, "debug": {"raw_model_text": (raw_text or "")[:1600]}}), 200
438
-
439
- except Exception as ex:
440
- log.exception("Processing error: %s", ex)
441
- try:
442
- items_out = fallback_contour_crops(bgr_img, max_items=8)
443
- return jsonify({"ok": True, "items": items_out, "session_id": session_id, "debug": {"error": str(ex)}}), 200
444
- except Exception as e2:
445
- log.exception("Fallback also failed: %s", e2)
446
- return jsonify({"error": "internal failure", "detail": str(e2)}), 500
447
-
448
- ---------- Finalize endpoint: keep selected and delete only session's temp files ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
 
450
  @app.route("/finalize_detections", methods=["POST"])
451
  def finalize_detections():
452
- """
453
- Body JSON:
454
- { "uid": "user123", "keep_ids": ["id1","id2",...], "session_id": "<session id from /process>" }
455
-
456
- Server will delete only detected/<uid>/* files whose:
457
- - metadata.tmp == "true"
458
- - metadata.session_id == session_id
459
- - item_id NOT in keep_ids
460
-
461
- Returns:
462
- { ok: True, kept: [...], deleted: [...], errors: [...] }
463
- """
464
- try:
465
- body = request.get_json(force=True)
466
- except Exception:
467
- return jsonify({"error": "invalid json"}), 400
468
-
469
- uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
470
- keep_ids = set(body.get("keep_ids") or [])
471
- session_id = (body.get("session_id") or request.args.get("session_id") or "").strip()
472
-
473
- if not session_id:
474
- return jsonify({"error": "session_id required for finalize to avoid unsafe deletes"}), 400
475
-
476
- if not FIREBASE_ADMIN_JSON or not FIREBASE_ADMIN_AVAILABLE:
477
- return jsonify({"error": "firebase admin not configured"}), 500
478
-
479
- try:
480
- init_firebase_admin_if_needed()
481
- bucket = fb_storage.bucket()
482
- except Exception as e:
483
- log.exception("Firebase init error in finalize: %s", e)
484
- return jsonify({"error": "firebase admin init failed", "detail": str(e)}), 500
485
-
486
- safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
487
- prefix = f"detected/{safe_uid}/"
488
-
489
- kept = []
490
- deleted = []
491
- errors = []
492
-
493
- try:
494
- blobs = list(bucket.list_blobs(prefix=prefix))
495
- for blob in blobs:
496
- try:
497
- name = blob.name
498
- fname = name.split("/")[-1]
499
- if "." not in fname:
500
- continue
501
- item_id = fname.rsplit(".", 1)[0]
502
-
503
- md = blob.metadata or {}
504
- # only consider temporary files matching this session id
505
- if str(md.get("session_id", "")) != session_id or str(md.get("tmp", "")).lower() not in ("true", "1", "yes"):
506
- continue
507
-
508
- if item_id in keep_ids:
509
- # ensure public URL available if possible
510
- try:
511
- blob.make_public()
512
- url = blob.public_url
513
- except Exception:
514
- url = f"gs://{bucket.name}/{name}"
515
-
516
- # extract AI metadata (if present)
517
- ai_type = md.get("ai_type") or ""
518
- ai_brand = md.get("ai_brand") or ""
519
- ai_summary = md.get("ai_summary") or ""
520
- ai_tags_raw = md.get("ai_tags") or "[]"
521
- try:
522
- ai_tags = json.loads(ai_tags_raw) if isinstance(ai_tags_raw, str) else ai_tags_raw
523
- except Exception:
524
- ai_tags = []
525
- kept.append({
526
- "id": item_id,
527
- "thumbnail_url": url,
528
- "thumbnail_path": name,
529
- "analysis": {
530
- "type": ai_type,
531
- "brand": ai_brand,
532
- "summary": ai_summary,
533
- "tags": ai_tags
534
- }
535
- })
536
- else:
537
- try:
538
- blob.delete()
539
- deleted.append(item_id)
540
- except Exception as de:
541
- errors.append({"id": item_id, "error": str(de)})
542
- except Exception as e:
543
- errors.append({"blob": getattr(blob, "name", None), "error": str(e)})
544
- return jsonify({"ok": True, "kept": kept, "deleted": deleted, "errors": errors}), 200
545
- except Exception as e:
546
- log.exception("finalize_detections error: %s", e)
547
- return jsonify({"error": "internal", "detail": str(e)}), 500
548
-
549
- ---------- Clear session: delete all temporary files for a session ----------
550
 
551
  @app.route("/clear_session", methods=["POST"])
552
  def clear_session():
553
- """
554
- Body JSON: { "session_id": "<id>", "uid": "<optional uid>" }
555
- Deletes all detected/<uid>/* blobs where metadata.session_id == session_id and metadata.tmp == "true".
556
- """
557
- try:
558
- body = request.get_json(force=True)
559
- except Exception:
560
- return jsonify({"error": "invalid json"}), 400
561
-
562
- session_id = (body.get("session_id") or request.args.get("session_id") or "").strip()
563
- uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
564
-
565
- if not session_id:
566
- return jsonify({"error": "session_id required"}), 400
567
-
568
- if not FIREBASE_ADMIN_JSON or not FIREBASE_ADMIN_AVAILABLE:
569
- return jsonify({"error": "firebase admin not configured"}), 500
570
-
571
- try:
572
- init_firebase_admin_if_needed()
573
- bucket = fb_storage.bucket()
574
- except Exception as e:
575
- log.exception("Firebase init error in clear_session: %s", e)
576
- return jsonify({"error": "firebase admin init failed", "detail": str(e)}), 500
577
-
578
- safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
579
- prefix = f"detected/{safe_uid}/"
580
-
581
- deleted = []
582
- errors = []
583
- try:
584
- blobs = list(bucket.list_blobs(prefix=prefix))
585
- for blob in blobs:
586
- try:
587
- md = blob.metadata or {}
588
- if str(md.get("session_id", "")) == session_id and str(md.get("tmp", "")).lower() in ("true", "1", "yes"):
589
- try:
590
- blob.delete()
591
- deleted.append(blob.name.split("/")[-1].rsplit(".", 1)[0])
592
- except Exception as de:
593
- errors.append({"blob": blob.name, "error": str(de)})
594
- except Exception as e:
595
- errors.append({"blob": getattr(blob, "name", None), "error": str(e)})
596
- return jsonify({"ok": True, "deleted": deleted, "errors": errors}), 200
597
- except Exception as e:
598
- log.exception("clear_session error: %s", e)
599
- return jsonify({"error": "internal", "detail": str(e)}), 500
600
-
601
- if name == "main":
602
- port = int(os.getenv("PORT", 7860))
603
- log.info("Starting server on 0.0.0.0:%d", port)
604
- app.run(host="0.0.0.0", port=port, debug=True)
605
-
 
1
+ # server_gemini_seg.py
2
 
3
  import os
4
  import io
 
7
  import logging
8
  import uuid
9
  import time
10
+ import difflib
11
  from typing import List, Dict, Any, Tuple, Optional
12
 
13
  from flask import Flask, request, jsonify
 
16
  import numpy as np
17
  import cv2
18
 
19
+ # genai client
 
20
  from google import genai
21
  from google.genai import types
22
 
23
+ # Firebase Admin (in-memory JSON init)
 
24
  try:
25
+ import firebase_admin
26
+ from firebase_admin import credentials as fb_credentials, storage as fb_storage
27
+
28
+ FIREBASE_ADMIN_AVAILABLE = True
29
  except Exception:
30
+ firebase_admin = None
31
+ fb_credentials = None
32
+ fb_storage = None
33
+ FIREBASE_ADMIN_AVAILABLE = False
34
 
35
  logging.basicConfig(level=logging.INFO)
36
  log = logging.getLogger("wardrobe-server")
37
 
38
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
39
  if not GEMINI_API_KEY:
40
+ log.warning("GEMINI_API_KEY not set — gemini calls will fail (but fallback still works).")
41
 
42
  client = genai.Client(api_key=GEMINI_API_KEY) if GEMINI_API_KEY else None
43
 
44
+ # Firebase config (read service account JSON from env)
 
45
  FIREBASE_ADMIN_JSON = os.getenv("FIREBASE_ADMIN_JSON", "").strip()
46
  FIREBASE_STORAGE_BUCKET = os.getenv("FIREBASE_STORAGE_BUCKET", "").strip() # optional override
47
 
48
  if FIREBASE_ADMIN_JSON and not FIREBASE_ADMIN_AVAILABLE:
49
+ log.warning("FIREBASE_ADMIN_JSON provided but firebase-admin SDK is not installed. Install firebase-admin.")
50
 
51
+ app = Flask(__name__)
52
  CORS(app)
53
 
54
+ # ---------- Categories mapping (map model 'type' to frontend categories) ----------
55
+ # NOTE: If frontend has a definitive categories array, replace this list with that array.
56
+ # We use difflib.get_close_matches to pick the closest category from CATEGORIES.
57
+ CATEGORIES = [
58
+ "top",
59
+ "shirt",
60
+ "blouse",
61
+ "tshirt",
62
+ "sweater",
63
+ "jacket",
64
+ "coat",
65
+ "dress",
66
+ "skirt",
67
+ "pants",
68
+ "trousers",
69
+ "shorts",
70
+ "jeans",
71
+ "shoe",
72
+ "heels",
73
+ "sneaker",
74
+ "boot",
75
+ "sandals",
76
+ "bag",
77
+ "belt",
78
+ "hat",
79
+ "accessory",
80
+ "others",
81
+ ]
82
+
83
+
84
+ def map_type_to_category(item_type: str) -> str:
85
+ """Map a model-produced type string to the closest category from CATEGORIES.
86
+ Falls back to 'unknown' if no reasonable match is found.
87
+ """
88
+ if not item_type:
89
+ return "unknown"
90
+ t = item_type.strip().lower()
91
+ # direct hit
92
+ if t in CATEGORIES:
93
+ return t
94
+ # try splitting or common plural handling
95
+ t_clean = t.rstrip("s")
96
+ if t_clean in CATEGORIES:
97
+ return t_clean
98
+ # fuzzy match
99
+ matches = difflib.get_close_matches(t, CATEGORIES, n=1, cutoff=0.6)
100
+ if matches:
101
+ return matches[0]
102
+ # attempt to match by token intersection
103
+ for token in t.replace("_", " ").split():
104
+ if token in CATEGORIES:
105
+ return token
106
+ return "unknown"
107
+
108
+
109
+ # ---------- Firebase init helpers ----------
110
 
111
  _firebase_app = None
112
 
113
+
114
  def init_firebase_admin_if_needed():
115
+ global _firebase_app
116
+ if _firebase_app is not None:
117
+ return _firebase_app
118
+ if not FIREBASE_ADMIN_JSON:
119
+ log.info("No FIREBASE_ADMIN_JSON env var set; skipping Firebase admin init.")
120
+ return None
121
+ if not FIREBASE_ADMIN_AVAILABLE:
122
+ raise RuntimeError("firebase-admin not installed (pip install firebase-admin)")
123
+ try:
124
+ sa_obj = json.loads(FIREBASE_ADMIN_JSON)
125
+ except Exception as e:
126
+ log.exception("Failed parsing FIREBASE_ADMIN_JSON: %s", e)
127
+ raise
128
+ bucket_name = FIREBASE_STORAGE_BUCKET or (sa_obj.get("project_id") and f"{sa_obj.get('project_id')}.appspot.com")
129
+ if not bucket_name:
130
+ raise RuntimeError(
131
+ "Could not determine storage bucket. Set FIREBASE_STORAGE_BUCKET or include project_id in service account JSON."
132
+ )
133
+ try:
134
+ cred = fb_credentials.Certificate(sa_obj)
135
+ _firebase_app = firebase_admin.initialize_app(cred, {"storageBucket": bucket_name})
136
+ log.info("Initialized firebase admin with bucket: %s", bucket_name)
137
+ return _firebase_app
138
+ except Exception as e:
139
+ log.exception("Failed to initialize firebase admin: %s", e)
140
+ raise
141
+
142
 
143
  def upload_b64_to_firebase(base64_str: str, path: str, content_type="image/jpeg", metadata: dict = None) -> str:
144
+ """Upload base64 string to Firebase Storage at `path`. Optionally attach metadata dict (custom metadata).
145
+ Returns a public URL when possible, otherwise returns gs:///.
146
+ """
147
+ if not FIREBASE_ADMIN_JSON:
148
+ raise RuntimeError("FIREBASE_ADMIN_JSON not set")
149
+ init_firebase_admin_if_needed()
150
+ if not FIREBASE_ADMIN_AVAILABLE:
151
+ raise RuntimeError("firebase-admin not available")
152
+
153
+ raw = base64_str
154
+ if raw.startswith("data:"):
155
+ raw = raw.split(",", 1)[1]
156
+ raw = raw.replace("\n", "").replace("\r", "")
157
+ data = base64.b64decode(raw)
158
+
159
+ try:
160
+ bucket = fb_storage.bucket()
161
+ blob = bucket.blob(path)
162
+ blob.upload_from_string(data, content_type=content_type)
163
+ if metadata:
164
+ try:
165
+ blob.metadata = {k: (json.dumps(v) if not isinstance(v, str) else v) for k, v in metadata.items()}
166
+ blob.patch()
167
+ except Exception as me:
168
+ log.warning("Failed to patch metadata for %s: %s", path, me)
169
+ try:
170
+ blob.make_public()
171
+ return blob.public_url
172
+ except Exception as e:
173
+ log.warning("Could not make blob public: %s", e)
174
+ return f"gs://{bucket.name}/{path}"
175
+ except Exception as e:
176
+ log.exception("Firebase upload error for path %s: %s", path, e)
177
+ raise
178
+
179
+
180
+ # ---------- Image helpers (with EXIF transpose) ----------
181
+
 
182
 
183
  def read_image_bytes(file_storage) -> Tuple[np.ndarray, int, int, bytes]:
184
+ """Read bytes, apply EXIF orientation, return BGR numpy, width, height and raw bytes."""
185
+ data = file_storage.read()
186
+ img = Image.open(io.BytesIO(data))
187
+ try:
188
+ img = ImageOps.exif_transpose(img)
189
+ except Exception:
190
+ pass
191
+ img = img.convert("RGB")
192
+ w, h = img.size
193
+ arr = np.array(img)[:, :, ::-1] # RGB -> BGR
194
+ return arr, w, h, data
195
+
 
 
196
 
197
  def crop_and_b64(bgr_img: np.ndarray, x: int, y: int, w: int, h: int, max_side=512) -> str:
198
+ h_img, w_img = bgr_img.shape[:2]
199
+ x = max(0, int(x))
200
+ y = max(0, int(y))
201
+ x2 = min(w_img, int(x + w))
202
+ y2 = min(h_img, int(y + h))
203
+ crop = bgr_img[y:y2, x:x2]
204
+ if crop.size == 0:
205
+ return ""
206
+ max_dim = max(crop.shape[0], crop.shape[1])
207
+ if max_dim > max_side:
208
+ scale = max_side / max_dim
209
+ crop = cv2.resize(crop, (int(crop.shape[1] * scale), int(crop.shape[0] * scale)), interpolation=cv2.INTER_AREA)
210
+ _, jpeg = cv2.imencode(".jpg", crop, [int(cv2.IMWRITE_JPEG_QUALITY), 82])
211
+ return base64.b64encode(jpeg.tobytes()).decode("ascii")
212
+
213
 
214
  def fallback_contour_crops(bgr_img, max_items=8) -> List[Dict[str, Any]]:
215
+ gray = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2GRAY)
216
+ blur = cv2.GaussianBlur(gray, (7, 7), 0)
217
+ thresh = cv2.adaptiveThreshold(blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 15, 6)
218
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9))
219
+ closed = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
220
+ contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
221
+ h_img, w_img = bgr_img.shape[:2]
222
+ min_area = (w_img * h_img) * 0.005
223
+ items = []
224
+ for cnt in sorted(contours, key=cv2.contourArea, reverse=True):
225
+ if len(items) >= max_items:
226
+ break
227
+ area = cv2.contourArea(cnt)
228
+ if area < min_area:
229
+ continue
230
+ x, y, w, h = cv2.boundingRect(cnt)
231
+ pad_x, pad_y = int(w * 0.07), int(h * 0.07)
232
+ x = max(0, x - pad_x)
233
+ y = max(0, y - pad_y)
234
+ w = min(w_img - x, w + pad_x * 2)
235
+ h = min(h_img - y, h + pad_y * 2)
236
+ b64 = crop_and_b64(bgr_img, x, y, w, h)
237
+ if not b64:
238
+ continue
239
+ items.append(
240
+ {
241
+ "id": str(uuid.uuid4()),
242
+ "label": "unknown",
243
+ "confidence": min(0.95, max(0.25, area / (w_img * h_img))),
244
+ "bbox": {"x": x, "y": y, "w": w, "h": h},
245
+ "thumbnail_b64": b64,
246
+ "source": "fallback",
247
+ }
248
+ )
249
+ if not items:
250
+ h_half, w_half = h_img // 2, w_img // 2
251
+ rects = [(0, 0, w_half, h_half), (w_half, 0, w_half, h_half), (0, h_half, w_half, h_half), (w_half, h_half, w_half, h_half)]
252
+ for r in rects:
253
+ b64 = crop_and_b64(bgr_img, r[0], r[1], r[2], r[3])
254
+ if b64:
255
+ items.append(
256
+ {
257
+ "id": str(uuid.uuid4()),
258
+ "label": "unknown",
259
+ "confidence": 0.3,
260
+ "bbox": {"x": r[0], "y": r[1], "w": r[2], "h": r[3]},
261
+ "thumbnail_b64": b64,
262
+ "source": "fallback-grid",
263
+ }
264
+ )
265
+ return items
266
+
267
+
268
+ # ---------- AI analysis helper ----------
269
+
270
 
271
  def analyze_crop_with_gemini(jpeg_b64: str) -> Dict[str, Any]:
272
+ """Run Gemini on the cropped image bytes to extract:
273
+ type (one-word category like 'shoe', 'jacket', 'dress'),
274
+ summary (single-line description), brand (string or empty), tags (array of short descriptors)
275
+ Returns dict, falls back to empty/defaults on error or missing key.
276
+ """
277
+ if not client:
278
+ return {"type": "unknown", "summary": "", "brand": "", "tags": []}
279
+ try:
280
+ # prepare prompt
281
+ prompt = (
282
+ "You are an assistant that identifies clothing item characteristics from an image. "
283
+ "Return only a JSON object with keys: type (single word like 'shoe','top','jacket'), "
284
+ "summary (a single short sentence, one line), brand (brand name if visible else empty string), "
285
+ "tags (an array of short single-word tags describing visible attributes, e.g. ['striped','leather','white']). "
286
+ "Keep values short and concise."
287
+ )
288
+
289
+ contents = [types.Content(role="user", parts=[types.Part.from_text(text=prompt)])]
290
+
291
+ # attach the image bytes
292
+ image_bytes = base64.b64decode(jpeg_b64)
293
+ contents.append(types.Content(role="user", parts=[types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")]))
294
+
295
+ schema = {
296
+ "type": "object",
297
+ "properties": {
298
+ "type": {"type": "string"},
299
+ "summary": {"type": "string"},
300
+ "brand": {"type": "string"},
301
+ "tags": {"type": "array", "items": {"type": "string"}},
302
+ },
303
+ "required": ["type", "summary"],
304
+ }
305
+ cfg = types.GenerateContentConfig(response_mime_type="application/json", response_schema=schema)
306
+
307
+ # call model (use the same model family you used before)
308
+ resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents, config=cfg)
309
+ text = resp.text or ""
310
+ parsed = {}
311
+ try:
312
+ parsed = json.loads(text)
313
+ # coerce expected shapes
314
+ parsed["type"] = str(parsed.get("type", "")).strip()
315
+ parsed["summary"] = str(parsed.get("summary", "")).strip()
316
+ parsed["brand"] = str(parsed.get("brand", "")).strip()
317
+ tags = parsed.get("tags", [])
318
+ if not isinstance(tags, list):
319
+ tags = []
320
+ parsed["tags"] = [str(t).strip() for t in tags if str(t).strip()]
321
+ except Exception as e:
322
+ log.warning("Failed parsing Gemini analysis JSON: %s — raw: %s", e, (text[:300] if text else ""))
323
+ parsed = {"type": "unknown", "summary": "", "brand": "", "tags": []}
324
+ return {
325
+ "type": parsed.get("type", "unknown") or "unknown",
326
+ "summary": parsed.get("summary", "") or "",
327
+ "brand": parsed.get("brand", "") or "",
328
+ "tags": parsed.get("tags", []) or [],
329
+ }
330
+ except Exception as e:
331
+ log.exception("analyze_crop_with_gemini failure: %s", e)
332
+ return {"type": "unknown", "summary": "", "brand": "", "tags": []}
333
+
334
+
335
+ # ---------- Main / processing ----------
336
+
 
 
 
337
 
338
  @app.route("/process", methods=["POST"])
339
  def process_image():
340
+ if "photo" not in request.files:
341
+ return jsonify({"error": "missing photo"}), 400
342
+ file = request.files["photo"]
343
+
344
+ uid = (request.form.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
345
+ try:
346
+ bgr_img, img_w, img_h, raw_bytes = read_image_bytes(file)
347
+ except Exception as e:
348
+ log.error("invalid image: %s", e)
349
+ return jsonify({"error": "invalid image"}), 400
350
+
351
+ session_id = str(uuid.uuid4())
352
+
353
+ # Detection prompt (same as before)
354
+ user_prompt = (
355
+ "You are an assistant that extracts clothing detections from a single image. "
356
+ "Return a JSON object with a single key 'items' which is an array. Each item must have: "
357
+ "label (string, short like 'top','skirt','sneakers'), "
358
+ "bbox with normalized coordinates between 0 and 1: {x, y, w, h} where x,y are top-left relative to width/height, "
359
+ "confidence (0-1). Example output: {\"items\":[{\"label\":\"top\",\"bbox\":{\"x\":0.1,\"y\":0.2,\"w\":0.3,\"h\":0.4},\"confidence\":0.95}]} "
360
+ "Output ONLY valid JSON. If you cannot detect any clothing confidently, return {\"items\":[]}."
361
+ )
362
+
363
+ try:
364
+ contents = [types.Content(role="user", parts=[types.Part.from_text(text=user_prompt)])]
365
+ contents.append(types.Content(role="user", parts=[types.Part.from_bytes(data=raw_bytes, mime_type="image/jpeg")]))
366
+
367
+ schema = {
368
+ "type": "object",
369
+ "properties": {
370
+ "items": {
371
+ "type": "array",
372
+ "items": {
373
+ "type": "object",
374
+ "properties": {
375
+ "label": {"type": "string"},
376
+ "bbox": {
377
+ "type": "object",
378
+ "properties": {
379
+ "x": {"type": "number"},
380
+ "y": {"type": "number"},
381
+ "w": {"type": "number"},
382
+ "h": {"type": "number"},
383
+ },
384
+ "required": ["x", "y", "w", "h"],
385
+ },
386
+ "confidence": {"type": "number"},
387
+ },
388
+ "required": ["label", "bbox", "confidence"],
389
+ },
390
+ }
391
+ },
392
+ "required": ["items"],
393
+ }
394
+
395
+ cfg = types.GenerateContentConfig(response_mime_type="application/json", response_schema=schema)
396
+
397
+ log.info("Calling Gemini model for detection (gemini-2.5-flash-lite)...")
398
+ model_resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents, config=cfg) if client else None
399
+ raw_text = (model_resp.text or "") if model_resp else ""
400
+ log.info("Gemini raw response length: %d", len(raw_text))
401
+
402
+ parsed = None
403
+ try:
404
+ parsed = json.loads(raw_text) if raw_text else None
405
+ except Exception as e:
406
+ log.warning("Could not parse Gemini JSON: %s", e)
407
+ parsed = None
408
+
409
+ items_out: List[Dict[str, Any]] = []
410
+ if parsed and isinstance(parsed.get("items"), list) and len(parsed["items"]) > 0:
411
+ for it in parsed["items"]:
412
+ try:
413
+ label = str(it.get("label", "unknown"))[:48]
414
+ bbox = it.get("bbox", {})
415
+ nx = float(bbox.get("x", 0))
416
+ ny = float(bbox.get("y", 0))
417
+ nw = float(bbox.get("w", 0))
418
+ nh = float(bbox.get("h", 0))
419
+ nx = max(0.0, min(1.0, nx))
420
+ ny = max(0.0, min(1.0, ny))
421
+ nw = max(0.0, min(1.0, nw))
422
+ nh = max(0.0, min(1.0, nh))
423
+ px = int(nx * img_w)
424
+ py = int(ny * img_h)
425
+ pw = int(nw * img_w)
426
+ ph = int(nh * img_h)
427
+ if pw <= 8 or ph <= 8:
428
+ continue
429
+ b64 = crop_and_b64(bgr_img, px, py, pw, ph)
430
+ if not b64:
431
+ continue
432
+ item_obj = {
433
+ "id": str(uuid.uuid4()),
434
+ "label": label,
435
+ "confidence": float(it.get("confidence", 0.5)),
436
+ "bbox": {"x": px, "y": py, "w": pw, "h": ph},
437
+ "thumbnail_b64": b64,
438
+ "source": "gemini",
439
+ }
440
+ # Add placeholder analysis/title; will be filled later if analysis runs
441
+ item_obj["analysis"] = {"type": "unknown", "summary": "", "brand": "", "tags": []}
442
+ item_obj["title"] = "unknown"
443
+ items_out.append(item_obj)
444
+ except Exception as e:
445
+ log.warning("skipping item due to error: %s", e)
446
+ else:
447
+ log.info("Gemini returned no items or parse failed — using fallback contour crops.")
448
+ items_out = fallback_contour_crops(bgr_img, max_items=8)
449
+ # ensure analysis/title placeholders
450
+ for itm in items_out:
451
+ itm.setdefault("analysis", {"type": "unknown", "summary": "", "brand": "", "tags": []})
452
+ itm.setdefault("title", "unknown")
453
+
454
+ # Perform AI analysis per crop (if possible) and auto-upload to firebase with metadata (tmp + session)
455
+ if FIREBASE_ADMIN_JSON and FIREBASE_ADMIN_AVAILABLE:
456
+ try:
457
+ init_firebase_admin_if_needed()
458
+ bucket = fb_storage.bucket()
459
+ except Exception as e:
460
+ log.exception("Firebase admin init for upload failed: %s", e)
461
+ bucket = None
462
+
463
+ safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
464
+ for itm in items_out:
465
+ b64 = itm.get("thumbnail_b64")
466
+ if not b64:
467
+ continue
468
+ # analyze
469
+ try:
470
+ analysis = analyze_crop_with_gemini(b64) if client else {"type": "unknown", "summary": "", "brand": "", "tags": []}
471
+ except Exception as ae:
472
+ log.warning("analysis failed: %s", ae)
473
+ analysis = {"type": "unknown", "summary": "", "brand": "", "tags": []}
474
+
475
+ # attach analysis and map to frontend category/title
476
+ itm["analysis"] = analysis
477
+ mapped_title = map_type_to_category(analysis.get("type", "") or itm.get("label", ""))
478
+ itm["title"] = mapped_title
479
+
480
+ item_id = itm.get("id") or str(uuid.uuid4())
481
+ path = f"detected/{safe_uid}/{item_id}.jpg"
482
+ try:
483
+ metadata = {
484
+ "tmp": "true",
485
+ "session_id": session_id,
486
+ "uploaded_by": safe_uid,
487
+ "uploaded_at": str(int(time.time())),
488
+ # store AI fields as JSON strings for later inspection
489
+ "ai_type": analysis.get("type", ""),
490
+ "ai_brand": analysis.get("brand", ""),
491
+ "ai_summary": analysis.get("summary", ""),
492
+ "ai_tags": json.dumps(analysis.get("tags", [])),
493
+ }
494
+ url = upload_b64_to_firebase(b64, path, content_type="image/jpeg", metadata=metadata)
495
+ itm["thumbnail_url"] = url
496
+ itm["thumbnail_path"] = path
497
+ itm.pop("thumbnail_b64", None)
498
+ itm["_session_id"] = session_id
499
+ log.debug("Auto-uploaded thumbnail for %s -> %s (session=%s)", item_id, url, session_id)
500
+ except Exception as up_e:
501
+ log.warning("Auto-upload failed for %s: %s", item_id, up_e)
502
+ # keep thumbnail_b64 and analysis for client fallback
503
+ else:
504
+ if not FIREBASE_ADMIN_JSON:
505
+ log.info("FIREBASE_ADMIN_JSON not set; skipping server-side thumbnail upload.")
506
+ else:
507
+ log.info("Firebase admin SDK not available; skipping server-side thumbnail upload.")
508
+ # For items without firebase upload, still attempt local analysis mapping
509
+ for itm in items_out:
510
+ if "analysis" not in itm or not itm["analysis"]:
511
+ # attempt lightweight analysis mapping using label
512
+ itm.setdefault("analysis", {"type": itm.get("label", "unknown"), "summary": "", "brand": "", "tags": []})
513
+ mapped_title = map_type_to_category(itm["analysis"].get("type", "") or itm.get("label", ""))
514
+ itm["title"] = mapped_title
515
+
516
+ return jsonify({"ok": True, "items": items_out, "session_id": session_id, "debug": {"raw_model_text": (raw_text or "")[:1600]}}), 200
517
+ except Exception as ex:
518
+ log.exception("Processing error: %s", ex)
519
+ try:
520
+ items_out = fallback_contour_crops(bgr_img, max_items=8)
521
+ for itm in items_out:
522
+ itm.setdefault("analysis", {"type": "unknown", "summary": "", "brand": "", "tags": []})
523
+ itm["title"] = map_type_to_category(itm["analysis"].get("type", "") or itm.get("label", ""))
524
+ return jsonify({"ok": True, "items": items_out, "session_id": session_id, "debug": {"error": str(ex)}}), 200
525
+ except Exception as e2:
526
+ log.exception("Fallback also failed: %s", e2)
527
+ return jsonify({"error": "internal failure", "detail": str(e2)}), 500
528
+
529
+
530
+ # ---------- Finalize endpoint: keep selected and delete only session's temp files ----------
531
+
532
 
533
  @app.route("/finalize_detections", methods=["POST"])
534
  def finalize_detections():
535
+ """
536
+ Body JSON: { "uid": "user123", "keep_ids": ["id1","id2",...], "session_id": "<session id from /process>" }
537
+
538
+ Server will delete only detected/<uid>/* files whose:
539
+ - metadata.tmp == "true"
540
+ - metadata.session_id == session_id
541
+ - item_id NOT in keep_ids
542
+
543
+ Returns:
544
+ { ok: True, kept: [...], deleted: [...], errors: [...] }
545
+ """
546
+ try:
547
+ body = request.get_json(force=True)
548
+ except Exception:
549
+ return jsonify({"error": "invalid json"}), 400
550
+
551
+ uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
552
+ keep_ids = set(body.get("keep_ids") or [])
553
+ session_id = (body.get("session_id") or request.args.get("session_id") or "").strip()
554
+
555
+ if not session_id:
556
+ return jsonify({"error": "session_id required for finalize to avoid unsafe deletes"}), 400
557
+
558
+ if not FIREBASE_ADMIN_JSON or not FIREBASE_ADMIN_AVAILABLE:
559
+ return jsonify({"error": "firebase admin not configured"}), 500
560
+
561
+ try:
562
+ init_firebase_admin_if_needed()
563
+ bucket = fb_storage.bucket()
564
+ except Exception as e:
565
+ log.exception("Firebase init error in finalize: %s", e)
566
+ return jsonify({"error": "firebase admin init failed", "detail": str(e)}), 500
567
+
568
+ safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
569
+ prefix = f"detected/{safe_uid}/"
570
+
571
+ kept = []
572
+ deleted = []
573
+ errors = []
574
+
575
+ try:
576
+ blobs = list(bucket.list_blobs(prefix=prefix))
577
+ for blob in blobs:
578
+ try:
579
+ name = blob.name
580
+ fname = name.split("/")[-1]
581
+ if "." not in fname:
582
+ continue
583
+ item_id = fname.rsplit(".", 1)[0]
584
+
585
+ md = blob.metadata or {}
586
+ # only consider temporary files matching this session id
587
+ if str(md.get("session_id", "")) != session_id or str(md.get("tmp", "")).lower() not in ("true", "1", "yes"):
588
+ continue
589
+
590
+ if item_id in keep_ids:
591
+ # ensure public URL available if possible
592
+ try:
593
+ blob.make_public()
594
+ url = blob.public_url
595
+ except Exception:
596
+ url = f"gs://{bucket.name}/{name}"
597
+
598
+ # extract AI metadata (if present)
599
+ ai_type = md.get("ai_type") or ""
600
+ ai_brand = md.get("ai_brand") or ""
601
+ ai_summary = md.get("ai_summary") or ""
602
+ ai_tags_raw = md.get("ai_tags") or "[]"
603
+ try:
604
+ ai_tags = json.loads(ai_tags_raw) if isinstance(ai_tags_raw, str) else ai_tags_raw
605
+ except Exception:
606
+ ai_tags = []
607
+ kept.append(
608
+ {
609
+ "id": item_id,
610
+ "thumbnail_url": url,
611
+ "thumbnail_path": name,
612
+ "analysis": {"type": ai_type, "brand": ai_brand, "summary": ai_summary, "tags": ai_tags},
613
+ }
614
+ )
615
+ else:
616
+ try:
617
+ blob.delete()
618
+ deleted.append(item_id)
619
+ except Exception as de:
620
+ errors.append({"id": item_id, "error": str(de)})
621
+ except Exception as e:
622
+ errors.append({"blob": getattr(blob, "name", None), "error": str(e)})
623
+ return jsonify({"ok": True, "kept": kept, "deleted": deleted, "errors": errors}), 200
624
+ except Exception as e:
625
+ log.exception("finalize_detections error: %s", e)
626
+ return jsonify({"error": "internal", "detail": str(e)}), 500
627
+
628
+
629
+ # ---------- Clear session: delete all temporary files for a session ----------
630
+
 
 
631
 
632
  @app.route("/clear_session", methods=["POST"])
633
  def clear_session():
634
+ """
635
+ Body JSON: { "session_id": "", "uid": "" }
636
+ Deletes all detected//* blobs where metadata.session_id == session_id and metadata.tmp == "true".
637
+ """
638
+ try:
639
+ body = request.get_json(force=True)
640
+ except Exception:
641
+ return jsonify({"error": "invalid json"}), 400
642
+
643
+ session_id = (body.get("session_id") or request.args.get("session_id") or "").strip()
644
+ uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
645
+
646
+ if not session_id:
647
+ return jsonify({"error": "session_id required"}), 400
648
+
649
+ if not FIREBASE_ADMIN_JSON or not FIREBASE_ADMIN_AVAILABLE:
650
+ return jsonify({"error": "firebase admin not configured"}), 500
651
+
652
+ try:
653
+ init_firebase_admin_if_needed()
654
+ bucket = fb_storage.bucket()
655
+ except Exception as e:
656
+ log.exception("Firebase init error in clear_session: %s", e)
657
+ return jsonify({"error": "firebase admin init failed", "detail": str(e)}), 500
658
+
659
+ safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
660
+ prefix = f"detected/{safe_uid}/"
661
+
662
+ deleted = []
663
+ errors = []
664
+ try:
665
+ blobs = list(bucket.list_blobs(prefix=prefix))
666
+ for blob in blobs:
667
+ try:
668
+ md = blob.metadata or {}
669
+ if str(md.get("session_id", "")) == session_id and str(md.get("tmp", "")).lower() in ("true", "1", "yes"):
670
+ try:
671
+ blob.delete()
672
+ deleted.append(blob.name.split("/")[-1].rsplit(".", 1)[0])
673
+ except Exception as de:
674
+ errors.append({"blob": blob.name, "error": str(de)})
675
+ except Exception as e:
676
+ errors.append({"blob": getattr(blob, "name", None), "error": str(e)})
677
+ return jsonify({"ok": True, "deleted": deleted, "errors": errors}), 200
678
+ except Exception as e:
679
+ log.exception("clear_session error: %s", e)
680
+ return jsonify({"error": "internal", "detail": str(e)}), 500
681
+
682
+
683
+ if __name__ == "__main__":
684
+ port = int(os.getenv("PORT", 7860))
685
+ log.info("Starting server on 0.0.0.0:%d", port)
686
+ app.run(host="0.0.0.0", port=port, debug=True)