Pepguy commited on
Commit
b08efa4
·
verified ·
1 Parent(s): 8efcfa2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +129 -16
app.py CHANGED
@@ -5,7 +5,7 @@ import json
5
  import base64
6
  import logging
7
  import uuid
8
- from typing import List, Dict, Any
9
 
10
  from flask import Flask, request, jsonify
11
  from flask_cors import CORS
@@ -17,6 +17,17 @@ import cv2
17
  from google import genai
18
  from google.genai import types
19
 
 
 
 
 
 
 
 
 
 
 
 
20
  logging.basicConfig(level=logging.INFO)
21
  log = logging.getLogger("wardrobe-server")
22
 
@@ -24,20 +35,91 @@ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
24
  if not GEMINI_API_KEY:
25
  log.warning("GEMINI_API_KEY not set — gemini calls will fail.")
26
 
 
 
 
 
 
 
 
27
  client = genai.Client(api_key=GEMINI_API_KEY)
28
 
29
  app = Flask(__name__)
30
  CORS(app)
31
 
32
- # Helper: read uploaded image bytes -> BGR numpy
33
- def read_image_bytes(file_storage) -> (np.ndarray, int, int, bytes):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  data = file_storage.read()
35
  img = Image.open(io.BytesIO(data)).convert("RGB")
36
  w, h = img.size
37
  arr = np.array(img)[:, :, ::-1] # RGB -> BGR
38
  return arr, w, h, data
39
 
40
- # Helper: crop bgr image by pixel rect and return base64 jpeg
41
  def crop_and_b64(bgr_img: np.ndarray, x: int, y: int, w: int, h: int, max_side=512) -> str:
42
  h_img, w_img = bgr_img.shape[:2]
43
  x = max(0, int(x)); y = max(0, int(y))
@@ -53,7 +135,6 @@ def crop_and_b64(bgr_img: np.ndarray, x: int, y: int, w: int, h: int, max_side=5
53
  _, jpeg = cv2.imencode(".jpg", crop, [int(cv2.IMWRITE_JPEG_QUALITY), 82])
54
  return base64.b64encode(jpeg.tobytes()).decode("ascii")
55
 
56
- # Fallback: simple contour detection cropping
57
  def fallback_contour_crops(bgr_img, max_items=8) -> List[Dict[str, Any]]:
58
  gray = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2GRAY)
59
  blur = cv2.GaussianBlur(gray, (7,7), 0)
@@ -105,12 +186,16 @@ def fallback_contour_crops(bgr_img, max_items=8) -> List[Dict[str, Any]]:
105
  })
106
  return items
107
 
108
- # Main route: process image using Gemini for detection -> crop thumbnails with OpenCV
109
  @app.route("/process", methods=["POST"])
110
  def process_image():
111
  if "photo" not in request.files:
112
  return jsonify({"error": "missing photo"}), 400
113
  file = request.files["photo"]
 
 
 
 
114
  try:
115
  bgr_img, img_w, img_h, raw_bytes = read_image_bytes(file)
116
  except Exception as e:
@@ -127,15 +212,12 @@ def process_image():
127
  "Output ONLY valid JSON. If you cannot detect any clothing confidently, return {\"items\":[]}."
128
  )
129
 
130
- # Prepare request contents: prompt text + image bytes
131
  try:
132
  contents = [
133
  types.Content(role="user", parts=[types.Part.from_text(text=user_prompt)])
134
  ]
135
- # include image bytes as a user part (like other examples)
136
  contents.append(types.Content(role="user", parts=[types.Part.from_bytes(data=raw_bytes, mime_type="image/jpeg")]))
137
 
