Pepguy commited on
Commit
0900715
·
verified ·
1 Parent(s): 6deee40

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +279 -212
app.py CHANGED
@@ -2,13 +2,10 @@
2
  import os
3
  import io
4
  import json
5
- from io import BytesIO
6
-
7
  import base64
8
  import logging
9
  import uuid
10
  import time
11
- import re
12
  from typing import List, Dict, Any, Tuple, Optional
13
 
14
  from flask import Flask, request, jsonify
@@ -18,8 +15,12 @@ import numpy as np
18
  import cv2
19
 
20
  # genai client
21
- from google import genai
22
- from google.genai import types
 
 
 
 
23
 
24
  # Firebase Admin (in-memory JSON init)
25
  try:
@@ -35,11 +36,17 @@ except Exception:
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()
@@ -51,8 +58,8 @@ if FIREBASE_ADMIN_JSON and not FIREBASE_ADMIN_AVAILABLE:
51
  app = Flask(__name__)
52
  CORS(app)
53
 
54
- # ---------- Category mapping (must match frontend) ----------
55
- CATEGORIES = [
56
  "Heels",
57
  "Sneakers",
58
  "Loafers",
@@ -66,96 +73,8 @@ CATEGORIES = [
66
  "Coat",
67
  "Shorts",
68
  ]
69
-
70
- SYNONYMS: Dict[str, str] = {
71
- "heel": "Heels", "heels": "Heels",
72
- "sneaker": "Sneakers", "sneakers": "Sneakers", "trainer": "Sneakers", "trainers": "Sneakers",
73
- "loafer": "Loafers", "loafers": "Loafers",
74
- "boot": "Boots", "boots": "Boots",
75
- "dress": "Dress", "gown": "Dress",
76
- "jean": "Jeans", "jeans": "Jeans", "denim": "Jeans",
77
- "skirt": "Skirt",
78
- "jacket": "Jacket",
79
- "coat": "Coat",
80
- "blazer": "Blazer",
81
- "t-shirt": "T-Shirt", "t shirt": "T-Shirt", "tee": "T-Shirt", "shirt": "T-Shirt", "top": "T-Shirt",
82
- "short": "Shorts", "shorts": "Shorts",
83
- "shoe": "Sneakers", "shoes": "Sneakers",
84
- "sandal": "Heels", "sandals": "Heels",
85
- }
86
-
87
- def normalize_text(s: str) -> str:
88
- return re.sub(r'[^a-z0-9\s\-]', ' ', s.lower()).strip()
89
-
90
- def choose_category_from_candidates(*candidates: Optional[str], tags: Optional[List[str]] = None) -> str:
91
- if tags:
92
- for t in tags:
93
- if not t: continue
94
- tok = normalize_text(str(t))
95
- if tok in SYNONYMS:
96
- return SYNONYMS[tok]
97
- for key, cat in SYNONYMS.items():
98
- if key in tok:
99
- return cat
100
- for cat in CATEGORIES:
101
- if tok == cat.lower() or cat.lower() in tok:
102
- return cat
103
- for c in candidates:
104
- if not c: continue
105
- s = normalize_text(str(c))
106
- for cat in CATEGORIES:
107
- if s == cat.lower() or cat.lower() in s:
108
- return cat
109
- words = s.split()
110
- for w in words:
111
- if w in SYNONYMS:
112
- return SYNONYMS[w]
113
- for key, cat in SYNONYMS.items():
114
- if key in s:
115
- return cat
116
- return "T-Shirt"
117
-
118
- # ---------- New: ask Gemini to pick EXACT allowed category ----------
119
- def pick_allowed_category(preferred_text: Optional[str], label_text: Optional[str], tags: Optional[List[str]] = None) -> str:
120
- """
121
- Try to get Gemini to return exactly one category string from CATEGORIES.
122
- If client not available or call fails or the returned value isn't an exact match, fallback to local chooser.
123
- """
124
- candidate = preferred_text or label_text or ""
125
- # build short instruction
126
- if client:
127
- try:
128
- # prompt: return exactly one of the categories listed, nothing else (no punctuation)
129
- prompt = (
130
- "You are given a short description of a clothing item. "
131
- "From the following list choose the single best category that matches the item. "
132
- "Return ONLY the category name exactly as shown (case-sensitive match is not required):\n\n"
133
- f"{', '.join(CATEGORIES)}\n\n"
134
- f"Item description: {candidate}\n\n"
135
- "Output exactly one of the category names above (no JSON, no explanation)."
136
- )
137
- contents = [types.Content(role="user", parts=[types.Part.from_text(text=prompt)])]
138
- # prefer to ask model to respond with a single string; we won't rely on strict schema formatting,
139
- # but we'll attempt to validate the returned string.
140
- cfg = types.GenerateContentConfig(response_mime_type="text/plain")
141
- resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents, config=cfg)
142
- raw = (resp.text or "").strip()
143
- # strip quotes if present
144
- candidate_out = raw.strip().strip('"').strip("'").strip()
145
- # check candidate_out against allowed categories (case-insensitive)
146
- for cat in CATEGORIES:
147
- if candidate_out.lower() == cat.lower():
148
- return cat
149
- # sometimes model returns JSON or extra text; try to extract any allowed category substring
150
- low = candidate_out.lower()
151
- for cat in CATEGORIES:
152
- if cat.lower() in low:
153
- return cat
154
- # if not matched, fallback to local matching
155
- except Exception as e:
156
- log.warning("pick_allowed_category Gemini call failed: %s", e)
157
- # Gemini not available or didn't return a valid match -> fallback
158
- return choose_category_from_candidates(preferred_text, label_text, tags=tags)
159
 
160
  # ---------- Firebase init helpers ----------
161
  _firebase_app = None
@@ -187,20 +106,28 @@ def init_firebase_admin_if_needed():
187
  raise
188
 
189
  def upload_b64_to_firebase(base64_str: str, path: str, content_type="image/jpeg", metadata: dict = None) -> str:
 
 
 
 
 
190
  if not FIREBASE_ADMIN_JSON:
191
  raise RuntimeError("FIREBASE_ADMIN_JSON not set")
192
  init_firebase_admin_if_needed()
193
  if not FIREBASE_ADMIN_AVAILABLE:
194
  raise RuntimeError("firebase-admin not available")
 
195
  raw = base64_str
196
  if raw.startswith("data:"):
197
  raw = raw.split(",", 1)[1]
