Pepguy commited on
Commit
e364d6d
·
verified ·
1 Parent(s): 5282e3c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +77 -170
app.py CHANGED
@@ -50,7 +50,6 @@ app = Flask(__name__)
50
  CORS(app)
51
 
52
  # ---------- Category mapping (must match frontend) ----------
53
- # These values intentionally match the CATEGORY_OPTIONS array on the frontend.
54
  CATEGORIES = [
55
  "Heels",
56
  "Sneakers",
@@ -66,89 +65,96 @@ CATEGORIES = [
66
  "Shorts",
67
  ]
68
 
69
- # simple synonyms / keyword -> category mapping (lowercase keys)
70
  SYNONYMS: Dict[str, str] = {
71
- "heel": "Heels",
72
- "heels": "Heels",
73
- "sneaker": "Sneakers",
74
- "sneakers": "Sneakers",
75
- "trainer": "Sneakers",
76
- "trainers": "Sneakers",
77
- "loafer": "Loafers",
78
- "loafers": "Loafers",
79
- "boot": "Boots",
80
- "boots": "Boots",
81
- "dress": "Dress",
82
- "gown": "Dress",
83
- "jean": "Jeans",
84
- "jeans": "Jeans",
85
- "denim": "Jeans",
86
  "skirt": "Skirt",
87
  "jacket": "Jacket",
88
  "coat": "Coat",
89
  "blazer": "Blazer",
90
- "t-shirt": "T-Shirt",
91
- "t shirt": "T-Shirt",
92
- "tee": "T-Shirt",
93
- "shirt": "T-Shirt",
94
- "top": "T-Shirt",
95
- "short": "Shorts",
96
- "shorts": "Shorts",
97
- "shoe": "Sneakers", # generic shoe -> map to Sneakers as fallback
98
- "shoes": "Sneakers",
99
- "sandal": "Heels", # if ambiguous, map sandals to Heels bucket (you can adjust)
100
- "sandals": "Heels",
101
  }
102
 
103
  def normalize_text(s: str) -> str:
104
  return re.sub(r'[^a-z0-9\s\-]', ' ', s.lower()).strip()
105
 
106
  def choose_category_from_candidates(*candidates: Optional[str], tags: Optional[List[str]] = None) -> str:
107
- """
108
- Given a list of candidate strings (analysis.type, label, summary, etc.) and optional tags,
109
- attempt to pick a category from CATEGORIES. Returns a category string guaranteed to be in CATEGORIES.
110
- Falls back to "T-Shirt" if nothing matches.
111
- """
112
- # try tags first (explicit tag likely to indicate category)
113
  if tags:
114
  for t in tags:
115
- if not t:
116
- continue
117
  tok = normalize_text(str(t))
118
- # direct synonym match
119
  if tok in SYNONYMS:
120
  return SYNONYMS[tok]
121
- # partial substring match
122
  for key, cat in SYNONYMS.items():
123
  if key in tok:
124
  return cat
125
- # try direct category name match
126
  for cat in CATEGORIES:
127
  if tok == cat.lower() or cat.lower() in tok:
128
  return cat
129
-
130
- # iterate through candidate strings in order provided
131
  for c in candidates:
132
- if not c:
133
- continue
134
  s = normalize_text(str(c))
135
- # exact category match
136
  for cat in CATEGORIES:
137
  if s == cat.lower() or cat.lower() in s:
138
  return cat
139
- # check synonyms dictionary words
140
  words = s.split()
141
  for w in words:
142
  if w in SYNONYMS:
143
  return SYNONYMS[w]
144
- # check substrings (e.g., "sneaker" inside longer text)
145
  for key, cat in SYNONYMS.items():
146
  if key in s:
147
  return cat
148
-
149
- # If nothing found, return a safe default present in CATEGORIES
150
  return "T-Shirt"
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  # ---------- Firebase init helpers ----------
153
  _firebase_app = None
154
 
@@ -179,28 +185,20 @@ def init_firebase_admin_if_needed():
179
  raise
180
 
181
  def upload_b64_to_firebase(base64_str: str, path: str, content_type="image/jpeg", metadata: dict = None) -> str:
182
- """
183
- Upload base64 string to Firebase Storage at `path`.
184
- Optionally attach metadata dict (custom metadata).
185
- Returns a public URL when possible, otherwise returns gs://<bucket>/<path>.
186
- """
187
  if not FIREBASE_ADMIN_JSON:
188
  raise RuntimeError("FIREBASE_ADMIN_JSON not set")
189
  init_firebase_admin_if_needed()
190
  if not FIREBASE_ADMIN_AVAILABLE:
191
  raise RuntimeError("firebase-admin not available")
192
-
193
  raw = base64_str
194
  if raw.startswith("data:"):
195
  raw = raw.split(",", 1)[1]
196
  raw = raw.replace("\n", "").replace("\r", "")
197
  data = base64.b64decode(raw)
198
-
199
  try:
200
  bucket = fb_storage.bucket()
201
  blob = bucket.blob(path)
202
  blob.upload_from_string(data, content_type=content_type)
203
- # attach metadata if provided (values must be strings)
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()}
@@ -219,19 +217,15 @@ def upload_b64_to_firebase(base64_str: str, path: str, content_type="image/jpeg"
219
 
220
  # ---------- Image helpers (with EXIF transpose) ----------
221
  def read_image_bytes(file_storage) -> Tuple[np.ndarray, int, int, bytes]:
222
- """
223
- Read bytes, apply EXIF orientation, return BGR numpy, width, height and raw bytes.
224
- """
225
  data = file_storage.read()
226
  img = Image.open(io.BytesIO(data))
227
- # apply EXIF orientation so photos from phones are upright
228
  try:
229
  img = ImageOps.exif_transpose(img)
230
  except Exception:
231
  pass
232
  img = img.convert("RGB")
233
  w, h = img.size
234
- arr = np.array(img)[:, :, ::-1] # RGB -> BGR for OpenCV
235
  return arr, w, h, data
236
 
237
  def crop_and_b64(bgr_img: np.ndarray, x: int, y: int, w: int, h: int, max_side=512) -> str:
@@ -298,36 +292,23 @@ def fallback_contour_crops(bgr_img, max_items=8) -> List[Dict[str, Any]]:
298
  })