138
- # Force JSON response schema: top-level object with items array
139
  schema = {
140
  "type": "object",
141
  "properties": {
@@ -166,13 +248,11 @@ def process_image():
166
 
167
  cfg = types.GenerateContentConfig(response_mime_type="application/json", response_schema=schema)
168
 
169
- # Call Gemini model
170
  log.info("Calling Gemini model for detection (gemini-2.5-flash-lite)...")
171
  model_resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents, config=cfg)
172
  raw_text = model_resp.text or ""
173
  log.info("Gemini raw response length: %d", len(raw_text))
174
 
175
- # Try parse JSON
176
  parsed = None
177
  try:
178
  parsed = json.loads(raw_text)
@@ -186,17 +266,14 @@ def process_image():
186
  try:
187
  label = str(it.get("label","unknown"))[:48]
188
  bbox = it.get("bbox",{})
189
- # bbox in normalized coords -> to pixels
190
  nx = float(bbox.get("x",0))
191
  ny = float(bbox.get("y",0))
192
  nw = float(bbox.get("w",0))
193
  nh = float(bbox.get("h",0))
194
- # clamp 0..1
195
  nx = max(0.0, min(1.0, nx)); ny = max(0.0,min(1.0,ny))
196
  nw = max(0.0, min(1.0, nw)); nh = max(0.0, min(1.0, nh))
197
  px = int(nx * img_w); py = int(ny * img_h)
198
  pw = int(nw * img_w); ph = int(nh * img_h)
199
- # guard tiny
200
  if pw <= 8 or ph <= 8:
201
  continue
202
  b64 = crop_and_b64(bgr_img, px, py, pw, ph)
@@ -213,17 +290,53 @@ def process_image():
213
  except Exception as e:
214
  log.warning("skipping item due to error: %s", e)
215
  else:
216
- # Fallback to contour heuristic
217
  log.info("Gemini returned no items or parse failed — using fallback contour crops.")
218
  items_out = fallback_contour_crops(bgr_img, max_items=8)
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  return jsonify({"ok": True, "items": items_out, "debug": {"raw_model_text": raw_text[:1600]}}), 200
221
 
222
  except Exception as ex:
223
  log.exception("Processing error: %s", ex)
224
- # final fallback: contour crops
225
  try:
226
  items_out = fallback_contour_crops(bgr_img, max_items=8)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  return jsonify({"ok": True, "items": items_out, "debug": {"error": str(ex)}}), 200
228
  except Exception as e2:
229
  log.exception("Fallback also failed: %s", e2)
 
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
11
  from flask_cors import CORS
 
17
  from google import genai
18
  from google.genai import types
19
 
20
+ # Firebase Admin (in-memory JSON init)
21
+ try:
22
+ import firebase_admin
23
+ from firebase_admin import credentials as fb_credentials, storage as fb_storage
24
+ FIREBASE_ADMIN_AVAILABLE = True
25
+ except Exception:
26
+ firebase_admin = None
27
+ fb_credentials = None
28
+ fb_storage = None
29
+ FIREBASE_ADMIN_AVAILABLE = False
30
+
31
  logging.basicConfig(level=logging.INFO)
32
  log = logging.getLogger("wardrobe-server")
33
 
 
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()
40
+ FIREBASE_STORAGE_BUCKET = os.getenv("FIREBASE_STORAGE_BUCKET", "").strip() # optional override
41
+
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
 
50
+ # ---------- Firebase init helpers ----------
51
+ _firebase_app = None
52
+
53
+ def init_firebase_admin_if_needed():
54
+ global _firebase_app
55
+ if _firebase_app is not None:
56
+ return _firebase_app
57
+ if not FIREBASE_ADMIN_JSON:
58
+ log.info("No FIREBASE_ADMIN_JSON env var set; skipping Firebase admin init.")
59
+ return None
60
+ if not FIREBASE_ADMIN_AVAILABLE:
61
+ raise RuntimeError("firebase-admin not installed (pip install firebase-admin)")
62
+
63
+ try:
64
+ sa_obj = json.loads(FIREBASE_ADMIN_JSON)
65
+ except Exception as e:
66
+ log.exception("Failed parsing FIREBASE_ADMIN_JSON: %s", e)
67
+ raise
68
+
69
+ # determine bucket
70
+ bucket_name = FIREBASE_STORAGE_BUCKET or sa_obj.get("project_id") and f"{sa_obj.get('project_id')}.appspot.com"
71
+ if not bucket_name:
72
+ raise RuntimeError("Could not determine storage bucket. Set FIREBASE_STORAGE_BUCKET or include project_id in service account JSON.")
73
+
74
+ try:
75
+ cred = fb_credentials.Certificate(sa_obj)
76
+ _firebase_app = firebase_admin.initialize_app(cred, {"storageBucket": bucket_name})
77
+ log.info("Initialized firebase admin with bucket: %s", bucket_name)
78
+ return _firebase_app
79
+ except Exception as e:
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:
89
+ raise RuntimeError("FIREBASE_ADMIN_JSON not set")
90
+ init_firebase_admin_if_needed()
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]
98
+ raw = raw.replace("\n", "").replace("\r", "")
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
108
+ except Exception as e:
109
+ log.warning("Could not make blob public: %s", e)
110
+ return f"gs://{bucket.name}/{path}"
111
+ except Exception as e:
112
+ log.exception("Firebase upload error for path %s: %s", path, e)
113
+ raise
114
+
115
+ # ---------- Image helpers (unchanged) ----------
116
+ def read_image_bytes(file_storage) -> Tuple[np.ndarray, int, int, bytes]:
117
  data = file_storage.read()
118
  img = Image.open(io.BytesIO(data)).convert("RGB")
119
  w, h = img.size
120
  arr = np.array(img)[:, :, ::-1] # RGB -> BGR
121
  return arr, w, h, data
122
 
 
123
  def crop_and_b64(bgr_img: np.ndarray, x: int, y: int, w: int, h: int, max_side=512) -> str:
124
  h_img, w_img = bgr_img.shape[:2]
125
  x = max(0, int(x)); y = max(0, int(y))
 
135
  _, jpeg = cv2.imencode(".jpg", crop, [int(cv2.IMWRITE_JPEG_QUALITY), 82])
136
  return base64.b64encode(jpeg.tobytes()).decode("ascii")
137
 
 
138
  def fallback_contour_crops(bgr_img, max_items=8) -> List[Dict[str, Any]]:
139
  gray = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2GRAY)
140
  blur = cv2.GaussianBlur(gray, (7,7), 0)
 
186
  })
187
  return items
188
 
189
+ # ---------- Main / processing ----------
190
  @app.route("/process", methods=["POST"])
191
  def process_image():
192
  if "photo" not in request.files:
193
  return jsonify({"error": "missing photo"}), 400
194
  file = request.files["photo"]
195
+
196
+ # optional uid from form fields (client can supply for grouping)
197
+ uid = (request.form.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
198
+
199
  try:
200
  bgr_img, img_w, img_h, raw_bytes = read_image_bytes(file)
201
  except Exception as e:
 
212
  "Output ONLY valid JSON. If you cannot detect any clothing confidently, return {\"items\":[]}."
213
  )
214
 
 
215
  try:
216
  contents = [
217
  types.Content(role="user", parts=[types.Part.from_text(text=user_prompt)])
218
  ]
 
219
  contents.append(types.Content(role="user", parts=[types.Part.from_bytes(data=raw_bytes, mime_type="image/jpeg")]))
220
 
 
221
  schema = {
222
  "type": "object",
223
  "properties": {
 
248
 
249
  cfg = types.GenerateContentConfig(response_mime_type="application/json", response_schema=schema)
250
 
 
251
  log.info("Calling Gemini model for detection (gemini-2.5-flash-lite)...")
252
  model_resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents, config=cfg)
253
  raw_text = model_resp.text or ""
254
  log.info("Gemini raw response length: %d", len(raw_text))
255
 
 
256
  parsed = None
257
  try:
258
  parsed = json.loads(raw_text)
 
266
  try:
267
  label = str(it.get("label","unknown"))[:48]
268
  bbox = it.get("bbox",{})
 
269
  nx = float(bbox.get("x",0))
270
  ny = float(bbox.get("y",0))
271
  nw = float(bbox.get("w",0))
272
  nh = float(bbox.get("h",0))
 
273
  nx = max(0.0, min(1.0, nx)); ny = max(0.0,min(1.0,ny))
274
  nw = max(0.0, min(1.0, nw)); nh = max(0.0, min(1.0, nh))
275
  px = int(nx * img_w); py = int(ny * img_h)
276
  pw = int(nw * img_w); ph = int(nh * img_h)
 
277
  if pw <= 8 or ph <= 8:
278
  continue
279
  b64 = crop_and_b64(bgr_img, px, py, pw, ph)
 
290
  except Exception as e:
291
  log.warning("skipping item due to error: %s", e)
292
  else:
 
293
  log.info("Gemini returned no items or parse failed — using fallback contour crops.")
294
  items_out = fallback_contour_crops(bgr_img, max_items=8)
295
 
296
+ # Try uploading thumbnails to Firebase Storage (if configured)
297
+ if FIREBASE_ADMIN_JSON and FIREBASE_ADMIN_AVAILABLE:
298
+ for itm in items_out:
299
+ b64 = itm.get("thumbnail_b64")
300
+ if not b64:
301
+ continue
302
+ item_id = itm.get("id") or str(uuid.uuid4())
303
+ safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
304
+ path = f"detected/{safe_uid}/{item_id}.jpg"
305
+ try:
306
+ url = upload_b64_to_firebase(b64, path, content_type="image/jpeg")
307
+ itm["thumbnail_url"] = url
308
+ # remove raw base64 to keep response small
309
+ itm.pop("thumbnail_b64", None)
310
+ log.debug("Uploaded thumbnail for %s -> %s", item_id, url)
311
+ except Exception as up_e:
312
+ log.warning("Failed to upload thumbnail for %s: %s", item_id, up_e)
313
+ # keep thumbnail_b64 as fallback
314
+ else:
315
+ if not FIREBASE_ADMIN_JSON:
316
+ log.info("FIREBASE_ADMIN_JSON not set; skipping server-side thumbnail upload.")
317
+ else:
318
+ log.info("Firebase admin SDK not available; skipping server-side thumbnail upload.")
319
+
320
  return jsonify({"ok": True, "items": items_out, "debug": {"raw_model_text": raw_text[:1600]}}), 200
321
 
322
  except Exception as ex:
323
  log.exception("Processing error: %s", ex)
 
324
  try:
325
  items_out = fallback_contour_crops(bgr_img, max_items=8)
326
+ if FIREBASE_ADMIN_JSON and FIREBASE_ADMIN_AVAILABLE:
327
+ for itm in items_out:
328
+ b64 = itm.get("thumbnail_b64")
329
+ if not b64:
330
+ continue
331
+ item_id = itm.get("id") or str(uuid.uuid4())
332
+ safe_uid = "".join(ch for ch in uid if ch.isalnum() or ch in ("-", "_")) or "anon"
333
+ path = f"detected/{safe_uid}/{item_id}.jpg"
334
+ try:
335
+ url = upload_b64_to_firebase(b64, path, content_type="image/jpeg")
336
+ itm["thumbnail_url"] = url
337
+ itm.pop("thumbnail_b64", None)
338
+ except Exception as up_e:
339
+ log.warning("Failed to upload fallback thumbnail for %s: %s", item_id, up_e)
340
  return jsonify({"ok": True, "items": items_out, "debug": {"error": str(ex)}}), 200
341
  except Exception as e2:
342
  log.exception("Fallback also failed: %s", e2)