198
  raw = raw.replace("\n", "").replace("\r", "")
199
  data = base64.b64decode(raw)
 
200
  try:
201
  bucket = fb_storage.bucket()
202
  blob = bucket.blob(path)
203
  blob.upload_from_string(data, content_type=content_type)
 
204
  if metadata:
205
  try:
206
  blob.metadata = {k: (json.dumps(v) if not isinstance(v, str) else v) for k, v in metadata.items()}
@@ -218,46 +145,48 @@ def upload_b64_to_firebase(base64_str: str, path: str, content_type="image/jpeg"
218
  raise
219
 
220
  # ---------- Image helpers (with EXIF transpose) ----------
221
-
222
- # Replace existing read_image_bytes and crop_and_b64 with this block
223
-
224
  def read_image_bytes(file_storage) -> Tuple[np.ndarray, int, int, bytes]:
225
  """
226
- Read bytes, apply EXIF orientation, return BGR numpy, width, height and re-encoded JPEG bytes.
227
- This ensures the bytes we pass to Gemini / upload to storage are physically upright
228
- (EXIF orientation is applied and not left in metadata).
229
  """
230
  data = file_storage.read()
 
 
 
 
231
  try:
232
  img = Image.open(io.BytesIO(data))
233
  except Exception as e:
234
- # fallback: try to decode raw bytes via OpenCV
235
- try:
236
- arr_np = np.frombuffer(data, np.uint8)
237
- cv_img = cv2.imdecode(arr_np, cv2.IMREAD_COLOR)
238
- if cv_img is None:
239
- raise
240
- h, w = cv_img.shape[:2]
241
- # re-encode to jpeg bytes to have consistent format
242
- _, jpeg = cv2.imencode(".jpg", cv_img, [int(cv2.IMWRITE_JPEG_QUALITY), 92])
243
- return cv_img, w, h, jpeg.tobytes()
244
- except Exception as ee:
245
- raise
246
-
247
- # physically apply EXIF rotation if present
248
  try:
249
- img = ImageOps.exif_transpose(img)
 
 
 
 
250
  except Exception:
251
- # ignore failures here; proceed with original image
252
- pass
253
 
254
- # ensure RGB and get size
 
 
 
 
 
 
255
  img = img.convert("RGB")
256
  w, h = img.size
257
-
258
- # re-encode to JPEG bytes to strip EXIF orientation tag (important!)
259
- buf = BytesIO()
260
- # We intentionally omit any EXIF bytes when saving so orientation is cleared.
261
  img.save(buf, format="JPEG", quality=92, optimize=True)
262
  jpeg_bytes = buf.getvalue()
263
 
@@ -265,11 +194,7 @@ def read_image_bytes(file_storage) -> Tuple[np.ndarray, int, int, bytes]:
265
  arr = np.array(img)[:, :, ::-1] # RGB -> BGR
266
  return arr, w, h, jpeg_bytes
267
 
268
-
269
  def crop_and_b64(bgr_img: np.ndarray, x: int, y: int, w: int, h: int, max_side=512) -> str:
270
- """
271
- Crop from BGR image (already upright), optionally resize, encode as JPEG and return base64 string.
272
- """
273
  h_img, w_img = bgr_img.shape[:2]
274
  x = max(0, int(x)); y = max(0, int(y))
275
  x2 = min(w_img, int(x + w)); y2 = min(h_img, int(y + h))
@@ -281,7 +206,6 @@ def crop_and_b64(bgr_img: np.ndarray, x: int, y: int, w: int, h: int, max_side=5
281
  if max_dim > max_side:
282
  scale = max_side / max_dim
283
  crop = cv2.resize(crop, (int(crop.shape[1] * scale), int(crop.shape[0] * scale)), interpolation=cv2.INTER_AREA)
284
- # encode to JPEG (this will be upright because bgr_img was exif_transposed)
285
  _, jpeg = cv2.imencode(".jpg", crop, [int(cv2.IMWRITE_JPEG_QUALITY), 82])
286
  return base64.b64encode(jpeg.tobytes()).decode("ascii")
287
 
@@ -335,23 +259,27 @@ def fallback_contour_crops(bgr_img, max_items=8) -> List[Dict[str, Any]]:
335
  })
336
  return items
337
 
338
- # ---------- AI analysis helper (unchanged) ----------
339
  def analyze_crop_with_gemini(jpeg_b64: str) -> Dict[str, Any]:
340
- if not client:
 
 
 
 
 
341
  return {"type": "unknown", "summary": "", "brand": "", "tags": []}
342
  try:
343
  prompt = (
344
  "You are an assistant that identifies clothing item characteristics from an image. "
345
  "Return only a JSON object with keys: type (single word like 'shoe','top','jacket'), "
346
  "summary (a single short sentence, one line), brand (brand name if visible else empty string), "
347
- "tags (an array of short single-word tags describing visible attributes). "
348
- "Keep values short and concise."
349
  )
350
- contents = [
351
- types.Content(role="user", parts=[types.Part.from_text(text=prompt)])
352
- ]
353
  image_bytes = base64.b64decode(jpeg_b64)
354
  contents.append(types.Content(role="user", parts=[types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")]))
 
355
  schema = {
356
  "type": "object",
357
  "properties": {
@@ -368,16 +296,17 @@ def analyze_crop_with_gemini(jpeg_b64: str) -> Dict[str, Any]:
368
  parsed = {}
369
  try:
370
  parsed = json.loads(text)
371
- parsed["type"] = str(parsed.get("type", "")).strip()
372
- parsed["summary"] = str(parsed.get("summary", "")).strip()
373
- parsed["brand"] = str(parsed.get("brand", "")).strip()
374
- tags = parsed.get("tags", [])
375
- if not isinstance(tags, list):
376
- tags = []
377
- parsed["tags"] = [str(t).strip() for t in tags if str(t).strip()]
378
  except Exception as e:
379
  log.warning("Failed parsing Gemini analysis JSON: %s — raw: %s", e, (text[:300] if text else ""))
380
  parsed = {"type": "unknown", "summary": "", "brand": "", "tags": []}
 
 
 
 
 
 
 
 
381
  return {
382
  "type": parsed.get("type", "unknown") or "unknown",
383
  "summary": parsed.get("summary", "") or "",
@@ -388,31 +317,109 @@ def analyze_crop_with_gemini(jpeg_b64: str) -> Dict[str, Any]:
388
  log.exception("analyze_crop_with_gemini failure: %s", e)
389
  return {"type": "unknown", "summary": "", "brand": "", "tags": []}
390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  # ---------- Main / processing ----------
392
  @app.route("/process", methods=["POST"])
393
  def process_image():
394
  if "photo" not in request.files:
395
  return jsonify({"error": "missing photo"}), 400
396
  file = request.files["photo"]
 
397
  uid = (request.form.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
 
398
  try:
399
- bgr_img, img_w, img_h, raw_bytes = read_image_bytes(file)
 
400
  except Exception as e:
401
  log.error("invalid image: %s", e)
402
  return jsonify({"error": "invalid image"}), 400
 
403
  session_id = str(uuid.uuid4())
 
 
404
  user_prompt = (
405
  "You are an assistant that extracts clothing detections from a single image. "
406
  "Return a JSON object with a single key 'items' which is an array. Each item must have: "
407
  "label (string, short like 'top','skirt','sneakers'), "
408
  "bbox with normalized coordinates between 0 and 1: {x, y, w, h} where x,y are top-left relative to width/height, "
409
- "confidence (0-1). Output ONLY valid JSON."
 
410
  )
 
411
  try:
412
  contents = [
413
- types.Content(role="user", parts=[types.Part.from_text(text=user_prompt)])
414
  ]
415
- contents.append(types.Content(role="user", parts=[types.Part.from_bytes(data=raw_bytes, mime_type="image/jpeg")]))
 
 
 
416
  schema = {
417
  "type": "object",
418
  "properties": {
@@ -424,7 +431,12 @@ def process_image():
424
  "label": {"type": "string"},
425
  "bbox": {
426
  "type": "object",
427
- "properties": {"x": {"type": "number"}, "y": {"type": "number"}, "w": {"type": "number"}, "h": {"type": "number"}},
 
 
 
 
 
428
  "required": ["x","y","w","h"]
429
  },
430
  "confidence": {"type": "number"}
@@ -435,47 +447,81 @@ def process_image():
435
  },
436
  "required": ["items"]
437
  }
438
- cfg = types.GenerateContentConfig(response_mime_type="application/json", response_schema=schema)
439
- log.info("Calling Gemini model for detection (gemini-2.5-flash-lite)...")
440
- model_resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents, config=cfg) if client else None
441
- raw_text = (model_resp.text or "") if model_resp else ""
442
- log.info("Gemini raw response length: %d", len(raw_text))
 
 
 
 
 
 
 
 
443
  parsed = None
444
  try:
445
  parsed = json.loads(raw_text) if raw_text else None
446
  except Exception as e:
447
  log.warning("Could not parse Gemini JSON: %s", e)
448
  parsed = None
 
449
  items_out: List[Dict[str, Any]] = []
450
  if parsed and isinstance(parsed.get("items"), list) and len(parsed["items"])>0:
451
  for it in parsed["items"]:
452
  try:
453
- label = str(it.get("label","unknown"))[:48]
454
  bbox = it.get("bbox",{})
455
- nx = float(bbox.get("x",0)); ny = float(bbox.get("y",0)); nw = float(bbox.get("w",0)); nh = float(bbox.get("h",0))
 
 
 
456
  nx = max(0.0, min(1.0, nx)); ny = max(0.0,min(1.0,ny))
457
  nw = max(0.0, min(1.0, nw)); nh = max(0.0, min(1.0, nh))
458
  px = int(nx * img_w); py = int(ny * img_h)
459
  pw = int(nw * img_w); ph = int(nh * img_h)
460
  if pw <= 8 or ph <= 8:
461
  continue
462
- b64 = crop_and_b64(bgr_img, px, py, pw, ph)
463
- if not b64:
464
  continue
465
- items_out.append({
466
- "id": str(uuid.uuid4()),
467
- "label": label,
 
 
 
 
 
 
 
 
 
468
  "confidence": float(it.get("confidence", 0.5)),
469
  "bbox": {"x": px, "y": py, "w": pw, "h": ph},
470
- "thumbnail_b64": b64,
 
471
  "source": "gemini"
472
- })
 
473
  except Exception as e:
474
  log.warning("skipping item due to error: %s", e)
475
  else:
476
  log.info("Gemini returned no items or parse failed — using fallback contour crops.")
477
  items_out = fallback_contour_crops(bgr_img, max_items=8)
478
- # AI analysis & upload
 
 
 
 
 
 
 
 
 
 
 
479
  if FIREBASE_ADMIN_JSON and FIREBASE_ADMIN_AVAILABLE:
480
  try:
481
  init_firebase_admin_if_needed()
@@ -483,20 +529,12 @@ def process_image():
483
  except Exception as e:
484
  log.exception("Firebase admin init for upload failed: %s", e)
485
  bucket = None
 
486
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
487
  for itm in items_out:
488
  b64 = itm.get("thumbnail_b64")
489
  if not b64:
490
  continue
491
- try:
492
- analysis = analyze_crop_with_gemini(b64) if client else {"type":"unknown","summary":"","brand":"","tags":[]}
493
- except Exception as ae:
494
- log.warning("analysis failed: %s", ae)
495
- analysis = {"type":"unknown","summary":"","brand":"","tags":[]}
496
- itm["analysis"] = analysis
497
- # pick allowed category (this is the important change: we ask Gemini to pick allowed category then fallback)
498
- title = pick_allowed_category(analysis.get("type",""), itm.get("label",""), tags=analysis.get("tags", []))
499
- itm["title"] = title
500
  item_id = itm.get("id") or str(uuid.uuid4())
501
  path = f"detected/{safe_uid}/{item_id}.jpg"
502
  try:
@@ -505,68 +543,91 @@ def process_image():
505
  "session_id": session_id,
506
  "uploaded_by": safe_uid,
507
  "uploaded_at": str(int(time.time())),
508
- "ai_type": analysis.get("type",""),
509
- "ai_brand": analysis.get("brand",""),
510
- "ai_summary": analysis.get("summary",""),
511
- "ai_tags": json.dumps(analysis.get("tags", [])),
512
- "title": title,
 
513
  }
514
  url = upload_b64_to_firebase(b64, path, content_type="image/jpeg", metadata=metadata)
515
  itm["thumbnail_url"] = url
516
  itm["thumbnail_path"] = path
 
517
  itm.pop("thumbnail_b64", None)
518
  itm["_session_id"] = session_id
519
- log.debug("Auto-uploaded thumbnail for %s -> %s (session=%s) title=%s", item_id, url, session_id, title)
 
 
520
  except Exception as up_e:
521
  log.warning("Auto-upload failed for %s: %s", item_id, up_e)
 
522
  else:
523
  if not FIREBASE_ADMIN_JSON:
524
  log.info("FIREBASE_ADMIN_JSON not set; skipping server-side thumbnail upload.")
525
  else:
526
  log.info("Firebase admin SDK not available; skipping server-side thumbnail upload.")
527
- # ensure a title exists for frontend even if no upload
528
- for itm in items_out:
529
- if "title" not in itm:
530
- analysis = itm.get("analysis") or {"type":"unknown","tags":[]}
531
- itm["title"] = pick_allowed_category(analysis.get("type",""), itm.get("label",""), tags=analysis.get("tags", []))
532
  return jsonify({"ok": True, "items": items_out, "session_id": session_id, "debug": {"raw_model_text": (raw_text or "")[:1600]}}), 200
 
533
  except Exception as ex:
534
  log.exception("Processing error: %s", ex)
535
  try:
536
  items_out = fallback_contour_crops(bgr_img, max_items=8)
537
  for itm in items_out:
538
- if "title" not in itm:
539
- itm["title"] = choose_category_from_candidates(itm.get("label","unknown"))
540
  return jsonify({"ok": True, "items": items_out, "session_id": session_id, "debug": {"error": str(ex)}}), 200
541
  except Exception as e2:
542
  log.exception("Fallback also failed: %s", e2)
543
  return jsonify({"error": "internal failure", "detail": str(e2)}), 500
544
 
545
- # ---------- Finalize endpoint ----------
546
  @app.route("/finalize_detections", methods=["POST"])
547
  def finalize_detections():
 
 
 
 
 
 
 
 
 
 
 
 
 
548
  try:
549
  body = request.get_json(force=True)
550
  except Exception:
551
  return jsonify({"error": "invalid json"}), 400
 
552
  uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
553
  keep_ids = set(body.get("keep_ids") or [])
554
  session_id = (body.get("session_id") or request.args.get("session_id") or "").strip()
 
555
  if not session_id:
556
  return jsonify({"error": "session_id required for finalize to avoid unsafe deletes"}), 400
 
557
  if not FIREBASE_ADMIN_JSON or not FIREBASE_ADMIN_AVAILABLE:
558
  return jsonify({"error": "firebase admin not configured"}), 500
 
559
  try:
560
  init_firebase_admin_if_needed()
561
  bucket = fb_storage.bucket()
562
  except Exception as e:
563
  log.exception("Firebase init error in finalize: %s", e)
564
  return jsonify({"error": "firebase admin init failed", "detail": str(e)}), 500
 
565
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
566
  prefix = f"detected/{safe_uid}/"
 
567
  kept = []
568
  deleted = []
569
  errors = []
 
570
  try:
571
  blobs = list(bucket.list_blobs(prefix=prefix))
572
  for blob in blobs:
@@ -576,46 +637,42 @@ def finalize_detections():
576
  if "." not in fname:
577
  continue
578
  item_id = fname.rsplit(".", 1)[0]
 
579
  md = blob.metadata or {}
 
580
  if str(md.get("session_id", "")) != session_id or str(md.get("tmp", "")).lower() not in ("true", "1", "yes"):
581
  continue
 
582
  if item_id in keep_ids:
583
  try:
584
  blob.make_public()
585
  url = blob.public_url
586
  except Exception:
587
  url = f"gs://{bucket.name}/{name}"
 
588
  ai_type = md.get("ai_type") or ""
589
  ai_brand = md.get("ai_brand") or ""
590
  ai_summary = md.get("ai_summary") or ""
591
  ai_tags_raw = md.get("ai_tags") or "[]"
592
- title_meta = md.get("title") or ""
593
  try:
594
  ai_tags = json.loads(ai_tags_raw) if isinstance(ai_tags_raw, str) else ai_tags_raw
595
  except Exception:
596
  ai_tags = []
597
- title = None
598
- if title_meta:
599
- try:
600
- title = json.loads(title_meta) if (title_meta.startswith('[') or title_meta.startswith('{')) else str(title_meta)
601
- except Exception:
602
- title = str(title_meta)
603
- # validate title: if not in allowed set, derive from AI fields
604
- valid = False
605
- if isinstance(title, str) and title.strip():
606
- for cat in CATEGORIES:
607
- if title.strip().lower() == cat.lower():
608
- title = cat
609
- valid = True
610
- break
611
- if not valid:
612
- title = choose_category_from_candidates(ai_type, ai_summary, tags=ai_tags)
613
  kept.append({
614
  "id": item_id,
615
  "thumbnail_url": url,
616
  "thumbnail_path": name,
617
- "analysis": {"type": ai_type, "brand": ai_brand, "summary": ai_summary, "tags": ai_tags},
618
- "title": title
 
 
 
 
 
 
619
  })
620
  else:
621
  try:
@@ -630,27 +687,37 @@ def finalize_detections():
630
  log.exception("finalize_detections error: %s", e)
631
  return jsonify({"error": "internal", "detail": str(e)}), 500
632
 
633
- # ---------- Clear session ----------
634
  @app.route("/clear_session", methods=["POST"])
635
  def clear_session():
 
 
 
 
636
  try:
637
  body = request.get_json(force=True)
638
  except Exception:
639
  return jsonify({"error": "invalid json"}), 400
 
640
  session_id = (body.get("session_id") or request.args.get("session_id") or "").strip()
641
  uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
 
642
  if not session_id:
643
  return jsonify({"error": "session_id required"}), 400
 
644
  if not FIREBASE_ADMIN_JSON or not FIREBASE_ADMIN_AVAILABLE:
645
  return jsonify({"error": "firebase admin not configured"}), 500
 
646
  try:
647
  init_firebase_admin_if_needed()
648
  bucket = fb_storage.bucket()
649
  except Exception as e:
650
  log.exception("Firebase init error in clear_session: %s", e)
651
  return jsonify({"error": "firebase admin init failed", "detail": str(e)}), 500
 
652
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
653
  prefix = f"detected/{safe_uid}/"
 
654
  deleted = []
655
  errors = []
656
  try:
 
2
  import os
3
  import io
4
  import json
 
 
5
  import base64
6
  import logging
7
  import uuid
8
  import time
 
9
  from typing import List, Dict, Any, Tuple, Optional
10
 
11
  from flask import Flask, request, jsonify
 
15
  import cv2
16
 
17
  # genai client
18
+ try:
19
+ from google import genai
20
+ from google.genai import types
21
+ except Exception:
22
+ genai = None
23
+ types = None
24
 
25
  # Firebase Admin (in-memory JSON init)
26
  try:
 
36
  logging.basicConfig(level=logging.INFO)
37
  log = logging.getLogger("wardrobe-server")
38
 
39
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
40
+ if GEMINI_API_KEY and genai:
41
+ try:
42
+ client = genai.Client(api_key=GEMINI_API_KEY)
43
+ except Exception as e:
44
+ log.exception("Failed to init genai client: %s", e)
45
+ client = None
46
+ else:
47
+ client = None
48
+ if not GEMINI_API_KEY:
49
+ log.info("GEMINI_API_KEY not set; model calls disabled.")
50
 
51
  # Firebase config (read service account JSON from env)
52
  FIREBASE_ADMIN_JSON = os.getenv("FIREBASE_ADMIN_JSON", "").strip()
 
58
  app = Flask(__name__)
59
  CORS(app)
60
 
61
+ # ---------- Category options (must match frontend) ----------
62
+ CATEGORY_OPTIONS = [
63
  "Heels",
64
  "Sneakers",
65
  "Loafers",
 
73
  "Coat",
74
  "Shorts",
75
  ]
76
+ # normalized set for quick match
77
+ _CATEGORY_RENORM = [c.lower() for c in CATEGORY_OPTIONS]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  # ---------- Firebase init helpers ----------
80
  _firebase_app = None
 
106
  raise
107
 
108
  def upload_b64_to_firebase(base64_str: str, path: str, content_type="image/jpeg", metadata: dict = None) -> str:
109
+ """
110
+ Upload base64 string to Firebase Storage at `path`.
111
+ Optionally attach metadata dict (custom metadata).
112
+ Returns a public URL when possible, otherwise returns gs://<bucket>/<path>.
113
+ """
114
  if not FIREBASE_ADMIN_JSON:
115
  raise RuntimeError("FIREBASE_ADMIN_JSON not set")
116
  init_firebase_admin_if_needed()
117
  if not FIREBASE_ADMIN_AVAILABLE:
118
  raise RuntimeError("firebase-admin not available")
119
+
120
  raw = base64_str
121
  if raw.startswith("data:"):
122
  raw = raw.split(",", 1)[1]
123
  raw = raw.replace("\n", "").replace("\r", "")
124
  data = base64.b64decode(raw)
125
+
126
  try:
127
  bucket = fb_storage.bucket()
128
  blob = bucket.blob(path)
129
  blob.upload_from_string(data, content_type=content_type)
130
+ # attach metadata if provided (values must be strings)
131
  if metadata:
132
  try:
133
  blob.metadata = {k: (json.dumps(v) if not isinstance(v, str) else v) for k, v in metadata.items()}
 
145
  raise
146
 
147
  # ---------- Image helpers (with EXIF transpose) ----------
 
 
 
148
  def read_image_bytes(file_storage) -> Tuple[np.ndarray, int, int, bytes]:
149
  """
150
+ Read uploaded bytes, apply EXIF orientation via PIL.ImageOps.exif_transpose,
151
+ re-encode to JPEG bytes (EXIF cleared), and return (bgr_numpy, width, height, jpeg_bytes).
 
152
  """
153
  data = file_storage.read()
154
+ if not data:
155
+ raise ValueError("No image data uploaded")
156
+
157
+ # Try opening with PIL to read EXIF and apply transpose
158
  try:
159
  img = Image.open(io.BytesIO(data))
160
  except Exception as e:
161
+ log.warning("PIL failed to open image; falling back to OpenCV decode: %s", e)
162
+ arr_np = np.frombuffer(data, np.uint8)
163
+ cv_img = cv2.imdecode(arr_np, cv2.IMREAD_COLOR)
164
+ if cv_img is None:
165
+ raise RuntimeError("Could not decode uploaded image")
166
+ h, w = cv_img.shape[:2]
167
+ _, jpeg = cv2.imencode(".jpg", cv_img, [int(cv2.IMWRITE_JPEG_QUALITY), 92])
168
+ return cv_img, w, h, jpeg.tobytes()
169
+
170
+ # log original EXIF orientation when present
 
 
 
 
171
  try:
172
+ exif = img._getexif() or {}
173
+ orientation = None
174
+ if isinstance(exif, dict):
175
+ orientation = exif.get(274) # tag 274 orientation
176
+ log.debug("Original EXIF orientation: %s", orientation)
177
  except Exception:
178
+ orientation = None
 
179
 
180
+ # physically apply EXIF rotation (so image pixels are upright)
181
+ try:
182
+ img = ImageOps.exif_transpose(img)
183
+ except Exception as e:
184
+ log.warning("exif_transpose failed: %s", e)
185
+
186
+ # ensure RGB, then re-encode to JPEG to remove orientation tag from bytes
187
  img = img.convert("RGB")
188
  w, h = img.size
189
+ buf = io.BytesIO()
 
 
 
190
  img.save(buf, format="JPEG", quality=92, optimize=True)
191
  jpeg_bytes = buf.getvalue()
192
 
 
194
  arr = np.array(img)[:, :, ::-1] # RGB -> BGR
195
  return arr, w, h, jpeg_bytes
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)); y = max(0, int(y))
200
  x2 = min(w_img, int(x + w)); y2 = min(h_img, int(y + h))
 
206
  if max_dim > max_side:
207
  scale = max_side / max_dim
208
  crop = cv2.resize(crop, (int(crop.shape[1] * scale), int(crop.shape[0] * scale)), interpolation=cv2.INTER_AREA)
 
209
  _, jpeg = cv2.imencode(".jpg", crop, [int(cv2.IMWRITE_JPEG_QUALITY), 82])
210
  return base64.b64encode(jpeg.tobytes()).decode("ascii")
211
 
 
259
  })
260
  return items
261
 
262
+ # ---------- AI analysis helper ----------
263
  def analyze_crop_with_gemini(jpeg_b64: str) -> Dict[str, Any]:
264
+ """
265
+ Run Gemini on the cropped image bytes to extract:
266
+ type, summary, brand, tags
267
+ Returns dict, falls back to defaults on error.
268
+ """
269
+ if not client or not types:
270
  return {"type": "unknown", "summary": "", "brand": "", "tags": []}
271
  try:
272
  prompt = (
273
  "You are an assistant that identifies clothing item characteristics from an image. "
274
  "Return only a JSON object with keys: type (single word like 'shoe','top','jacket'), "
275
  "summary (a single short sentence, one line), brand (brand name if visible else empty string), "
276
+ "tags (an array of short single-word tags). Keep values short and concise."
 
277
  )
278
+
279
+ contents = [types.Content(role="user", parts=[types.Part.from_text(text=prompt)])]
 
280
  image_bytes = base64.b64decode(jpeg_b64)
281
  contents.append(types.Content(role="user", parts=[types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")]))
282
+
283
  schema = {
284
  "type": "object",
285
  "properties": {
 
296
  parsed = {}
297
  try:
298
  parsed = json.loads(text)
 
 
 
 
 
 
 
299
  except Exception as e:
300
  log.warning("Failed parsing Gemini analysis JSON: %s — raw: %s", e, (text[:300] if text else ""))
301
  parsed = {"type": "unknown", "summary": "", "brand": "", "tags": []}
302
+ # coerce
303
+ parsed["type"] = str(parsed.get("type","") or "").strip()
304
+ parsed["summary"] = str(parsed.get("summary","") or "").strip()
305
+ parsed["brand"] = str(parsed.get("brand","") or "").strip()
306
+ tags = parsed.get("tags", [])
307
+ if not isinstance(tags, list):
308
+ tags = []
309
+ parsed["tags"] = [str(t).strip() for t in tags if str(t).strip()]
310
  return {
311
  "type": parsed.get("type", "unknown") or "unknown",
312
  "summary": parsed.get("summary", "") or "",
 
317
  log.exception("analyze_crop_with_gemini failure: %s", e)
318
  return {"type": "unknown", "summary": "", "brand": "", "tags": []}
319
 
320
+ # ---------- Title mapping helper ----------
321
+ def choose_title_from_label_and_analysis(label: str, analysis: Dict[str, Any]) -> str:
322
+ """
323
+ Return a title that is guaranteed to be one of CATEGORY_OPTIONS.
324
+ Heuristics:
325
+ - check analysis.type
326
+ - check analysis.tags
327
+ - check label text
328
+ - fallback to 'T-Shirt'
329
+ """
330
+ def find_match_in_text(txt: str) -> Optional[str]:
331
+ if not txt:
332
+ return None
333
+ s = txt.lower()
334
+ # quick synonyms mapping
335
+ synonyms = {
336
+ "tshirt": "T-Shirt", "t-shirt": "T-Shirt", "tee": "T-Shirt",
337
+ "sneaker": "Sneakers", "trainers": "Sneakers",
338
+ "jeans": "Jeans", "denim": "Jeans",
339
+ "dress": "Dress",
340
+ "skirt": "Skirt",
341
+ "jacket": "Jacket",
342
+ "coat": "Coat",
343
+ "blazer": "Blazer",
344
+ "boot": "Boots",
345
+ "heel": "Heels",
346
+ "loafer": "Loafers",
347
+ "short": "Shorts",
348
+ "shoe": "Sneakers", # generic shoe -> put under Sneakers by default
349
+ "sneakers": "Sneakers",
350
+ }
351
+ for k, v in synonyms.items():
352
+ if k in s:
353
+ return v
354
+ # check direct category words
355
+ for idx, cat in enumerate(CATEGORY_OPTIONS):
356
+ if cat.lower().replace("-", "").replace(" ", "") in s.replace("-", "").replace(" ", ""):
357
+ return CATEGORY_OPTIONS[idx]
358
+ return None
359
+
360
+ # try analysis.type first
361
+ atype = (analysis.get("type") or "").strip()
362
+ match = find_match_in_text(atype)
363
+ if match:
364
+ return match
365
+
366
+ # try analysis.tags
367
+ tags = analysis.get("tags") or []
368
+ if isinstance(tags, list):
369
+ for t in tags:
370
+ m = find_match_in_text(t)
371
+ if m:
372
+ return m
373
+
374
+ # try label (raw detection label from detection model)
375
+ m = find_match_in_text(label or "")
376
+ if m:
377
+ return m
378
+
379
+ # try analysis.summary casual check
380
+ m = find_match_in_text(analysis.get("summary", "") or "")
381
+ if m:
382
+ return m
383
+
384
+ # fallback: prefer 'T-Shirt' as generic top fallback (guaranteed category)
385
+ return "T-Shirt"
386
+
387
  # ---------- Main / processing ----------
388
  @app.route("/process", methods=["POST"])
389
  def process_image():
390
  if "photo" not in request.files:
391
  return jsonify({"error": "missing photo"}), 400
392
  file = request.files["photo"]
393
+
394
  uid = (request.form.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
395
+
396
  try:
397
+ # read and get corrected jpeg bytes (EXIF transpose applied)
398
+ bgr_img, img_w, img_h, corrected_jpeg_bytes = read_image_bytes(file)
399
  except Exception as e:
400
  log.error("invalid image: %s", e)
401
  return jsonify({"error": "invalid image"}), 400
402
+
403
  session_id = str(uuid.uuid4())
404
+
405
+ # Detection prompt (Gemini expects the corrected image bytes)
406
  user_prompt = (
407
  "You are an assistant that extracts clothing detections from a single image. "
408
  "Return a JSON object with a single key 'items' which is an array. Each item must have: "
409
  "label (string, short like 'top','skirt','sneakers'), "
410
  "bbox with normalized coordinates between 0 and 1: {x, y, w, h} where x,y are top-left relative to width/height, "
411
+ "confidence (0-1). Example output: {\"items\":[{\"label\":\"top\",\"bbox\":{\"x\":0.1,\"y\":0.2,\"w\":0.3,\"h\":0.4},\"confidence\":0.95}]} "
412
+ "Output ONLY valid JSON. If you cannot detect any clothing confidently, return {\"items\":[]}."
413
  )
414
+
415
  try:
416
  contents = [
417
+ types.Content(role="user", parts=[types.Part.from_text(text=user_prompt)]) if types else None
418
  ]
419
+ # attach corrected jpeg bytes
420
+ if types:
421
+ contents.append(types.Content(role="user", parts=[types.Part.from_bytes(data=corrected_jpeg_bytes, mime_type="image/jpeg")]))
422
+
423
  schema = {
424
  "type": "object",
425
  "properties": {
 
431
  "label": {"type": "string"},
432
  "bbox": {
433
  "type": "object",
434
+ "properties": {
435
+ "x": {"type": "number"},
436
+ "y": {"type": "number"},
437
+ "w": {"type": "number"},
438
+ "h": {"type": "number"}
439
+ },
440
  "required": ["x","y","w","h"]
441
  },
442
  "confidence": {"type": "number"}
 
447
  },
448
  "required": ["items"]
449
  }
450
+
451
+ cfg = types.GenerateContentConfig(response_mime_type="application/json", response_schema=schema) if types else None
452
+
453
+ if client and types:
454
+ log.info("Calling Gemini model for detection (gemini-2.5-flash-lite)...")
455
+ model_resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents, config=cfg)
456
+ raw_text = model_resp.text or ""
457
+ else:
458
+ log.info("Gemini client not configured, skipping model detection — using fallback.")
459
+ raw_text = ""
460
+
461
+ log.info("Gemini raw response length: %d", len(raw_text) if raw_text else 0)
462
+
463
  parsed = None
464
  try:
465
  parsed = json.loads(raw_text) if raw_text else None
466
  except Exception as e:
467
  log.warning("Could not parse Gemini JSON: %s", e)
468
  parsed = None
469
+
470
  items_out: List[Dict[str, Any]] = []
471
  if parsed and isinstance(parsed.get("items"), list) and len(parsed["items"])>0:
472
  for it in parsed["items"]:
473
  try:
474
+ raw_label = str(it.get("label","unknown"))[:64]
475
  bbox = it.get("bbox",{})
476
+ nx = float(bbox.get("x",0))
477
+ ny = float(bbox.get("y",0))
478
+ nw = float(bbox.get("w",0))
479
+ nh = float(bbox.get("h",0))
480
  nx = max(0.0, min(1.0, nx)); ny = max(0.0,min(1.0,ny))
481
  nw = max(0.0, min(1.0, nw)); nh = max(0.0, min(1.0, nh))
482
  px = int(nx * img_w); py = int(ny * img_h)
483
  pw = int(nw * img_w); ph = int(nh * img_h)
484
  if pw <= 8 or ph <= 8:
485
  continue
486
+ crop_b64 = crop_and_b64(bgr_img, px, py, pw, ph)
487
+ if not crop_b64:
488
  continue
489
+
490
+ # analyze crop with Gemini (optional)
491
+ analysis = analyze_crop_with_gemini(crop_b64) if client else {"type":"unknown","summary":"","brand":"","tags":[]}
492
+
493
+ # choose title within CATEGORY_OPTIONS
494
+ title = choose_title_from_label_and_analysis(raw_label, analysis)
495
+
496
+ item_id = str(uuid.uuid4())
497
+ itm = {
498
+ "id": item_id,
499
+ "label": raw_label,
500
+ "title": title,
501
  "confidence": float(it.get("confidence", 0.5)),
502
  "bbox": {"x": px, "y": py, "w": pw, "h": ph},
503
+ "thumbnail_b64": crop_b64,
504
+ "analysis": analysis,
505
  "source": "gemini"
506
+ }
507
+ items_out.append(itm)
508
  except Exception as e:
509
  log.warning("skipping item due to error: %s", e)
510
  else:
511
  log.info("Gemini returned no items or parse failed — using fallback contour crops.")
512
  items_out = fallback_contour_crops(bgr_img, max_items=8)
513
+ # do analysis + title mapping for fallback crops
514
+ for itm in items_out:
515
+ try:
516
+ crop_b64 = itm.get("thumbnail_b64")
517
+ analysis = analyze_crop_with_gemini(crop_b64) if client else {"type":"unknown","summary":"","brand":"","tags":[]}
518
+ itm["analysis"] = analysis
519
+ itm["title"] = choose_title_from_label_and_analysis(itm.get("label","unknown"), analysis)
520
+ except Exception:
521
+ itm["analysis"] = {"type":"unknown","summary":"","brand":"","tags":[]}
522
+ itm["title"] = choose_title_from_label_and_analysis(itm.get("label","unknown"), itm["analysis"])
523
+
524
+ # Auto-upload thumbnails to Firebase Storage (temporary, marked by session_id)
525
  if FIREBASE_ADMIN_JSON and FIREBASE_ADMIN_AVAILABLE:
526
  try:
527
  init_firebase_admin_if_needed()
 
529
  except Exception as e:
530
  log.exception("Firebase admin init for upload failed: %s", e)
531
  bucket = None
532
+
533
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
534
  for itm in items_out:
535
  b64 = itm.get("thumbnail_b64")
536
  if not b64:
537
  continue
 
 
 
 
 
 
 
 
 
538
  item_id = itm.get("id") or str(uuid.uuid4())
539
  path = f"detected/{safe_uid}/{item_id}.jpg"
540
  try:
 
543
  "session_id": session_id,
544
  "uploaded_by": safe_uid,
545
  "uploaded_at": str(int(time.time())),
546
+ # AI fields
547
+ "ai_type": itm.get("analysis", {}).get("type", ""),
548
+ "ai_brand": itm.get("analysis", {}).get("brand", ""),
549
+ "ai_summary": itm.get("analysis", {}).get("summary", ""),
550
+ "ai_tags": json.dumps(itm.get("analysis", {}).get("tags", [])),
551
+ "ai_title": itm.get("title", "")
552
  }
553
  url = upload_b64_to_firebase(b64, path, content_type="image/jpeg", metadata=metadata)
554
  itm["thumbnail_url"] = url
555
  itm["thumbnail_path"] = path
556
+ # remove raw base64 to keep response small
557
  itm.pop("thumbnail_b64", None)
558
  itm["_session_id"] = session_id
559
+ # annotate uploaded_at (unix)
560
+ itm["uploaded_at"] = int(time.time())
561
+ log.debug("Auto-uploaded thumbnail for %s -> %s (session=%s)", item_id, url, session_id)
562
  except Exception as up_e:
563
  log.warning("Auto-upload failed for %s: %s", item_id, up_e)
564
+ # keep thumbnail_b64 as fallback
565
  else:
566
  if not FIREBASE_ADMIN_JSON:
567
  log.info("FIREBASE_ADMIN_JSON not set; skipping server-side thumbnail upload.")
568
  else:
569
  log.info("Firebase admin SDK not available; skipping server-side thumbnail upload.")
570
+
571
+ # Final response: items contain id,title,confidence,bbox,thumbnail_url or thumbnail_b64,analysis,uploaded_at if available,source, _session_id
 
 
 
572
  return jsonify({"ok": True, "items": items_out, "session_id": session_id, "debug": {"raw_model_text": (raw_text or "")[:1600]}}), 200
573
+
574
  except Exception as ex:
575
  log.exception("Processing error: %s", ex)
576
  try:
577
  items_out = fallback_contour_crops(bgr_img, max_items=8)
578
  for itm in items_out:
579
+ itm["analysis"] = analyze_crop_with_gemini(itm.get("thumbnail_b64")) if client else {"type":"unknown","summary":"","brand":"","tags":[]}
580
+ itm["title"] = choose_title_from_label_and_analysis(itm.get("label","unknown"), itm["analysis"])
581
  return jsonify({"ok": True, "items": items_out, "session_id": session_id, "debug": {"error": str(ex)}}), 200
582
  except Exception as e2:
583
  log.exception("Fallback also failed: %s", e2)
584
  return jsonify({"error": "internal failure", "detail": str(e2)}), 500
585
 
586
+ # ---------- Finalize endpoint: keep selected and delete only session's temp files ----------
587
  @app.route("/finalize_detections", methods=["POST"])
588
  def finalize_detections():
589
+ """
590
+ Body JSON:
591
+ { "uid": "user123", "keep_ids": ["id1","id2",...], "session_id": "<session id from /process>" }
592
+
593
+ Server will delete only detected/<uid>/* files whose:
594
+ - metadata.tmp == "true"
595
+ - metadata.session_id == session_id
596
+ - item_id NOT in keep_ids
597
+
598
+ Returns:
599
+ { ok: True, kept: [...], deleted: [...], errors: [...] }
600
+ kept entries include id, thumbnail_url, thumbnail_path, analysis, title, uploaded_at
601
+ """
602
  try:
603
  body = request.get_json(force=True)
604
  except Exception:
605
  return jsonify({"error": "invalid json"}), 400
606
+
607
  uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
608
  keep_ids = set(body.get("keep_ids") or [])
609
  session_id = (body.get("session_id") or request.args.get("session_id") or "").strip()
610
+
611
  if not session_id:
612
  return jsonify({"error": "session_id required for finalize to avoid unsafe deletes"}), 400
613
+
614
  if not FIREBASE_ADMIN_JSON or not FIREBASE_ADMIN_AVAILABLE:
615
  return jsonify({"error": "firebase admin not configured"}), 500
616
+
617
  try:
618
  init_firebase_admin_if_needed()
619
  bucket = fb_storage.bucket()
620
  except Exception as e:
621
  log.exception("Firebase init error in finalize: %s", e)
622
  return jsonify({"error": "firebase admin init failed", "detail": str(e)}), 500
623
+
624
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
625
  prefix = f"detected/{safe_uid}/"
626
+
627
  kept = []
628
  deleted = []
629
  errors = []
630
+
631
  try:
632
  blobs = list(bucket.list_blobs(prefix=prefix))
633
  for blob in blobs:
 
637
  if "." not in fname:
638
  continue
639
  item_id = fname.rsplit(".", 1)[0]
640
+
641
  md = blob.metadata or {}
642
+ # only consider temporary files matching this session id
643
  if str(md.get("session_id", "")) != session_id or str(md.get("tmp", "")).lower() not in ("true", "1", "yes"):
644
  continue
645
+
646
  if item_id in keep_ids:
647
  try:
648
  blob.make_public()
649
  url = blob.public_url
650
  except Exception:
651
  url = f"gs://{bucket.name}/{name}"
652
+
653
  ai_type = md.get("ai_type") or ""
654
  ai_brand = md.get("ai_brand") or ""
655
  ai_summary = md.get("ai_summary") or ""
656
  ai_tags_raw = md.get("ai_tags") or "[]"
 
657
  try:
658
  ai_tags = json.loads(ai_tags_raw) if isinstance(ai_tags_raw, str) else ai_tags_raw
659
  except Exception:
660
  ai_tags = []
661
+ ai_title = md.get("ai_title") or ""
662
+ uploaded_at = md.get("uploaded_at") or None
663
+
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  kept.append({
665
  "id": item_id,
666
  "thumbnail_url": url,
667
  "thumbnail_path": name,
668
+ "analysis": {
669
+ "type": ai_type,
670
+ "brand": ai_brand,
671
+ "summary": ai_summary,
672
+ "tags": ai_tags
673
+ },
674
+ "title": ai_title or choose_title_from_label_and_analysis("", {"type": ai_type, "summary": ai_summary, "brand": ai_brand, "tags": ai_tags}),
675
+ "uploaded_at": int(uploaded_at) if uploaded_at and str(uploaded_at).isdigit() else uploaded_at
676
  })
677
  else:
678
  try:
 
687
  log.exception("finalize_detections error: %s", e)
688
  return jsonify({"error": "internal", "detail": str(e)}), 500
689
 
690
+ # ---------- Clear session: delete all temporary files for a session ----------
691
  @app.route("/clear_session", methods=["POST"])
692
  def clear_session():
693
+ """
694
+ Body JSON: { "session_id": "<id>", "uid": "<optional uid>" }
695
+ Deletes all detected/<uid>/* blobs where metadata.session_id == session_id and metadata.tmp == "true".
696
+ """
697
  try:
698
  body = request.get_json(force=True)
699
  except Exception:
700
  return jsonify({"error": "invalid json"}), 400
701
+
702
  session_id = (body.get("session_id") or request.args.get("session_id") or "").strip()
703
  uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
704
+
705
  if not session_id:
706
  return jsonify({"error": "session_id required"}), 400
707
+
708
  if not FIREBASE_ADMIN_JSON or not FIREBASE_ADMIN_AVAILABLE:
709
  return jsonify({"error": "firebase admin not configured"}), 500
710
+
711
  try:
712
  init_firebase_admin_if_needed()
713
  bucket = fb_storage.bucket()
714
  except Exception as e:
715
  log.exception("Firebase init error in clear_session: %s", e)
716
  return jsonify({"error": "firebase admin init failed", "detail": str(e)}), 500
717
+
718
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
719
  prefix = f"detected/{safe_uid}/"
720
+
721
  deleted = []
722
  errors = []
723
  try: