Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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 |
-
#
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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)
|