Pepguy commited on
Commit
75e6b15
·
verified ·
1 Parent(s): b36e067

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +76 -39
app.py CHANGED
@@ -5,6 +5,7 @@ import json
5
  import base64
6
  import logging
7
  import uuid
 
8
  from typing import List, Dict, Any, Tuple
9
 
10
  from flask import Flask, request, jsonify
@@ -33,7 +34,9 @@ log = logging.getLogger("wardrobe-server")
33
 
34
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
35
  if not GEMINI_API_KEY:
36
- log.warning("GEMINI_API_KEY not set — gemini calls will fail.")
 
 
37
 
38
  # Firebase config (read service account JSON from env)
39
  FIREBASE_ADMIN_JSON = os.getenv("FIREBASE_ADMIN_JSON", "").strip()
@@ -42,8 +45,6 @@ FIREBASE_STORAGE_BUCKET = os.getenv("FIREBASE_STORAGE_BUCKET", "").strip() # op
42
  if FIREBASE_ADMIN_JSON and not FIREBASE_ADMIN_AVAILABLE:
43
  log.warning("FIREBASE_ADMIN_JSON provided but firebase-admin SDK is not installed. Install firebase-admin.")
44
 
45
- client = genai.Client(api_key=GEMINI_API_KEY)
46
-
47
  app = Flask(__name__)
48
  CORS(app)
49
 
@@ -80,9 +81,10 @@ def init_firebase_admin_if_needed():
80
  log.exception("Failed to initialize firebase admin: %s", e)
81
  raise
82
 
83
- def upload_b64_to_firebase(base64_str: str, path: str, content_type="image/jpeg") -> str:
84
  """
85
  Upload base64 string to Firebase Storage at `path` (e.g. detected/uid/item.jpg).
 
86
  Returns a public URL when possible, otherwise returns gs://<bucket>/<path>.
87
  """
88
  if not FIREBASE_ADMIN_JSON:
@@ -91,7 +93,6 @@ def upload_b64_to_firebase(base64_str: str, path: str, content_type="image/jpeg"
91
  if not FIREBASE_ADMIN_AVAILABLE:
92
  raise RuntimeError("firebase-admin not available")
93
 
94
- # decode base64 (strip data:... prefix if present)
95
  raw = base64_str
96
  if raw.startswith("data:"):
97
  raw = raw.split(",", 1)[1]
@@ -99,9 +100,17 @@ def upload_b64_to_firebase(base64_str: str, path: str, content_type="image/jpeg"
99
  data = base64.b64decode(raw)
100
 
101
  try:
102
- bucket = fb_storage.bucket() # uses default bucket from app options
103
  blob = bucket.blob(path)
104
  blob.upload_from_string(data, content_type=content_type)
 
 
 
 
 
 
 
 
105
  try:
106
  blob.make_public()
107
  return blob.public_url
@@ -123,6 +132,7 @@ def read_image_bytes(file_storage) -> Tuple[np.ndarray, int, int, bytes]:
123
  try:
124
  img = ImageOps.exif_transpose(img)
125
  except Exception:
 
126
  pass
127
  img = img.convert("RGB")
128
  w, h = img.size
@@ -136,6 +146,7 @@ def crop_and_b64(bgr_img: np.ndarray, x: int, y: int, w: int, h: int, max_side=5
136
  crop = bgr_img[y:y2, x:x2]
137
  if crop.size == 0:
138
  return ""
 
139
  max_dim = max(crop.shape[0], crop.shape[1])
140
  if max_dim > max_side:
141
  scale = max_side / max_dim
@@ -174,6 +185,7 @@ def fallback_contour_crops(bgr_img, max_items=8) -> List[Dict[str, Any]]:
174
  "thumbnail_b64": b64,
175
  "source": "fallback"
176
  })
 
177
  if not items:
178
  h_half, w_half = h_img//2, w_img//2
179
  rects = [
@@ -200,6 +212,7 @@ def process_image():
200
  return jsonify({"error": "missing photo"}), 400
201
  file = request.files["photo"]
202
 
 
203
  uid = (request.form.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
204
 
205
  try:
@@ -208,6 +221,10 @@ def process_image():
208
  log.error("invalid image: %s", e)
209
  return jsonify({"error": "invalid image"}), 400
210
 
 
 
 
 
211
  user_prompt = (
212
  "You are an assistant that extracts clothing detections from a single image. "
213
  "Return a JSON object with a single key 'items' which is an array. Each item must have: "
@@ -265,14 +282,16 @@ def process_image():
265
  log.warning("Could not parse Gemini JSON: %s", e)
266
  parsed = None
267
 
268
- items_out = []
269
  if parsed and isinstance(parsed.get("items"), list) and len(parsed["items"])>0:
270
  for it in parsed["items"]:
271
  try:
272
  label = str(it.get("label","unknown"))[:48]
273
  bbox = it.get("bbox",{})
274
- nx = float(bbox.get("x",0)); ny = float(bbox.get("y",0))
275
- nw = float(bbox.get("w",0)); nh = float(bbox.get("h",0))
 
 
276
  nx = max(0.0, min(1.0, nx)); ny = max(0.0,min(1.0,ny))
277
  nw = max(0.0, min(1.0, nw)); nh = max(0.0, min(1.0, nh))
278
  px = int(nx * img_w); py = int(ny * img_h)
@@ -296,7 +315,7 @@ def process_image():
296
  log.info("Gemini returned no items or parse failed — using fallback contour crops.")
297
  items_out = fallback_contour_crops(bgr_img, max_items=8)
298
 
299
- # === AUTO-UPLOAD all detection thumbnails to Firebase Storage (so client doesn't upload) ===
300
  if FIREBASE_ADMIN_JSON and FIREBASE_ADMIN_AVAILABLE:
301
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
302
  for itm in items_out:
@@ -306,55 +325,53 @@ def process_image():
306
  item_id = itm.get("id") or str(uuid.uuid4())
307
  path = f"detected/{safe_uid}/{item_id}.jpg"
308
  try:
309
- url = upload_b64_to_firebase(b64, path, content_type="image/jpeg")
 
 
 
 
 
 
310
  itm["thumbnail_url"] = url
311
- # keep thumbnail_b64 for potential debugging but remove to keep payload small:
312
  itm.pop("thumbnail_b64", None)
313
- log.debug("Auto-uploaded thumbnail for %s -> %s", item_id, url)
 
 
314
  except Exception as up_e:
315
  log.warning("Auto-upload failed for %s: %s", item_id, up_e)
316
- # keep thumbnail_b64 as fallback (client can still display base64)
317
  else:
318
  if not FIREBASE_ADMIN_JSON:
319
  log.info("FIREBASE_ADMIN_JSON not set; skipping server-side thumbnail upload.")
320
  else:
321
  log.info("Firebase admin SDK not available; skipping server-side thumbnail upload.")
322
 
323
- return jsonify({"ok": True, "items": items_out, "debug": {"raw_model_text": raw_text[:1600]}}), 200
324
 
325
  except Exception as ex:
326
  log.exception("Processing error: %s", ex)
327
  try:
328
  items_out = fallback_contour_crops(bgr_img, max_items=8)
329
- # attempt auto-upload on fallback results as well
330
- if FIREBASE_ADMIN_JSON and FIREBASE_ADMIN_AVAILABLE:
331
- safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
332
- for itm in items_out:
333
- b64 = itm.get("thumbnail_b64")
334
- if not b64:
335
- continue
336
- item_id = itm.get("id") or str(uuid.uuid4())
337
- path = f"detected/{safe_uid}/{item_id}.jpg"
338
- try:
339
- url = upload_b64_to_firebase(b64, path, content_type="image/jpeg")
340
- itm["thumbnail_url"] = url
341
- itm.pop("thumbnail_b64", None)
342
- except Exception as up_e:
343
- log.warning("Failed to upload fallback thumbnail for %s: %s", item_id, up_e)
344
- return jsonify({"ok": True, "items": items_out, "debug": {"error": str(ex)}}), 200
345
  except Exception as e2:
346
  log.exception("Fallback also failed: %s", e2)
347
  return jsonify({"error": "internal failure", "detail": str(e2)}), 500
348
 
349
- # ---------- New endpoint: finalize (keep selected -> delete rest) ----------
350
  @app.route("/finalize_detections", methods=["POST"])
351
  def finalize_detections():
352
  """
353
  Body JSON:
354
- { "uid": "user123", "keep_ids": ["id1","id2",...] }
355
 
356
- Server will delete all detected/<uid>/* files whose id is NOT in keep_ids.
357
- Returns kept (id -> thumbnail_url) and deleted ids.
 
 
 
 
 
358
  """
359
  try:
360
  body = request.get_json(force=True)
@@ -363,12 +380,21 @@ def finalize_detections():
363
 
364
  uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
365
  keep_ids = set(body.get("keep_ids") or [])
 
 
 
 
366
 
367
  if not FIREBASE_ADMIN_JSON or not FIREBASE_ADMIN_AVAILABLE:
368
  return jsonify({"error": "firebase admin not configured"}), 500
369
 
370
- init_firebase_admin_if_needed()
371
- bucket = fb_storage.bucket()
 
 
 
 
 
372
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
373
  prefix = f"detected/{safe_uid}/"
374
 
@@ -380,9 +406,20 @@ def finalize_detections():
380
  blobs = list(bucket.list_blobs(prefix=prefix))
381
  for blob in blobs:
382
  try:
383
- name = blob.name # detected/<safe_uid>/<itemid>.jpg
384
  fname = name.split("/")[-1]
 
 
 
 
385
  item_id = fname.rsplit(".", 1)[0]
 
 
 
 
 
 
 
386
  if item_id in keep_ids:
387
  # ensure public URL available if possible
388
  try:
@@ -392,7 +429,7 @@ def finalize_detections():
392
  url = f"gs://{bucket.name}/{name}"
393
  kept.append({"id": item_id, "thumbnail_url": url})
394
  else:
395
- # delete unkept
396
  try:
397
  blob.delete()
398
  deleted.append(item_id)
 
5
  import base64
6
  import logging
7
  import uuid
8
+ import time
9
  from typing import List, Dict, Any, Tuple
10
 
11
  from flask import Flask, request, jsonify
 
34
 
35
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
36
  if not GEMINI_API_KEY:
37
+ log.warning("GEMINI_API_KEY not set — gemini calls will fail (but fallback still works).")
38
+
39
+ client = genai.Client(api_key=GEMINI_API_KEY)
40
 
41
  # Firebase config (read service account JSON from env)
42
  FIREBASE_ADMIN_JSON = os.getenv("FIREBASE_ADMIN_JSON", "").strip()
 
45
  if FIREBASE_ADMIN_JSON and not FIREBASE_ADMIN_AVAILABLE:
46
  log.warning("FIREBASE_ADMIN_JSON provided but firebase-admin SDK is not installed. Install firebase-admin.")
47
 
 
 
48
  app = Flask(__name__)
49
  CORS(app)
50
 
 
81
  log.exception("Failed to initialize firebase admin: %s", e)
82
  raise
83
 
84
+ def upload_b64_to_firebase(base64_str: str, path: str, content_type="image/jpeg", metadata: dict = None) -> str:
85
  """
86
  Upload base64 string to Firebase Storage at `path` (e.g. detected/uid/item.jpg).
87
+ Optionally attach metadata dict (custom metadata).
88
  Returns a public URL when possible, otherwise returns gs://<bucket>/<path>.
89
  """
90
  if not FIREBASE_ADMIN_JSON:
 
93
  if not FIREBASE_ADMIN_AVAILABLE:
94
  raise RuntimeError("firebase-admin not available")
95
 
 
96
  raw = base64_str
97
  if raw.startswith("data:"):
98
  raw = raw.split(",", 1)[1]
 
100
  data = base64.b64decode(raw)
101
 
102
  try:
103
+ bucket = fb_storage.bucket()
104
  blob = bucket.blob(path)
105
  blob.upload_from_string(data, content_type=content_type)
106
+ # attach metadata if provided
107
+ if metadata:
108
+ try:
109
+ # google-cloud-storage uses blob.metadata (dict)
110
+ blob.metadata = metadata
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
 
132
  try:
133
  img = ImageOps.exif_transpose(img)
134
  except Exception:
135
+ # ignore if EXIF not present or transpose fails
136
  pass
137
  img = img.convert("RGB")
138
  w, h = img.size
 
146
  crop = bgr_img[y:y2, x:x2]
147
  if crop.size == 0:
148
  return ""
149
+ # resize if too large
150
  max_dim = max(crop.shape[0], crop.shape[1])
151
  if max_dim > max_side:
152
  scale = max_side / max_dim
 
185
  "thumbnail_b64": b64,
186
  "source": "fallback"
187
  })
188
+ # if still none, split into grid
189
  if not items:
190
  h_half, w_half = h_img//2, w_img//2
191
  rects = [
 
212
  return jsonify({"error": "missing photo"}), 400
213
  file = request.files["photo"]
214
 
215
+ # optional uid from form fields (client can supply for grouping)
216
  uid = (request.form.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
217
 
218
  try:
 
221
  log.error("invalid image: %s", e)
222
  return jsonify({"error": "invalid image"}), 400
223
 
224
+ # generate a per-request session id used to mark temporary uploads
225
+ session_id = str(uuid.uuid4())
226
+
227
+ # Build a prompt instructing Gemini to detect garments and return normalized bbox list
228
  user_prompt = (
229
  "You are an assistant that extracts clothing detections from a single image. "
230
  "Return a JSON object with a single key 'items' which is an array. Each item must have: "
 
282
  log.warning("Could not parse Gemini JSON: %s", e)
283
  parsed = None
284
 
285
+ items_out: List[Dict[str, Any]] = []
286
  if parsed and isinstance(parsed.get("items"), list) and len(parsed["items"])>0:
287
  for it in parsed["items"]:
288
  try:
289
  label = str(it.get("label","unknown"))[:48]
290
  bbox = it.get("bbox",{})
291
+ nx = float(bbox.get("x",0))
292
+ ny = float(bbox.get("y",0))
293
+ nw = float(bbox.get("w",0))
294
+ nh = float(bbox.get("h",0))
295
  nx = max(0.0, min(1.0, nx)); ny = max(0.0,min(1.0,ny))
296
  nw = max(0.0, min(1.0, nw)); nh = max(0.0, min(1.0, nh))
297
  px = int(nx * img_w); py = int(ny * img_h)
 
315
  log.info("Gemini returned no items or parse failed — using fallback contour crops.")
316
  items_out = fallback_contour_crops(bgr_img, max_items=8)
317
 
318
+ # === AUTO-UPLOAD all detection thumbnails to Firebase Storage (temporary, marked by session_id) ===
319
  if FIREBASE_ADMIN_JSON and FIREBASE_ADMIN_AVAILABLE:
320
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
321
  for itm in items_out:
 
325
  item_id = itm.get("id") or str(uuid.uuid4())
326
  path = f"detected/{safe_uid}/{item_id}.jpg"
327
  try:
328
+ meta = {
329
+ "tmp": "true",
330
+ "session_id": session_id,
331
+ "uploaded_by": safe_uid,
332
+ "uploaded_at": str(int(time.time()))
333
+ }
334
+ url = upload_b64_to_firebase(b64, path, content_type="image/jpeg", metadata=meta)
335
  itm["thumbnail_url"] = url
336
+ # remove raw base64 to keep response small
337
  itm.pop("thumbnail_b64", None)
338
+ # add session marker to response item
339
+ itm["_session_id"] = session_id
340
+ log.debug("Auto-uploaded thumbnail for %s -> %s (session=%s)", item_id, url, session_id)
341
  except Exception as up_e:
342
  log.warning("Auto-upload failed for %s: %s", item_id, up_e)
343
+ # leave thumbnail_b64 for client fallback display
344
  else:
345
  if not FIREBASE_ADMIN_JSON:
346
  log.info("FIREBASE_ADMIN_JSON not set; skipping server-side thumbnail upload.")
347
  else:
348
  log.info("Firebase admin SDK not available; skipping server-side thumbnail upload.")
349
 
350
+ return jsonify({"ok": True, "items": items_out, "session_id": session_id, "debug": {"raw_model_text": raw_text[:1600]}}), 200
351
 
352
  except Exception as ex:
353
  log.exception("Processing error: %s", ex)
354
  try:
355
  items_out = fallback_contour_crops(bgr_img, max_items=8)
356
+ return jsonify({"ok": True, "items": items_out, "session_id": session_id, "debug": {"error": str(ex)}}), 200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  except Exception as e2:
358
  log.exception("Fallback also failed: %s", e2)
359
  return jsonify({"error": "internal failure", "detail": str(e2)}), 500
360
 
361
+ # ---------- Finalize endpoint: keep selected and delete only session's temp files ----------
362
  @app.route("/finalize_detections", methods=["POST"])
363
  def finalize_detections():
364
  """
365
  Body JSON:
366
+ { "uid": "user123", "keep_ids": ["id1","id2",...], "session_id": "<session id from /process>" }
367
 
368
+ Server will delete only detected/<uid>/* files whose:
369
+ - metadata.tmp == "true"
370
+ - metadata.session_id == session_id
371
+ - item_id NOT in keep_ids
372
+
373
+ Returns:
374
+ { ok: True, kept: [...], deleted: [...], errors: [...] }
375
  """
376
  try:
377
  body = request.get_json(force=True)
 
380
 
381
  uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
382
  keep_ids = set(body.get("keep_ids") or [])
383
+ session_id = (body.get("session_id") or request.args.get("session_id") or "").strip()
384
+
385
+ if not session_id:
386
+ return jsonify({"error": "session_id required for finalize to avoid unsafe deletes"}), 400
387
 
388
  if not FIREBASE_ADMIN_JSON or not FIREBASE_ADMIN_AVAILABLE:
389
  return jsonify({"error": "firebase admin not configured"}), 500
390
 
391
+ try:
392
+ init_firebase_admin_if_needed()
393
+ bucket = fb_storage.bucket()
394
+ except Exception as e:
395
+ log.exception("Firebase init error in finalize: %s", e)
396
+ return jsonify({"error": "firebase admin init failed", "detail": str(e)}), 500
397
+
398
  safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
399
  prefix = f"detected/{safe_uid}/"
400
 
 
406
  blobs = list(bucket.list_blobs(prefix=prefix))
407
  for blob in blobs:
408
  try:
409
+ name = blob.name # e.g. "detected/<safe_uid>/<itemid>.jpg"
410
  fname = name.split("/")[-1]
411
+ # only accept files of form <id>.<ext>
412
+ if "." not in fname:
413
+ # skip unexpected filenames
414
+ continue
415
  item_id = fname.rsplit(".", 1)[0]
416
+
417
+ md = blob.metadata or {}
418
+ # only consider temporary files that match this session id
419
+ if str(md.get("session_id", "")) != session_id or str(md.get("tmp", "")).lower() not in ("true", "1", "yes"):
420
+ # skip (not part of this session / not temporary)
421
+ continue
422
+
423
  if item_id in keep_ids:
424
  # ensure public URL available if possible
425
  try:
 
429
  url = f"gs://{bucket.name}/{name}"
430
  kept.append({"id": item_id, "thumbnail_url": url})
431
  else:
432
+ # delete unkept (safe: only temporary and session-matching files)
433
  try:
434
  blob.delete()
435
  deleted.append(item_id)