299
  return items
300
 
301
- # ---------- AI analysis helper ----------
302
  def analyze_crop_with_gemini(jpeg_b64: str) -> Dict[str, Any]:
303
- """
304
- Run Gemini on the cropped image bytes to extract:
305
- type (one-word category like 'shoe', 'jacket', 'dress'),
306
- summary (single-line description),
307
- brand (string or empty),
308
- tags (array of short descriptors)
309
- Returns dict, falls back to empty/defaults on error or missing key.
310
- """
311
  if not client:
312
  return {"type": "unknown", "summary": "", "brand": "", "tags": []}
313
  try:
314
- # prepare prompt
315
  prompt = (
316
  "You are an assistant that identifies clothing item characteristics from an image. "
317
  "Return only a JSON object with keys: type (single word like 'shoe','top','jacket'), "
318
  "summary (a single short sentence, one line), brand (brand name if visible else empty string), "
319
- "tags (an array of short single-word tags describing visible attributes, e.g. ['striped','leather','white']). "
320
  "Keep values short and concise."
321
  )
322
-
323
  contents = [
324
  types.Content(role="user", parts=[types.Part.from_text(text=prompt)])
325
  ]
326
-
327
- # attach the image bytes
328
  image_bytes = base64.b64decode(jpeg_b64)
329
  contents.append(types.Content(role="user", parts=[types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")]))
330
-
331
  schema = {
332
  "type": "object",
333
  "properties": {
@@ -339,14 +320,11 @@ def analyze_crop_with_gemini(jpeg_b64: str) -> Dict[str, Any]:
339
  "required": ["type", "summary"]
340
  }
341
  cfg = types.GenerateContentConfig(response_mime_type="application/json", response_schema=schema)
342
-
343
- # call model (use the same model family you used before)
344
  resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents, config=cfg)
345
  text = resp.text or ""
346
  parsed = {}
347
  try:
348
  parsed = json.loads(text)
349
- # coerce expected shapes
350
  parsed["type"] = str(parsed.get("type", "")).strip()
351
  parsed["summary"] = str(parsed.get("summary", "")).strip()
352
  parsed["brand"] = str(parsed.get("brand", "")).strip()
@@ -373,33 +351,25 @@ def process_image():
373
  if "photo" not in request.files:
374
  return jsonify({"error": "missing photo"}), 400
375
  file = request.files["photo"]
376
-
377
  uid = (request.form.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
378
-
379
  try:
380
  bgr_img, img_w, img_h, raw_bytes = read_image_bytes(file)
381
  except Exception as e:
382
  log.error("invalid image: %s", e)
383
  return jsonify({"error": "invalid image"}), 400
384
-
385
  session_id = str(uuid.uuid4())
386
-
387
- # Detection prompt (same as before)
388
  user_prompt = (
389
  "You are an assistant that extracts clothing detections from a single image. "
390
  "Return a JSON object with a single key 'items' which is an array. Each item must have: "
391
  "label (string, short like 'top','skirt','sneakers'), "
392
  "bbox with normalized coordinates between 0 and 1: {x, y, w, h} where x,y are top-left relative to width/height, "
393
- "confidence (0-1). Example output: {\"items\":[{\"label\":\"top\",\"bbox\":{\"x\":0.1,\"y\":0.2,\"w\":0.3,\"h\":0.4},\"confidence\":0.95}]} "
394
- "Output ONLY valid JSON. If you cannot detect any clothing confidently, return {\"items\":[]}."
395
  )
396
-
397
  try:
398
  contents = [
399
  types.Content(role="user", parts=[types.Part.from_text(text=user_prompt)])
400
  ]
401
  contents.append(types.Content(role="user", parts=[types.Part.from_bytes(data=raw_bytes, mime_type="image/jpeg")]))
402
-
403
  schema = {
404
  "type": "object",
405
  "properties": {
@@ -411,12 +381,7 @@ def process_image():
411
  "label": {"type": "string"},
412
  "bbox": {
413
  "type": "object",
414
- "properties": {
415
- "x": {"type": "number"},
416
- "y": {"type": "number"},
417
- "w": {"type": "number"},
418
- "h": {"type": "number"}
419
- },
420
  "required": ["x","y","w","h"]
421
  },
422
  "confidence": {"type": "number"}
@@ -427,31 +392,24 @@ def process_image():
427
  },
428
  "required": ["items"]
429
  }
430
-
431
  cfg = types.GenerateContentConfig(response_mime_type="application/json", response_schema=schema)
432
-
433
  log.info("Calling Gemini model for detection (gemini-2.5-flash-lite)...")
434
  model_resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents, config=cfg) if client else None
435
  raw_text = (model_resp.text or "") if model_resp else ""
436
  log.info("Gemini raw response length: %d", len(raw_text))
437
-
438
  parsed = None
439
  try:
440
  parsed = json.loads(raw_text) if raw_text else None
441
  except Exception as e:
442
  log.warning("Could not parse Gemini JSON: %s", e)
443
  parsed = None
444
-
445
  items_out: List[Dict[str, Any]] = []
446
  if parsed and isinstance(parsed.get("items"), list) and len(parsed["items"])>0:
447
  for it in parsed["items"]:
448
  try:
449
  label = str(it.get("label","unknown"))[:48]
450
  bbox = it.get("bbox",{})
451
- nx = float(bbox.get("x",0))
452
- ny = float(bbox.get("y",0))
453
- nw = float(bbox.get("w",0))
454
- nh = float(bbox.get("h",0))
455
  nx = max(0.0, min(1.0, nx)); ny = max(0.0,min(1.0,ny))
456
  nw = max(0.0, min(1.0, nw)); nh = max(0.0, min(1.0, nh))
457
  px = int(nx * img_w); py = int(ny * img_h)
@@ -474,8 +432,7 @@ def process_image():
474
  else:
475
  log.info("Gemini returned no items or parse failed — using fallback contour crops.")
476
  items_out = fallback_contour_crops(bgr_img, max_items=8)
477
-
478
- # Perform AI analysis per crop (if possible) and auto-upload to firebase with metadata (tmp + session)
479
  if FIREBASE_ADMIN_JSON and FIREBASE_ADMIN_AVAILABLE:
480
  try:
481
  init_firebase_admin_if_needed()
@@ -483,31 +440,20 @@ def process_image():
483
  except Exception as e:
484
  log.exception("Firebase admin init for upload failed: %s", e)
485
  bucket = None
486
-
487
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
488
  for itm in items_out:
489
  b64 = itm.get("thumbnail_b64")
490
  if not b64:
491
  continue
492
- # analyze
493
  try:
494
  analysis = analyze_crop_with_gemini(b64) if client else {"type":"unknown","summary":"","brand":"","tags":[]}
495
  except Exception as ae:
496
  log.warning("analysis failed: %s", ae)
497
  analysis = {"type":"unknown","summary":"","brand":"","tags":[]}
498
-
499
  itm["analysis"] = analysis
500
-
501
- # choose a frontend-category-compatible title
502
- # prefer analysis.type, then label, then tags, then summary
503
- title = choose_category_from_candidates(
504
- analysis.get("type", ""),
505
- itm.get("label", ""),
506
- ' '.join(analysis.get("tags", [])),
507
- tags=analysis.get("tags", [])
508
- )
509
  itm["title"] = title
510
-
511
  item_id = itm.get("id") or str(uuid.uuid4())
512
  path = f"detected/{safe_uid}/{item_id}.jpg"
513
  try:
@@ -516,7 +462,6 @@ def process_image():
516
  "session_id": session_id,
517
  "uploaded_by": safe_uid,
518
  "uploaded_at": str(int(time.time())),
519
- # store AI fields as JSON strings for later inspection
520
  "ai_type": analysis.get("type",""),
521
  "ai_brand": analysis.get("brand",""),
522
  "ai_summary": analysis.get("summary",""),
@@ -531,26 +476,21 @@ def process_image():
531
  log.debug("Auto-uploaded thumbnail for %s -> %s (session=%s) title=%s", item_id, url, session_id, title)
532
  except Exception as up_e:
533
  log.warning("Auto-upload failed for %s: %s", item_id, up_e)
534
- # keep thumbnail_b64 and analysis for client fallback
535
  else:
536
  if not FIREBASE_ADMIN_JSON:
537
  log.info("FIREBASE_ADMIN_JSON not set; skipping server-side thumbnail upload.")
538
  else:
539
  log.info("Firebase admin SDK not available; skipping server-side thumbnail upload.")
540
- # For non-upload path, still add a title derived from label/unknown so frontend has it
541
  for itm in items_out:
542
  if "title" not in itm:
543
  analysis = itm.get("analysis") or {"type":"unknown","tags":[]}
544
- title = choose_category_from_candidates(analysis.get("type",""), itm.get("label",""), tags=analysis.get("tags", []))
545
- itm["title"] = title
546
-
547
  return jsonify({"ok": True, "items": items_out, "session_id": session_id, "debug": {"raw_model_text": (raw_text or "")[:1600]}}), 200
548
-
549
  except Exception as ex:
550
  log.exception("Processing error: %s", ex)
551
  try:
552
  items_out = fallback_contour_crops(bgr_img, max_items=8)
553
- # give fallback items a default title so frontend can filter
554
  for itm in items_out:
555
  if "title" not in itm:
556
  itm["title"] = choose_category_from_candidates(itm.get("label","unknown"))
@@ -559,50 +499,31 @@ def process_image():
559
  log.exception("Fallback also failed: %s", e2)
560
  return jsonify({"error": "internal failure", "detail": str(e2)}), 500
561
 
562
- # ---------- Finalize endpoint: keep selected and delete only session's temp files ----------
563
  @app.route("/finalize_detections", methods=["POST"])
564
  def finalize_detections():
565
- """
566
- Body JSON:
567
- { "uid": "user123", "keep_ids": ["id1","id2",...], "session_id": "<session id from /process>" }
568
-
569
- Server will delete only detected/<uid>/* files whose:
570
- - metadata.tmp == "true"
571
- - metadata.session_id == session_id
572
- - item_id NOT in keep_ids
573
-
574
- Returns:
575
- { ok: True, kept: [...], deleted: [...], errors: [...] }
576
- """
577
  try:
578
  body = request.get_json(force=True)
579
  except Exception:
580
  return jsonify({"error": "invalid json"}), 400
581
-
582
  uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
583
  keep_ids = set(body.get("keep_ids") or [])
584
  session_id = (body.get("session_id") or request.args.get("session_id") or "").strip()
585
-
586
  if not session_id:
587
  return jsonify({"error": "session_id required for finalize to avoid unsafe deletes"}), 400
588
-
589
  if not FIREBASE_ADMIN_JSON or not FIREBASE_ADMIN_AVAILABLE:
590
  return jsonify({"error": "firebase admin not configured"}), 500
591
-
592
  try:
593
  init_firebase_admin_if_needed()
594
  bucket = fb_storage.bucket()
595
  except Exception as e:
596
  log.exception("Firebase init error in finalize: %s", e)
597
  return jsonify({"error": "firebase admin init failed", "detail": str(e)}), 500
598
-
599
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
600
  prefix = f"detected/{safe_uid}/"
601
-
602
  kept = []
603
  deleted = []
604
  errors = []
605
-
606
  try:
607
  blobs = list(bucket.list_blobs(prefix=prefix))
608
  for blob in blobs:
@@ -612,21 +533,15 @@ def finalize_detections():
612
  if "." not in fname:
613
  continue
614
  item_id = fname.rsplit(".", 1)[0]
615
-
616
  md = blob.metadata or {}
617
- # only consider temporary files matching this session id
618
  if str(md.get("session_id", "")) != session_id or str(md.get("tmp", "")).lower() not in ("true", "1", "yes"):
619
  continue
620
-
621
  if item_id in keep_ids:
622
- # ensure public URL available if possible
623
  try:
624
  blob.make_public()
625
  url = blob.public_url
626
  except Exception:
627
  url = f"gs://{bucket.name}/{name}"
628
-
629
- # extract AI metadata (if present)
630
  ai_type = md.get("ai_type") or ""
631
  ai_brand = md.get("ai_brand") or ""
632
  ai_summary = md.get("ai_summary") or ""
@@ -636,25 +551,27 @@ def finalize_detections():
636
  ai_tags = json.loads(ai_tags_raw) if isinstance(ai_tags_raw, str) else ai_tags_raw
637
  except Exception:
638
  ai_tags = []
639
- # derive title: prefer stored metadata title, then ai_type/tags/summary
640
  title = None
641
  if title_meta:
642
  try:
643
  title = json.loads(title_meta) if (title_meta.startswith('[') or title_meta.startswith('{')) else str(title_meta)
644
  except Exception:
645
  title = str(title_meta)
646
- if not title:
 
 
 
 
 
 
 
 
647
  title = choose_category_from_candidates(ai_type, ai_summary, tags=ai_tags)
648
  kept.append({
649
  "id": item_id,
650
  "thumbnail_url": url,
651
  "thumbnail_path": name,
652
- "analysis": {
653
- "type": ai_type,
654
- "brand": ai_brand,
655
- "summary": ai_summary,
656
- "tags": ai_tags
657
- },
658
  "title": title
659
  })
660
  else:
@@ -670,37 +587,27 @@ def finalize_detections():
670
  log.exception("finalize_detections error: %s", e)
671
  return jsonify({"error": "internal", "detail": str(e)}), 500
672
 
673
- # ---------- Clear session: delete all temporary files for a session ----------
674
  @app.route("/clear_session", methods=["POST"])
675
  def clear_session():
676
- """
677
- Body JSON: { "session_id": "<id>", "uid": "<optional uid>" }
678
- Deletes all detected/<uid>/* blobs where metadata.session_id == session_id and metadata.tmp == "true".
679
- """
680
  try:
681
  body = request.get_json(force=True)
682
  except Exception:
683
  return jsonify({"error": "invalid json"}), 400
684
-
685
  session_id = (body.get("session_id") or request.args.get("session_id") or "").strip()
686
  uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
687
-
688
  if not session_id:
689
  return jsonify({"error": "session_id required"}), 400
690
-
691
  if not FIREBASE_ADMIN_JSON or not FIREBASE_ADMIN_AVAILABLE:
692
  return jsonify({"error": "firebase admin not configured"}), 500
693
-
694
  try:
695
  init_firebase_admin_if_needed()
696
  bucket = fb_storage.bucket()
697
  except Exception as e:
698
  log.exception("Firebase init error in clear_session: %s", e)
699
  return jsonify({"error": "firebase admin init failed", "detail": str(e)}), 500
700
-
701
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
702
  prefix = f"detected/{safe_uid}/"
703
-
704
  deleted = []
705
  errors = []
706
  try:
 
50
  CORS(app)
51
 
52
  # ---------- Category mapping (must match frontend) ----------
 
53
  CATEGORIES = [
54
  "Heels",
55
  "Sneakers",
 
65
  "Shorts",
66
  ]
67
 
 
68
  SYNONYMS: Dict[str, str] = {
69
+ "heel": "Heels", "heels": "Heels",
70
+ "sneaker": "Sneakers", "sneakers": "Sneakers", "trainer": "Sneakers", "trainers": "Sneakers",
71
+ "loafer": "Loafers", "loafers": "Loafers",
72
+ "boot": "Boots", "boots": "Boots",
73
+ "dress": "Dress", "gown": "Dress",
74
+ "jean": "Jeans", "jeans": "Jeans", "denim": "Jeans",
 
 
 
 
 
 
 
 
 
75
  "skirt": "Skirt",
76
  "jacket": "Jacket",
77
  "coat": "Coat",
78
  "blazer": "Blazer",
79
+ "t-shirt": "T-Shirt", "t shirt": "T-Shirt", "tee": "T-Shirt", "shirt": "T-Shirt", "top": "T-Shirt",
80
+ "short": "Shorts", "shorts": "Shorts",
81
+ "shoe": "Sneakers", "shoes": "Sneakers",
82
+ "sandal": "Heels", "sandals": "Heels",
 
 
 
 
 
 
 
83
  }
84
 
85
  def normalize_text(s: str) -> str:
86
  return re.sub(r'[^a-z0-9\s\-]', ' ', s.lower()).strip()
87
 
88
  def choose_category_from_candidates(*candidates: Optional[str], tags: Optional[List[str]] = None) -> str:
 
 
 
 
 
 
89
  if tags:
90
  for t in tags:
91
+ if not t: continue
 
92
  tok = normalize_text(str(t))
 
93
  if tok in SYNONYMS:
94
  return SYNONYMS[tok]
 
95
  for key, cat in SYNONYMS.items():
96
  if key in tok:
97
  return cat
 
98
  for cat in CATEGORIES:
99
  if tok == cat.lower() or cat.lower() in tok:
100
  return cat
 
 
101
  for c in candidates:
102
+ if not c: continue
 
103
  s = normalize_text(str(c))
 
104
  for cat in CATEGORIES:
105
  if s == cat.lower() or cat.lower() in s:
106
  return cat
 
107
  words = s.split()
108
  for w in words:
109
  if w in SYNONYMS:
110
  return SYNONYMS[w]
 
111
  for key, cat in SYNONYMS.items():
112
  if key in s:
113
  return cat
 
 
114
  return "T-Shirt"
115
 
116
+ # ---------- New: ask Gemini to pick EXACT allowed category ----------
117
+ def pick_allowed_category(preferred_text: Optional[str], label_text: Optional[str], tags: Optional[List[str]] = None) -> str:
118
+ """
119
+ Try to get Gemini to return exactly one category string from CATEGORIES.
120
+ If client not available or call fails or the returned value isn't an exact match, fallback to local chooser.
121
+ """
122
+ candidate = preferred_text or label_text or ""
123
+ # build short instruction
124
+ if client:
125
+ try:
126
+ # prompt: return exactly one of the categories listed, nothing else (no punctuation)
127
+ prompt = (
128
+ "You are given a short description of a clothing item. "
129
+ "From the following list choose the single best category that matches the item. "
130
+ "Return ONLY the category name exactly as shown (case-sensitive match is not required):\n\n"
131
+ f"{', '.join(CATEGORIES)}\n\n"
132
+ f"Item description: {candidate}\n\n"
133
+ "Output exactly one of the category names above (no JSON, no explanation)."
134
+ )
135
+ contents = [types.Content(role="user", parts=[types.Part.from_text(text=prompt)])]
136
+ # prefer to ask model to respond with a single string; we won't rely on strict schema formatting,
137
+ # but we'll attempt to validate the returned string.
138
+ cfg = types.GenerateContentConfig(response_mime_type="text/plain")
139
+ resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents, config=cfg)
140
+ raw = (resp.text or "").strip()
141
+ # strip quotes if present
142
+ candidate_out = raw.strip().strip('"').strip("'").strip()
143
+ # check candidate_out against allowed categories (case-insensitive)
144
+ for cat in CATEGORIES:
145
+ if candidate_out.lower() == cat.lower():
146
+ return cat
147
+ # sometimes model returns JSON or extra text; try to extract any allowed category substring
148
+ low = candidate_out.lower()
149
+ for cat in CATEGORIES:
150
+ if cat.lower() in low:
151
+ return cat
152
+ # if not matched, fallback to local matching
153
+ except Exception as e:
154
+ log.warning("pick_allowed_category Gemini call failed: %s", e)
155
+ # Gemini not available or didn't return a valid match -> fallback
156
+ return choose_category_from_candidates(preferred_text, label_text, tags=tags)
157
+
158
  # ---------- Firebase init helpers ----------
159
  _firebase_app = None
160
 
 
185
  raise
186
 
187
  def upload_b64_to_firebase(base64_str: str, path: str, content_type="image/jpeg", metadata: dict = None) -> str:
 
 
 
 
 
188
  if not FIREBASE_ADMIN_JSON:
189
  raise RuntimeError("FIREBASE_ADMIN_JSON not set")
190
  init_firebase_admin_if_needed()
191
  if not FIREBASE_ADMIN_AVAILABLE:
192
  raise RuntimeError("firebase-admin not available")
 
193
  raw = base64_str
194
  if raw.startswith("data:"):
195
  raw = raw.split(",", 1)[1]
196
  raw = raw.replace("\n", "").replace("\r", "")
197
  data = base64.b64decode(raw)
 
198
  try:
199
  bucket = fb_storage.bucket()
200
  blob = bucket.blob(path)
201
  blob.upload_from_string(data, content_type=content_type)
 
202
  if metadata:
203
  try:
204
  blob.metadata = {k: (json.dumps(v) if not isinstance(v, str) else v) for k, v in metadata.items()}
 
217
 
218
  # ---------- Image helpers (with EXIF transpose) ----------
219
  def read_image_bytes(file_storage) -> Tuple[np.ndarray, int, int, bytes]:
 
 
 
220
  data = file_storage.read()
221
  img = Image.open(io.BytesIO(data))
 
222
  try:
223
  img = ImageOps.exif_transpose(img)
224
  except Exception:
225
  pass
226
  img = img.convert("RGB")
227
  w, h = img.size
228
+ arr = np.array(img)[:, :, ::-1]
229
  return arr, w, h, data
230
 
231
  def crop_and_b64(bgr_img: np.ndarray, x: int, y: int, w: int, h: int, max_side=512) -> str:
 
292
  })
293
  return items
294
 
295
+ # ---------- AI analysis helper (unchanged) ----------
296
  def analyze_crop_with_gemini(jpeg_b64: str) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
297
  if not client:
298
  return {"type": "unknown", "summary": "", "brand": "", "tags": []}
299
  try:
 
300
  prompt = (
301
  "You are an assistant that identifies clothing item characteristics from an image. "
302
  "Return only a JSON object with keys: type (single word like 'shoe','top','jacket'), "
303
  "summary (a single short sentence, one line), brand (brand name if visible else empty string), "
304
+ "tags (an array of short single-word tags describing visible attributes). "
305
  "Keep values short and concise."
306
  )
 
307
  contents = [
308
  types.Content(role="user", parts=[types.Part.from_text(text=prompt)])
309
  ]
 
 
310
  image_bytes = base64.b64decode(jpeg_b64)
311
  contents.append(types.Content(role="user", parts=[types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")]))
 
312
  schema = {
313
  "type": "object",
314
  "properties": {
 
320
  "required": ["type", "summary"]
321
  }
322
  cfg = types.GenerateContentConfig(response_mime_type="application/json", response_schema=schema)
 
 
323
  resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents, config=cfg)
324
  text = resp.text or ""
325
  parsed = {}
326
  try:
327
  parsed = json.loads(text)
 
328
  parsed["type"] = str(parsed.get("type", "")).strip()
329
  parsed["summary"] = str(parsed.get("summary", "")).strip()
330
  parsed["brand"] = str(parsed.get("brand", "")).strip()
 
351
  if "photo" not in request.files:
352
  return jsonify({"error": "missing photo"}), 400
353
  file = request.files["photo"]
 
354
  uid = (request.form.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
 
355
  try:
356
  bgr_img, img_w, img_h, raw_bytes = read_image_bytes(file)
357
  except Exception as e:
358
  log.error("invalid image: %s", e)
359
  return jsonify({"error": "invalid image"}), 400
 
360
  session_id = str(uuid.uuid4())
 
 
361
  user_prompt = (
362
  "You are an assistant that extracts clothing detections from a single image. "
363
  "Return a JSON object with a single key 'items' which is an array. Each item must have: "
364
  "label (string, short like 'top','skirt','sneakers'), "
365
  "bbox with normalized coordinates between 0 and 1: {x, y, w, h} where x,y are top-left relative to width/height, "
366
+ "confidence (0-1). Output ONLY valid JSON."
 
367
  )
 
368
  try:
369
  contents = [
370
  types.Content(role="user", parts=[types.Part.from_text(text=user_prompt)])
371
  ]
372
  contents.append(types.Content(role="user", parts=[types.Part.from_bytes(data=raw_bytes, mime_type="image/jpeg")]))
 
373
  schema = {
374
  "type": "object",
375
  "properties": {
 
381
  "label": {"type": "string"},
382
  "bbox": {
383
  "type": "object",
384
+ "properties": {"x": {"type": "number"}, "y": {"type": "number"}, "w": {"type": "number"}, "h": {"type": "number"}},
 
 
 
 
 
385
  "required": ["x","y","w","h"]
386
  },
387
  "confidence": {"type": "number"}
 
392
  },
393
  "required": ["items"]
394
  }
 
395
  cfg = types.GenerateContentConfig(response_mime_type="application/json", response_schema=schema)
 
396
  log.info("Calling Gemini model for detection (gemini-2.5-flash-lite)...")
397
  model_resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents, config=cfg) if client else None
398
  raw_text = (model_resp.text or "") if model_resp else ""
399
  log.info("Gemini raw response length: %d", len(raw_text))
 
400
  parsed = None
401
  try:
402
  parsed = json.loads(raw_text) if raw_text else None
403
  except Exception as e:
404
  log.warning("Could not parse Gemini JSON: %s", e)
405
  parsed = None
 
406
  items_out: List[Dict[str, Any]] = []
407
  if parsed and isinstance(parsed.get("items"), list) and len(parsed["items"])>0:
408
  for it in parsed["items"]:
409
  try:
410
  label = str(it.get("label","unknown"))[:48]
411
  bbox = it.get("bbox",{})
412
+ nx = float(bbox.get("x",0)); ny = float(bbox.get("y",0)); nw = float(bbox.get("w",0)); nh = float(bbox.get("h",0))
 
 
 
413
  nx = max(0.0, min(1.0, nx)); ny = max(0.0,min(1.0,ny))
414
  nw = max(0.0, min(1.0, nw)); nh = max(0.0, min(1.0, nh))
415
  px = int(nx * img_w); py = int(ny * img_h)
 
432
  else:
433
  log.info("Gemini returned no items or parse failed — using fallback contour crops.")
434
  items_out = fallback_contour_crops(bgr_img, max_items=8)
435
+ # AI analysis & upload
 
436
  if FIREBASE_ADMIN_JSON and FIREBASE_ADMIN_AVAILABLE:
437
  try:
438
  init_firebase_admin_if_needed()
 
440
  except Exception as e:
441
  log.exception("Firebase admin init for upload failed: %s", e)
442
  bucket = None
 
443
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
444
  for itm in items_out:
445
  b64 = itm.get("thumbnail_b64")
446
  if not b64:
447
  continue
 
448
  try:
449
  analysis = analyze_crop_with_gemini(b64) if client else {"type":"unknown","summary":"","brand":"","tags":[]}
450
  except Exception as ae:
451
  log.warning("analysis failed: %s", ae)
452
  analysis = {"type":"unknown","summary":"","brand":"","tags":[]}
 
453
  itm["analysis"] = analysis
454
+ # pick allowed category (this is the important change: we ask Gemini to pick allowed category then fallback)
455
+ title = pick_allowed_category(analysis.get("type",""), itm.get("label",""), tags=analysis.get("tags", []))
 
 
 
 
 
 
 
456
  itm["title"] = title
 
457
  item_id = itm.get("id") or str(uuid.uuid4())
458
  path = f"detected/{safe_uid}/{item_id}.jpg"
459
  try:
 
462
  "session_id": session_id,
463
  "uploaded_by": safe_uid,
464
  "uploaded_at": str(int(time.time())),
 
465
  "ai_type": analysis.get("type",""),
466
  "ai_brand": analysis.get("brand",""),
467
  "ai_summary": analysis.get("summary",""),
 
476
  log.debug("Auto-uploaded thumbnail for %s -> %s (session=%s) title=%s", item_id, url, session_id, title)
477
  except Exception as up_e:
478
  log.warning("Auto-upload failed for %s: %s", item_id, up_e)
 
479
  else:
480
  if not FIREBASE_ADMIN_JSON:
481
  log.info("FIREBASE_ADMIN_JSON not set; skipping server-side thumbnail upload.")
482
  else:
483
  log.info("Firebase admin SDK not available; skipping server-side thumbnail upload.")
484
+ # ensure a title exists for frontend even if no upload
485
  for itm in items_out:
486
  if "title" not in itm:
487
  analysis = itm.get("analysis") or {"type":"unknown","tags":[]}
488
+ itm["title"] = pick_allowed_category(analysis.get("type",""), itm.get("label",""), tags=analysis.get("tags", []))
 
 
489
  return jsonify({"ok": True, "items": items_out, "session_id": session_id, "debug": {"raw_model_text": (raw_text or "")[:1600]}}), 200
 
490
  except Exception as ex:
491
  log.exception("Processing error: %s", ex)
492
  try:
493
  items_out = fallback_contour_crops(bgr_img, max_items=8)
 
494
  for itm in items_out:
495
  if "title" not in itm:
496
  itm["title"] = choose_category_from_candidates(itm.get("label","unknown"))
 
499
  log.exception("Fallback also failed: %s", e2)
500
  return jsonify({"error": "internal failure", "detail": str(e2)}), 500
501
 
502
+ # ---------- Finalize endpoint ----------
503
  @app.route("/finalize_detections", methods=["POST"])
504
  def finalize_detections():
 
 
 
 
 
 
 
 
 
 
 
 
505
  try:
506
  body = request.get_json(force=True)
507
  except Exception:
508
  return jsonify({"error": "invalid json"}), 400
 
509
  uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
510
  keep_ids = set(body.get("keep_ids") or [])
511
  session_id = (body.get("session_id") or request.args.get("session_id") or "").strip()
 
512
  if not session_id:
513
  return jsonify({"error": "session_id required for finalize to avoid unsafe deletes"}), 400
 
514
  if not FIREBASE_ADMIN_JSON or not FIREBASE_ADMIN_AVAILABLE:
515
  return jsonify({"error": "firebase admin not configured"}), 500
 
516
  try:
517
  init_firebase_admin_if_needed()
518
  bucket = fb_storage.bucket()
519
  except Exception as e:
520
  log.exception("Firebase init error in finalize: %s", e)
521
  return jsonify({"error": "firebase admin init failed", "detail": str(e)}), 500
 
522
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
523
  prefix = f"detected/{safe_uid}/"
 
524
  kept = []
525
  deleted = []
526
  errors = []
 
527
  try:
528
  blobs = list(bucket.list_blobs(prefix=prefix))
529
  for blob in blobs:
 
533
  if "." not in fname:
534
  continue
535
  item_id = fname.rsplit(".", 1)[0]
 
536
  md = blob.metadata or {}
 
537
  if str(md.get("session_id", "")) != session_id or str(md.get("tmp", "")).lower() not in ("true", "1", "yes"):
538
  continue
 
539
  if item_id in keep_ids:
 
540
  try:
541
  blob.make_public()
542
  url = blob.public_url
543
  except Exception:
544
  url = f"gs://{bucket.name}/{name}"
 
 
545
  ai_type = md.get("ai_type") or ""
546
  ai_brand = md.get("ai_brand") or ""
547
  ai_summary = md.get("ai_summary") or ""
 
551
  ai_tags = json.loads(ai_tags_raw) if isinstance(ai_tags_raw, str) else ai_tags_raw
552
  except Exception:
553
  ai_tags = []
 
554
  title = None
555
  if title_meta:
556
  try:
557
  title = json.loads(title_meta) if (title_meta.startswith('[') or title_meta.startswith('{')) else str(title_meta)
558
  except Exception:
559
  title = str(title_meta)
560
+ # validate title: if not in allowed set, derive from AI fields
561
+ valid = False
562
+ if isinstance(title, str) and title.strip():
563
+ for cat in CATEGORIES:
564
+ if title.strip().lower() == cat.lower():
565
+ title = cat
566
+ valid = True
567
+ break
568
+ if not valid:
569
  title = choose_category_from_candidates(ai_type, ai_summary, tags=ai_tags)
570
  kept.append({
571
  "id": item_id,
572
  "thumbnail_url": url,
573
  "thumbnail_path": name,
574
+ "analysis": {"type": ai_type, "brand": ai_brand, "summary": ai_summary, "tags": ai_tags},
 
 
 
 
 
575
  "title": title
576
  })
577
  else:
 
587
  log.exception("finalize_detections error: %s", e)
588
  return jsonify({"error": "internal", "detail": str(e)}), 500
589
 
590
+ # ---------- Clear session ----------
591
  @app.route("/clear_session", methods=["POST"])
592
  def clear_session():
 
 
 
 
593
  try:
594
  body = request.get_json(force=True)
595
  except Exception:
596
  return jsonify({"error": "invalid json"}), 400
 
597
  session_id = (body.get("session_id") or request.args.get("session_id") or "").strip()
598
  uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
 
599
  if not session_id:
600
  return jsonify({"error": "session_id required"}), 400
 
601
  if not FIREBASE_ADMIN_JSON or not FIREBASE_ADMIN_AVAILABLE:
602
  return jsonify({"error": "firebase admin not configured"}), 500
 
603
  try:
604
  init_firebase_admin_if_needed()
605
  bucket = fb_storage.bucket()
606
  except Exception as e:
607
  log.exception("Firebase init error in clear_session: %s", e)
608
  return jsonify({"error": "firebase admin init failed", "detail": str(e)}), 500
 
609
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
610
  prefix = f"detected/{safe_uid}/"
 
611
  deleted = []
612
  errors = []
613
  try: