import os import sys import json import uuid import datetime from html import escape from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageOps import gradio as gr from huggingface_hub import InferenceClient # ===== Gradio 6.18.0 compatibility fix for 'page' attribute errors ===== # The issue: Older Gradio versions' fill_expected_parents() tries to access child.page # which doesn't exist on components. Fix by ensuring all Block instances have page attr. # Patch Block.__init__ to initialize 'page' attribute _original_block_init = gr.blocks.Block.__init__ def _patched_block_init(self, *args, **kwargs): _original_block_init(self, *args, **kwargs) # Ensure every block has a 'page' attribute if not hasattr(self, 'page'): self.page = None gr.blocks.Block.__init__ = _patched_block_init # Patch the instance method fill_expected_parents only if it exists in this Gradio version _original_fill_expected = getattr(gr.blocks.Block, 'fill_expected_parents', None) if _original_fill_expected is not None: def _patched_fill_expected_parents(self): """Safely handle missing 'page' attribute""" try: for child in self.children: if hasattr(child, 'page') and child.page is not None: self.page = child.page return except Exception: pass # Silently ignore errors in page propagation gr.blocks.Block.fill_expected_parents = _patched_fill_expected_parents # ===== End compatibility fix ===== # For Spaces compatibility - detect environment early SPACE_ENV_VARS = ["SPACE_ID", "HUGGINGFACE_SPACE_ID", "HF_SPACE_ID"] IS_SPACES = any(os.getenv(v) for v in SPACE_ENV_VARS) FRONTEND_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(FRONTEND_DIR, "data") IMAGES_DIR = os.path.join(FRONTEND_DIR, "images") ORIGINAL_IMAGE_DIR = os.path.join(IMAGES_DIR, "original") EDITED_IMAGE_DIR = os.path.join(IMAGES_DIR, "edited") sys.path.append(FRONTEND_DIR) def fs_path(path): if not path: return None return path if os.path.isabs(path) else os.path.join(FRONTEND_DIR, path) def fs_exists(path): p = fs_path(path) return os.path.exists(p) if p else False def static_url(relative_path: str) -> str: """Return a Gradio-served URL for assets referenced from gr.HTML.""" rel = relative_path.replace("\\", "/").lstrip("/") return f"/gradio_api/file={rel}" def asset_url(url_or_path: str) -> str: if not url_or_path: return url_or_path if url_or_path.startswith(("http://", "https://", "data:", "/gradio_api/file=", "/file=")): return url_or_path path = url_or_path.split("?", 1)[0] url = static_url(path.lstrip("/")) if "?" in url_or_path: url = f"{url}?{url_or_path.split('?', 1)[1]}" return url IMG_FALLBACK = static_url("ui/image.png") try: from cards import process_image from attributes import extract_attributes from wardrobe import load_wardrobe, save_wardrobe, add_item from vision_model import get_model, get_processor import torch HAS_LOCAL_MODELS = True print("Loaded inference modules from small-hackathon.") except Exception as _e: HAS_LOCAL_MODELS = False print("Warning – local models unavailable:", _e) def load_wardrobe(): p = os.path.join(DATA_DIR, "wardrobe.json") return json.load(open(p, "r", encoding="utf-8")) if os.path.exists(p) else [] def save_wardrobe(data): with open(os.path.join(DATA_DIR, "wardrobe.json"), "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) def add_item(item): w = load_wardrobe(); w.append(item); save_wardrobe(w) def process_image(img): raise RuntimeError("process_image unavailable") def extract_attributes(img): raise RuntimeError("extract_attributes unavailable") # ── DB Setup ─────── GENERATED_CARD_DIR = os.path.join(FRONTEND_DIR, "static", "generated") try: for d in [DATA_DIR, ORIGINAL_IMAGE_DIR, EDITED_IMAGE_DIR, GENERATED_CARD_DIR]: os.makedirs(d, exist_ok=True) print(f"✓ Created directories: {DATA_DIR}") except Exception as e: print(f"✗ Directory creation failed: {e}") PROFILE_PATH = os.path.join(DATA_DIR, "profile.json") HISTORY_PATH = os.path.join(DATA_DIR, "history.json") FAVORITES_PATH = os.path.join(DATA_DIR, "favorites.json") PROFILE_BG_PATH = os.path.join(IMAGES_DIR, "profile_banner.jpg") print(f"DEBUG: PROFILE_PATH = {PROFILE_PATH}") print(f"DEBUG: HISTORY_PATH = {HISTORY_PATH}") print(f"DEBUG: FAVORITES_PATH = {FAVORITES_PATH}") def safe_init_json(path, default_data): """Safely initialize JSON file with default data.""" print(f"DEBUG: safe_init_json called for {path}") try: if not os.path.exists(path): print(f"DEBUG: Creating {path}") os.makedirs(os.path.dirname(path), exist_ok=True) print(f"DEBUG: Directory created, writing JSON to {path}") with open(path, "w", encoding="utf-8") as f: json.dump(default_data, f, indent=2, ensure_ascii=False) print(f"✓ Initialized {path}") else: print(f"DEBUG: {path} already exists, skipping") except Exception as e: print(f"✗ EXCEPTION in safe_init_json for {path}: {type(e).__name__}: {e}") import traceback traceback.print_exc() print("DEBUG: About to call safe_init_json for PROFILE_PATH") safe_init_json(PROFILE_PATH, {"name":"Sarah","bio":"Fashion lover who adores neutral tones and comfortable silhouettes.","twin_id":"0001"}) print("DEBUG: About to call safe_init_json for HISTORY_PATH") safe_init_json(HISTORY_PATH, []) print("DEBUG: About to call safe_init_json for FAVORITES_PATH") safe_init_json(FAVORITES_PATH, []) print("DEBUG: JSON initialization complete!") def load_history(): return json.load(open(HISTORY_PATH,"r",encoding="utf-8")) if os.path.exists(HISTORY_PATH) else [] def save_history(h): json.dump(h, open(HISTORY_PATH,"w",encoding="utf-8"), indent=2, ensure_ascii=False) def load_favorites(): return json.load(open(FAVORITES_PATH,"r",encoding="utf-8")) if os.path.exists(FAVORITES_PATH) else [] def save_favorites(f): json.dump(f, open(FAVORITES_PATH,"w",encoding="utf-8"), indent=2, ensure_ascii=False) def load_profile(): return json.load(open(PROFILE_PATH,"r",encoding="utf-8")) def save_profile(p): json.dump(p, open(PROFILE_PATH,"w",encoding="utf-8"), indent=2, ensure_ascii=False) # HuggingFace client print("DEBUG: Reading HF_TOKEN environment variable") HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN") print(f"DEBUG: HF_TOKEN set: {bool(HF_TOKEN)}") print("DEBUG: About to create InferenceClient") try: client = InferenceClient(api_key=HF_TOKEN) print("DEBUG: InferenceClient created successfully") except Exception as e: print(f"ERROR creating InferenceClient: {type(e).__name__}: {e}") import traceback traceback.print_exc() client = None def get_style_profile(): history = load_history() likes = [h for h in history if h.get("rating") == "like"] if not likes: return load_profile().get("bio","") wm = {i["id"]:i for i in load_wardrobe()} from collections import Counter colors, fits, occasions = [], [], [] for l in likes: occasions.append(l.get("situation",{}).get("occasion","casual")) for iid in l.get("outfit",{}).get("items",{}).values(): if iid and iid in wm: c = wm[iid].get("color",[]) colors += (c if isinstance(c,list) else [c]) f = wm[iid].get("fit","") if f and f != "unknown": fits.append(f) tc = [x[0] for x in Counter(colors).most_common(2)] tf = [x[0] for x in Counter(fits).most_common(1)] to = [x[0] for x in Counter(occasions).most_common(1)] return f"Prefers {', '.join(tc) or 'neutral'} tones, {tf[0] if tf else 'comfortable'} fits, and {to[0] if to else 'casual'} occasions." # ── HTML Builders ── def img_src(item_id): if fs_exists(f"images/edited/{item_id}_edited.png"): return static_url(f"images/edited/{item_id}_edited.png") return static_url(f"images/original/{item_id}.png") def normalize_item_type(value, default="item"): if isinstance(value, list): return ", ".join(str(v) for v in value if v) if value is None: return default text = str(value).strip() if (text.startswith("['") and text.endswith("']")) or (text.startswith('["') and text.endswith('"]')): text = text[2:-2] return text.strip("[] ") or default def render_wardrobe_html(cat="all", selected_id=None): items = load_wardrobe() if cat != "all": items = [i for i in items if i.get("category")==cat] if not items: return "
Your wardrobe is empty. Upload your first garment to get started.
" html = '
' def _clean(v): if isinstance(v, list): return v[0] if v else "" return str(v).replace("['", "").replace("']", "").replace('["', '').replace('"]', '').strip("[]") for item in items: col = ", ".join(item.get("color",[]) if isinstance(item.get("color"),list) else [item.get("color","—")]) selected = item['id'] == selected_id badge = "
selected
" if selected else "" highlight = "box-shadow: 0 0 0 2px rgba(179,116,128,.3); border-color: var(--ct-pink);" if selected else "" html += f"""
{badge}
{normalize_item_type(item.get('type','item'))}
{col} · {_clean(item.get('style','—'))} · {_clean(item.get('fit','—'))}
""" return html + "
" def format_item_choice(item): cols = item.get("color", []) if not isinstance(cols, list): cols = [cols] if cols else [] color_label = ", ".join([c for c in cols if c]) or "unknown" return f"{normalize_item_type(item.get('type','item'))} ({color_label})" def item_choices(cat="all"): items = load_wardrobe() if cat != "all": items = [i for i in items if i.get("category") == cat] return [(format_item_choice(i), i['id']) for i in items] def item_has_image(item): item_id = item.get("id") image_clean = item.get("image_clean") or "" image_original = item.get("image_original") or "" return ( fs_exists(image_clean) or fs_exists(image_original) or fs_exists(f"images/edited/{item_id}_edited.png") or fs_exists(f"images/original/{item_id}.png") ) def item_kind(item): category = str(item.get("category", "")).lower() item_type = item.get("type", "") if isinstance(item_type, list): item_type = " ".join(str(t) for t in item_type) item_type = str(item_type).lower() if category == "dress" or "dress" in item_type: return "dress" return category def slot_for_item(item): kind = item_kind(item) if kind in {"top", "bottom", "dress", "shoes", "outerwear", "accessory"}: return kind return None def describe_item(item): item_type = item.get("type", item.get("category", "item")) if isinstance(item_type, list): item_type = ", ".join(str(t) for t in item_type) colors = item.get("color", []) if not isinstance(colors, list): colors = [colors] if colors else [] color_text = ", ".join(str(c) for c in colors if c) or "neutral" pattern = item.get("pattern", "solid") style = item.get("style", "simple") type_lower = str(item_type).lower() if any(str(c).lower() in type_lower for c in colors if c): color_text = "" color_prefix = f"{color_text} " if color_text else "" return f"{color_prefix}{item_type}, {pattern}, {style}" def build_flux_prompt(outfit, wm): item_descriptions = [] for slot, item_id in outfit.get("items", {}).items(): if item_id and item_id in wm: item_descriptions.append(f"{slot}: {describe_item(wm[item_id])}") energy = outfit.get("energy_detected", "effortless, wearable, real closet") styling = outfit.get("styling_note", "simple relaxed styling") return ( "Pinterest-style outfit layout card, no model body, flat lay composition, " "clean editorial wardrobe collage using only these real owned closet pieces: " + "; ".join(item_descriptions) + f". Energy: {energy}. Styling note: {styling}. " "Soft natural light, cohesive color palette, tasteful spacing, magazine layout, " "no shopping labels, no prices, no extra clothing items, no body-shaming text." ) def selected_item_descriptions(items, wm): descriptions = [] for slot in ["dress", "top", "bottom", "outerwear", "shoes", "accessory"]: item_id = items.get(slot) if item_id and item_id in wm: descriptions.append((slot, describe_item(wm[item_id]))) return descriptions def build_clean_reasoning(outfit, wm, adjusted=False): descriptions = selected_item_descriptions(outfit.get("items", {}), wm) if descriptions: pieces = ", ".join(desc for _slot, desc in descriptions) else: pieces = "the selected real closet pieces" energy = outfit.get("energy_detected", "wearable, closet-based") reason = ( f"This keeps the {energy} energy using only these saved closet items: {pieces}. " "The pieces are balanced as one wearable decision, without adding anything new." ) if adjusted: reason += " No-buy check adjusted the outfit to remove invented or conflicting pieces." return reason def build_clean_styling_note(outfit, wm): items = outfit.get("items", {}) if items.get("dress"): return "Let the dress be the main piece; keep accessories simple so it still feels easy." if items.get("outerwear"): return "Wear the outer layer open for a relaxed, put-together shape." if items.get("top") and items.get("bottom"): return "Keep the top relaxed with the bottom sitting naturally at the waist." return "Keep the styling simple and intentional." def refresh_outfit(outfit, wm, adjusted=False): refreshed = { "name": outfit.get("name") or "Closet Twin outfit", "items": {slot: outfit.get("items", {}).get(slot) for slot in ["dress", "top", "bottom", "shoes", "outerwear", "accessory"]}, "energy_detected": outfit.get("energy_detected", "wearable, closet-based"), } if refreshed["items"].get("dress"): refreshed["items"]["top"] = None refreshed["items"]["bottom"] = None refreshed["reasoning"] = build_clean_reasoning(refreshed, wm, adjusted) refreshed["styling_note"] = build_clean_styling_note(refreshed, wm) refreshed["card_prompt"] = build_flux_prompt(refreshed, wm) refreshed["flux_prompt"] = refreshed["card_prompt"] return refreshed def text_terms(value): if isinstance(value, list): value = " ".join(str(v) for v in value) text = str(value or "").lower() for char in ["|", ",", "/", "(", ")", ".", ":", ";"]: text = text.replace(char, " ") text = text.replace("-", " ") return set(text.split()) def item_search_text(item): fields = [ item.get("category", ""), item.get("type", ""), item.get("color", ""), item.get("pattern", ""), item.get("style", ""), item.get("fit", ""), item.get("season", ""), item.get("description", ""), ] return " ".join(" ".join(v) if isinstance(v, list) else str(v) for v in fields).lower() COLOR_WORDS = [ "black", "white", "cream", "beige", "brown", "gray", "grey", "blue", "green", "olive", "sage", "khaki", "yellow", "pink", "red", "navy", "pastel", "silver", "metallic", "champagne", ] STYLE_WORDS = [ "casual", "romantic", "tailored", "polished", "soft", "minimal", "bohemian", "sporty", "elegant", "formal", "vintage", "cozy", "effortless", "structured", "relaxed", "feminine", ] COLOR_FAMILIES = [ {"green", "olive", "sage", "khaki"}, {"beige", "cream", "champagne", "khaki", "tan"}, {"brown", "dark brown", "tan"}, {"gray", "grey", "silver", "metallic"}, {"blue", "light blue", "navy"}, {"black"}, {"white", "cream"}, ] def colors_are_related(ref_color, item_color): ref_color = str(ref_color or "").lower() item_color = str(item_color or "").lower() if not ref_color or not item_color: return False if ref_color in item_color or item_color in ref_color: return True return any(ref_color in family and item_color in family for family in COLOR_FAMILIES) def classify_reference_pixel(r, g, b): maxc, minc = max(r, g, b), min(r, g, b) if maxc > 242 and (maxc - minc) < 24: return None if maxc < 55: return "black" if maxc > 170 and (maxc - minc) < 30: return "silver" if r > 95 and g > 72 and b < 65 and r >= g: return "brown" if g >= r - 8 and g > b + 12: if maxc < 155: return "olive" return "sage" if r > 165 and g > 145 and b > 105: return "beige" if b > r + 18 and b > g + 10: return "blue" if r > 160 and b > 120 and g < 150: return "pink" return None def analyze_reference_image_fallback(img, note=""): small = img.convert("RGB") small.thumbnail((96, 96)) counts = {} for r, g, b in small.getdata(): color = classify_reference_pixel(r, g, b) if color: counts[color] = counts.get(color, 0) + 1 colors = [name for name, _count in sorted(counts.items(), key=lambda pair: pair[1], reverse=True)[:5]] note_text = str(note or "").lower() slots = [] if "dress" in note_text: slots.append("dress") else: if any(word in note_text for word in ["top", "tank", "shirt", "tee", "blouse", "cami"]): slots.append("top") if any(word in note_text for word in ["pants", "trouser", "jeans", "skirt", "shorts"]): slots.append("bottom") if any(word in note_text for word in ["sandal", "shoe", "sneaker", "flip"]): slots.append("shoes") if any(word in note_text for word in ["watch", "bag", "jewelry", "accessory", "silver"]): slots.append("accessory") if any(word in note_text for word in ["blazer", "jacket", "coat", "cardigan", "outerwear"]): slots.append("outerwear") if not slots: slots = ["top", "bottom", "shoes", "accessory"] description_bits = [] if colors: description_bits.append(", ".join(colors) + " tones") description_bits.append(note or "flat-lay reference outfit") return { "description": "reference outfit with " + " and ".join(description_bits), "style": note or "minimal relaxed casual effortless", "color": colors, "slots": list(dict.fromkeys(slots)), } def normalize_reference_details(details=None, note=""): details = details or {} note_text = str(note or "").lower() colors = details.get("color", []) if not isinstance(colors, list): colors = [colors] if colors else [] colors = [str(c).lower() for c in colors if c] colors += [c for c in COLOR_WORDS if c in note_text and c not in colors] style = details.get("style", "") style_terms = {term for term in text_terms(style) if term in STYLE_WORDS} style_terms |= {s for s in STYLE_WORDS if s in note_text} category = str(details.get("category", "") or "").lower() if "dress" in note_text: category = "dress" elif any(word in note_text for word in ["pants", "jeans", "skirt", "shorts"]): category = "bottom" elif any(word in note_text for word in ["blazer", "jacket", "coat"]): category = "outerwear" elif any(word in note_text for word in ["top", "blouse", "shirt", "tee", "cami"]): category = "top" slots = details.get("slots", []) if not isinstance(slots, list): slots = [slots] if slots else [] slots = [str(slot).lower() for slot in slots if slot] if "dress" in note_text and "dress" not in slots: slots.append("dress") if any(word in note_text for word in ["top", "tank", "blouse", "shirt", "tee", "cami"]) and "top" not in slots: slots.append("top") if any(word in note_text for word in ["pants", "trouser", "jeans", "skirt", "shorts"]) and "bottom" not in slots: slots.append("bottom") if any(word in note_text for word in ["sandal", "shoe", "sneaker", "flip"]) and "shoes" not in slots: slots.append("shoes") if any(word in note_text for word in ["watch", "bag", "jewelry", "accessory", "silver"]) and "accessory" not in slots: slots.append("accessory") if any(word in note_text for word in ["blazer", "jacket", "coat", "cardigan", "outerwear"]) and "outerwear" not in slots: slots.append("outerwear") ref_type = details.get("type", "") if isinstance(ref_type, list): ref_type = " ".join(str(t) for t in ref_type) ref_text = " ".join([ str(details.get("description", "")), str(ref_type), str(style), note_text, ]).lower() return { "category": category, "type": str(ref_type or "").lower(), "colors": list(dict.fromkeys(colors)), "style_terms": style_terms, "text": ref_text, "description": details.get("description") or note or "reference look", "slots": list(dict.fromkeys(slots)), } def score_item_against_reference(item, ref, preferred_slot=None): score = 0 kind = slot_for_item(item) if preferred_slot and kind == preferred_slot: score += 5 if ref.get("category") and kind == ref.get("category"): score += 4 item_text = item_search_text(item) item_colors = [str(c).lower() for c in (item.get("color", []) if isinstance(item.get("color"), list) else [item.get("color", "")])] score += 3 * sum( 1 for color in ref.get("colors", []) if any(colors_are_related(color, item_color) for item_color in item_colors) or color in item_text ) score += 2 * len(text_terms(item.get("style", "")) & ref.get("style_terms", set())) ref_terms = text_terms(ref.get("text", "")) item_terms = text_terms(item_search_text(item)) score += min(6, len(item_terms & ref_terms)) if kind == "top" and ref_terms & {"tank", "cami", "camisole"}: score += 4 if item_terms & {"tank", "cami", "camisole", "sleeveless"} else -2 if kind == "bottom" and ref_terms & {"pants", "trouser", "trousers", "wide", "leg"}: score += 5 if item_terms & {"pants", "trouser", "trousers", "wide", "leg", "jeans"} else -3 if item_terms & {"skirt", "shorts"}: score -= 4 if kind == "bottom" and "skirt" in ref_terms: score += 4 if "skirt" in item_terms else -2 if kind == "shoes" and ref_terms & {"sandal", "sandals", "flip", "slides"}: score += 4 if item_terms & {"sandal", "sandals", "slide", "slides", "flip"} else -1 if kind == "accessory" and ref_terms & {"watch", "silver", "jewelry"}: score += 4 if item_terms & {"watch", "silver", "jewelry", "metallic"} else -1 if item_has_image(item): score += 2 return score def best_items_for_slot(wardrobe, ref, slot, used=None, limit=3): used = used or set() candidates = [] for item in wardrobe: if item.get("id") in used or not item_has_image(item): continue if slot_for_item(item) != slot: continue candidates.append((score_item_against_reference(item, ref, slot), item)) candidates.sort(key=lambda pair: (-pair[0], pair[1].get("created_at", ""))) return [item for score, item in candidates[:limit] if score > 0] or [item for _score, item in candidates[:limit]] def build_recreated_outfit(wardrobe, ref, variation_idx=0): used = set() items = {slot: None for slot in ["dress", "top", "bottom", "shoes", "outerwear", "accessory"]} requested_slots = set(ref.get("slots") or ["top", "bottom", "shoes", "accessory"]) def get_match(slot): matches = best_items_for_slot(wardrobe, ref, slot, used, limit=variation_idx + 1) return matches[variation_idx] if len(matches) > variation_idx else (matches[-1] if matches else None) prefer_dress = "dress" in requested_slots or ref.get("category") == "dress" or "dress" in ref.get("text", "") if prefer_dress: dress = get_match("dress") if dress: items["dress"] = dress["id"] used.add(dress["id"]) if not items["dress"]: for slot in ["top", "bottom"]: if slot not in requested_slots: continue match = get_match(slot) if match: items[slot] = match["id"] used.add(match["id"]) for slot in ["outerwear", "shoes", "accessory"]: if slot not in requested_slots: continue match = get_match(slot) if match: items[slot] = match["id"] used.add(match["id"]) outfit = { "name": "Recreated closet look", "items": items, "energy_detected": ", ".join(sorted(ref.get("style_terms") or [])) or "same energy, real closet", } return refresh_outfit(outfit, {i["id"]: i for i in wardrobe}, adjusted=False) def closet_gap_notes(outfit, ref, wm): ref_text = ref.get("text", "") notes = [] bottom = wm.get(outfit.get("items", {}).get("bottom")) shoes = wm.get(outfit.get("items", {}).get("shoes")) accessory = wm.get(outfit.get("items", {}).get("accessory")) if any(word in ref_text for word in ["sage", "khaki", "olive"]) and bottom: bottom_colors = bottom.get("color", []) if not isinstance(bottom_colors, list): bottom_colors = [bottom_colors] if not any(colors_are_related(color, c) for color in ["sage", "khaki", "olive"] for c in bottom_colors): notes.append(f"No sage/olive wide-leg bottom is saved, so I used the closest relaxed bottom: {bottom.get('type', 'bottom')}.") if any(word in ref_text for word in ["sandal", "sandals", "flip", "slides"]) and shoes: shoe_text = item_search_text(shoes) if not any(word in shoe_text for word in ["sandal", "sandals", "flip", "slides"]): notes.append(f"No brown sandals are saved, so I used the available shoes: {shoes.get('type', 'shoes')}.") if any(word in ref_text for word in ["watch", "silver", "jewelry"]) and accessory: accessory_text = item_search_text(accessory) if not any(word in accessory_text for word in ["watch", "silver", "metallic", "jewelry"]): notes.append(f"No silver watch is saved, so I used the closest simple accessory: {accessory.get('type', 'accessory')}.") if not notes: return "" return "
Closest closet match:
" + "
".join(escape(note) for note in notes) + "
" def item_image_path(item_id, wm=None): item = (wm or {}).get(item_id, {}) candidates = [ item.get("image_clean") or "", item.get("image_original") or "", f"images/edited/{item_id}_edited.png", f"images/original/{item_id}.png", ] for path in candidates: if path and fs_exists(path): return fs_path(path) return None def load_card_font(size, bold=False): font_names = [ "C:/Windows/Fonts/georgiab.ttf" if bold else "C:/Windows/Fonts/georgia.ttf", "C:/Windows/Fonts/arialbd.ttf" if bold else "C:/Windows/Fonts/arial.ttf", ] for font_name in font_names: try: return ImageFont.truetype(font_name, size) except Exception: pass return ImageFont.load_default() def draw_wrapped(draw, text, xy, font, fill, max_width, line_gap=6): words = str(text or "").split() if not words: return xy[1] x, y = xy line = "" for word in words: trial = (line + " " + word).strip() bbox = draw.textbbox((x, y), trial, font=font) if bbox[2] - bbox[0] <= max_width or not line: line = trial else: draw.text((x, y), line, font=font, fill=fill) y += (bbox[3] - bbox[1]) + line_gap line = word if line: draw.text((x, y), line, font=font, fill=fill) bbox = draw.textbbox((x, y), line, font=font) y += (bbox[3] - bbox[1]) + line_gap return y def make_product_cutout(image_path): img = Image.open(image_path).convert("RGBA") rgb = img.convert("RGB") corners = [ rgb.getpixel((0, 0)), rgb.getpixel((rgb.width - 1, 0)), rgb.getpixel((0, rgb.height - 1)), rgb.getpixel((rgb.width - 1, rgb.height - 1)), ] bg = tuple(sum(c[i] for c in corners) // len(corners) for i in range(3)) def is_background(px): r, g, b, a = px if a == 0: return True dist = ((r - bg[0]) ** 2 + (g - bg[1]) ** 2 + (b - bg[2]) ** 2) ** 0.5 maxc, minc = max(r, g, b), min(r, g, b) return dist < 44 or (maxc > 242 and (maxc - minc) < 22) pix = img.load() width, height = img.size stack = [] seen = set() for x in range(width): stack.append((x, 0)) stack.append((x, height - 1)) for y in range(height): stack.append((0, y)) stack.append((width - 1, y)) while stack: x, y = stack.pop() if (x, y) in seen or x < 0 or y < 0 or x >= width or y >= height: continue seen.add((x, y)) if not is_background(pix[x, y]): continue r, g, b, _a = pix[x, y] pix[x, y] = (r, g, b, 0) stack.extend(((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1))) bbox = img.getbbox() return img.crop(bbox) if bbox else img def paste_flatlay_item(canvas, image_path, center, max_size, angle=0, shadow=True): img = make_product_cutout(image_path) img = ImageOps.contain(img, max_size) if angle: img = img.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC) x = int(center[0] - img.width / 2) y = int(center[1] - img.height / 2) if shadow: alpha = img.getchannel("A") shadow_img = Image.new("RGBA", img.size, (0, 0, 0, 34)) shadow_img.putalpha(alpha.filter(ImageFilter.GaussianBlur(7))) canvas.alpha_composite(shadow_img, (x + 10, y + 12)) canvas.alpha_composite(img, (x, y)) def create_outfit_collage(outfit, wm): items = outfit.get("items", {}) paths = { slot: item_image_path(item_id, wm) if item_id else None for slot, item_id in items.items() } paths = {slot: path for slot, path in paths.items() if path} if not paths: raise ValueError("No selected outfit items have usable images.") width, height = 900, 1200 canvas = Image.new("RGBA", (width, height), (255, 255, 255, 255)) if paths.get("dress"): paste_flatlay_item(canvas, paths["dress"], (520, 500), (390, 900), shadow=True) if paths.get("outerwear"): paste_flatlay_item(canvas, paths["outerwear"], (610, 315), (330, 420), angle=2, shadow=True) else: if paths.get("outerwear"): paste_flatlay_item(canvas, paths["outerwear"], (575, 250), (340, 430), angle=2, shadow=True) if paths.get("top"): paste_flatlay_item(canvas, paths["top"], (530, 250), (360, 430), shadow=True) if paths.get("bottom"): paste_flatlay_item(canvas, paths["bottom"], (540, 645), (430, 780), shadow=True) if paths.get("shoes"): paste_flatlay_item(canvas, paths["shoes"], (260, 875), (330, 360), angle=-14, shadow=True) if paths.get("accessory"): paste_flatlay_item(canvas, paths["accessory"], (250, 450), (280, 340), angle=-4, shadow=True) card_id = str(uuid.uuid4()) card_path = os.path.join(GENERATED_CARD_DIR, f"{card_id}.png") canvas.convert("RGB").save(card_path, quality=95) return card_path, static_url(f"static/generated/{card_id}.png"), len(paths) def sanitize_outfits(outfits, wardrobe, anchor=None): wm = {item["id"]: item for item in wardrobe} clean_outfits = [] allowed_slots = ["dress", "top", "bottom", "shoes", "outerwear", "accessory"] for idx, outfit in enumerate(outfits[:3]): raw_items = outfit.get("items", {}) if isinstance(outfit, dict) else {} clean_items = {slot: None for slot in allowed_slots} changed = False for slot, item_id in raw_items.items(): if item_id in [None, "", "null"]: continue if item_id not in wm: changed = True continue item = wm[item_id] correct_slot = slot_for_item(item) if correct_slot is None: changed = True continue if correct_slot != slot: changed = True if correct_slot == "dress": clean_items["dress"] = item_id clean_items["top"] = None clean_items["bottom"] = None elif clean_items.get(correct_slot) is None: clean_items[correct_slot] = item_id if clean_items.get("dress"): clean_items["top"] = None clean_items["bottom"] = None if anchor and anchor in wm and anchor not in clean_items.values(): anchor_slot = slot_for_item(wm[anchor]) if anchor_slot: clean_items[anchor_slot] = anchor if item_kind(wm[anchor]) == "dress": clean_items["top"] = None clean_items["bottom"] = None changed = True if clean_items.get("dress"): clean_items["top"] = None clean_items["bottom"] = None if not any(clean_items.values()): # LLM hallucinated IDs – build a fallback outfit from real wardrobe items slots_order = ["top", "bottom", "shoes", "accessory", "outerwear", "dress"] for slot in slots_order: candidates = [item for item in wardrobe if slot_for_item(item) == slot and item_has_image(item) and item["id"] not in clean_items.values()] if candidates: best = sorted(candidates, key=lambda i: i.get("created_at",""), reverse=True)[0] clean_items[slot] = best["id"] break if not any(clean_items.values()): continue clean_outfit = { "name": outfit.get("name") or f"Outfit {idx + 1}", "items": clean_items, "energy_detected": outfit.get("energy_detected", "wearable, closet-based"), } clean_outfit["reasoning"] = build_clean_reasoning(clean_outfit, wm, changed) clean_outfit["styling_note"] = build_clean_styling_note(clean_outfit, wm) clean_outfit["card_prompt"] = build_flux_prompt(clean_outfit, wm) clean_outfit["flux_prompt"] = clean_outfit["card_prompt"] clean_outfits.append(clean_outfit) return clean_outfits def ensure_three_outfits(outfits, wardrobe, anchor=None): wm = {item["id"]: item for item in wardrobe} slots = ["dress", "top", "bottom", "shoes", "outerwear", "accessory"] results = list(outfits or [])[:3] signatures = { tuple((outfit.get("items") or {}).get(slot) for slot in slots) for outfit in results } pools = { slot: [item for item in wardrobe if slot_for_item(item) == slot and item_has_image(item)] for slot in slots } fallback_names = ["Safest wearable option", "Softer option", "More polished option"] fallback_energy = [ "easy, wearable, real closet", "soft, relaxed, closet-based", "polished, intentional, no-buy", ] def build_candidate(offset): items = {slot: None for slot in slots} if anchor and anchor in wm: anchor_slot = slot_for_item(wm[anchor]) if anchor_slot: items[anchor_slot] = anchor if items.get("dress"): fill_slots = ["shoes", "accessory", "outerwear"] else: fill_slots = ["top", "bottom", "shoes", "accessory", "outerwear"] for slot in fill_slots: if items.get(slot): continue choices = [item for item in pools.get(slot, []) if item["id"] not in items.values()] if choices: items[slot] = choices[offset % len(choices)]["id"] if not (items.get("dress") or items.get("top") or items.get("bottom")): choices = [item for item in pools.get("dress", []) if item["id"] not in items.values()] if choices: items["dress"] = choices[offset % len(choices)]["id"] items["top"] = None items["bottom"] = None if not any(items.values()): return None idx = len(results) % 3 outfit = { "name": fallback_names[idx], "items": items, "energy_detected": fallback_energy[idx], } return refresh_outfit(outfit, wm, adjusted=True) offset = 0 while len(results) < 3 and offset < max(12, len(wardrobe) * 3): candidate = build_candidate(offset) offset += 1 if not candidate: continue signature = tuple(candidate.get("items", {}).get(slot) for slot in slots) if signature in signatures and len(wardrobe) > 1: continue signatures.add(signature) results.append(candidate) while results and len(results) < 3: idx = len(results) % len(results) clone = json.loads(json.dumps(results[idx])) label_idx = len(results) % 3 clone["name"] = fallback_names[label_idx] clone["energy_detected"] = fallback_energy[label_idx] results.append(refresh_outfit(clone, wm, adjusted=True)) return results[:3] def favorite_choices(): return [(fav.get('name','Saved Look'), fav.get('id')) for fav in load_favorites()] def parse_item_id(selection): if not selection: return None # Support backward compatibility if someone had an old string saved if " — " in selection: return selection.split(" — ", 1)[0] return selection def render_saved_cards_html(active_idx=0): cards = [fav for fav in load_favorites() if fav.get("card_url")] if not cards: return "
No outfit cards saved yet. Generate a card from the stylist page, then save it here.
" total = len(cards) slides_html = "" for i, fav in enumerate(reversed(cards)): name = escape(fav.get("name") or "Saved outfit card") energy = escape(fav.get("energy_detected") or "closet-based") styling = escape(fav.get("styling_note") or "") url = asset_url(fav.get("card_url")) saved_at = escape(fav.get("card_saved_at") or fav.get("saved_at") or "") item_count = len([item_id for item_id in fav.get("items", {}).values() if item_id]) active = "block" if i == int(active_idx or 0) else "none" slides_html += f"""
{name} {item_count} pieces
{name}
{energy}
{f'
{styling}
' if styling else ''}
{saved_at}
Download Card
""" uid = "ct_saved_carousel" return f"""
outfit cards {int(active_idx or 0) + 1} / {total}
{slides_html}
""" def render_outfits_html(outfits, wm, active_idx=0): if not outfits: return """
What are we dressing for?

Describe the vibe or upload an inspiration look. Closet Twin will choose from saved wardrobe pieces only.

""" active_idx = max(0, min(active_idx, len(outfits) - 1)) cards = [] for idx, o in enumerate(outfits): display = 'block' if idx == active_idx else 'none' items_html = "" chips_html = "" for slot, iid in o.get("items", {}).items(): if iid and iid in wm: wi = wm[iid] items_html += f"""
{escape(normalize_item_type(wi.get('type', slot)))} {escape(str(slot))}
""" chips_html += f""" {escape(normalize_item_type(wi.get('type', slot)))} {escape(str(slot))} """ cards.append(f"""
energy detected: {escape(str(o.get('energy_detected','wearable, closet-based')))}
wear this
{items_html}
"{escape(str(o.get('name','Closet Twin look'))).lower()}"
garments
{chips_html}
why this works

{escape(str(o.get('reasoning','')))}

styling note

{escape(str(o.get('styling_note','Keep the styling simple and intentional.')))}

""" ) return f""" """ def render_favorites_html(): favs = load_favorites(); wm = {i["id"]:i for i in load_wardrobe()} if not favs: return "
No saved outfits yet.
" html = "" for fav in favs: items_html = "".join(f"""
{normalize_item_type(wm[iid].get('type',slot)) if iid in wm else slot}
""" for slot,iid in fav.get("items",{}).items() if iid) html += f"""
{fav.get('name','Saved Look')} {fav.get('saved_at','')}
{items_html}
{fav.get('reasoning','')}
""" return html def render_history_html(): history = load_history() if not history: return "
No styling history yet.
" html = "
" for idx, h in enumerate(reversed(history)): rating = h.get("rating","generated") col = "#315F9D" if rating=="like" else "#930500" if rating=="dislike" else "#7d2b27" label_icon = "↻" if rating=="like" else "x" if rating=="dislike" else "+" detail_id = f"ct-hist-detail-{idx}" html += f"""
{h.get('timestamp','')[:10]} — {h.get('situation',{}).get('occasion','Casual')}
{label_icon} {rating}
{h.get('outfit',{}).get('name','Outfit')}
Weather: {h.get('situation',{}).get('weather','—')} · Mood: {h.get('situation',{}).get('mood','—')}
""" return html + "
" def render_analytics_html(): wardrobe = load_wardrobe(); history = load_history() total = len(wardrobe) cats, cols = {}, {} for item in wardrobe: cats[item.get("category","?")] = cats.get(item.get("category","?"),0)+1 for c in (item.get("color",[]) if isinstance(item.get("color"),list) else [item.get("color","")]): cols[c] = cols.get(c,0)+1 used = set() for h in history: if h.get("rating")=="like": for iid in h.get("outfit",{}).get("items",{}).values(): if iid: used.add(iid) underused = [i for i in wardrobe if i["id"] not in used] def bars(d, color, total_ref): if not d: return "

No data yet.

" return "".join(f"""
{k}{v}
""" for k,v in sorted(d.items(),key=lambda x:-x[1])[:6]) underused_html = "".join(f"""
{normalize_item_type(i.get('type','item'))}
""" for i in underused[:6]) or "

Every item has been worn!

" return f"""
Closet by Category
{bars(cats,'#930500',total)}
Dominant Colors
{bars(cols,'#C9DAEA',total)}
Rarely Worn Items

These garments haven't appeared in a liked outfit yet — try building a look around them.

{underused_html}
""" def render_badge_html(): p = load_profile() t = int(datetime.datetime.now().timestamp()) avatar = f"{static_url('images/profile_avatar.png')}?t={t}" if fs_exists("images/profile_avatar.png") else "data:image/svg+xml;utf8," banner_style = f"background-image:url('{static_url('images/profile_banner.jpg')}?t={t}');background-size:cover;background-position:center;" if fs_exists(PROFILE_BG_PATH) else "background-color:#FDFBFC;background-image:linear-gradient(rgba(147,5,0,.08) 1px, transparent 1px),linear-gradient(90deg, rgba(147,5,0,.08) 1px, transparent 1px);background-size:32px 32px;background-position:center;" wardrobe = load_wardrobe() history = load_history() liked = sum(1 for h in history if h.get('rating') == 'like') bio = p.get('bio', 'No bio yet — tell your closet twin about your style.') return f"""
Avatar
{p.get('name','Your Name')}
Twin ID #{p.get('twin_id','0001')}
Wardrobe Owner
{len(wardrobe)}
Items
{len(history)}
Outfits
{liked}
Liked

"{bio}"

""" def render_landing_html(): polaroids = f"""
Hearts
favorites
Fits
my style
Ribbons
Closet Twin
""" return f"""
{polaroids}

ClosetTwin

An AI stylist built for one real person — you.

Stop standing in front of your wardrobe wondering what to wear. Upload your clothes once, and let your personal AI twin build complete, occasion-ready outfits from everything you already own.

from the stylist
the styling journal

how it works

Closet Twin uses a local vision model to identify every garment you upload — reading its color, formality, season suitability, and cut. A language model then acts as your personal stylist, building complete outfit proposals from only what already hangs in your wardrobe.

reduced decision fatigue
100% sustainable styling
features

what your twin can do

  • Build outfit options around a specific garment you want to wear today
  • Adapt recommendations by weather temperature and occasion type
  • Learn your color and fit preferences through feedback over time
  • Recreate Pinterest and Instagram looks using your existing wardrobe
  • Highlight underused clothing items so nothing gets forgotten
  • Save favorite combinations to your personal collection

ready to meet your closet twin?

""" # ── CSS ──────────── CUSTOM_CSS = """ footer.svelte-mpyp5e, footer { display: none !important; } :root { --ct-cream: #FDFBFC; --ct-paper: #FDFBFC; --ct-red: #930500; --ct-red-soft: #B10B07; --ct-blue: #C9DAEA; --ct-blue-soft: #C8DCF6; --ct-ink: #930500; --ct-muted: #7d2b27; --ct-line: rgba(147, 5, 0, 0.15); --ct-shadow: rgba(86, 0, 0, 0.18); --ct-white: #FDFBFC; --ct-pink: #930500; --ct-lpink: #FFE9E4; --ct-dpink: #930500; --ct-sage: #C9DAEA; --ct-lsage: #E8F2FF; --ct-dsage: #315F9D; --ct-taupe: #7d2b27; --ct-ltaupe: #a75a55; --ct-brown: #930500; --ct-deep: #650000; --card-bg: #FDFBFC; } html, body, gradio-app, #root, .app, .main, .gradio-container { min-height: 100vh; margin: 0 !important; padding: 0 !important; color: var(--ct-ink) !important; font-family: 'Space Grotesk', 'Inter', Arial, sans-serif !important; background-color: var(--ct-cream) !important; background-image: linear-gradient(rgba(147, 5, 0, .08) 1px, transparent 1px), linear-gradient(90deg, rgba(147, 5, 0, .08) 1px, transparent 1px) !important; background-repeat: repeat !important; background-size: 32px 32px, 32px 32px !important; background-attachment: fixed !important; } .gradio-container { max-width: 100% !important; width: 100% !important; padding-top: 0 !important; padding-left: 0 !important; padding-right: 0 !important; background: transparent !important; } #ct-style-injector, #ct-style-injector > div, #ct-style-injector .prose { display: none !important; width: 0 !important; height: 0 !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important; overflow: hidden !important; } gradio-app, gradio-app > div, #root, #root > div, .gradio-container > div:first-child, .gradio-container .main:first-child, .gradio-container .contain:first-child { margin-top: 0 !important; padding-top: 0 !important; } .gradio-container > .main, .gradio-container > main, .gradio-container main, .gradio-container .main, .gradio-container .app, .gradio-container .contain, .gradio-container .wrap { max-width: none !important; width: 100% !important; margin: 0 !important; padding: 0 !important; } .gr-prose, .gr-form, .gr-padded, .html-container, .gradio-container .block, .gradio-container .panel, .gradio-container .gr-group, .gradio-container .form, .gradio-container .wrap:not(input):not(select):not(textarea) { background: transparent !important; border: none !important; box-shadow: none !important; } .ct-navbar { width: 100vw !important; max-width: none !important; margin: 0 calc(50% - 50vw) !important; min-height: 76px !important; padding: 0 !important; display: flex !important; align-items: center !important; justify-content: center !important; position: sticky !important; top: 0 !important; z-index: 1000 !important; background: #FED4E0 !important; color: var(--ct-red) !important; border: 0 !important; border-bottom: 1px solid rgba(147, 5, 0, .18) !important; border-radius: 0 !important; box-shadow: none !important; overflow: visible !important; } .ct-navbar, .ct-navbar.wrap, .ct-navbar.row, .ct-navbar.block, .ct-navbar.svelte-1ed2p3z { margin-top: 0 !important; } .ct-navbar:after { content: ''; position: absolute; left: 0; right: 0; bottom: -17px; height: 18px; background: radial-gradient(circle at 14px 0, transparent 13px, #FED4E0 14px) repeat-x; background-size: 28px 18px; pointer-events: none; } .ct-navbar, .ct-navbar > div, .ct-navbar-inner, .ct-navbar-inner > div { display: flex !important; flex-direction: row !important; align-items: center !important; justify-content: center !important; gap: 0 !important; flex-wrap: nowrap !important; } .gradio-container .ct-navbar.column, .gradio-container .ct-navbar .column, .gradio-container .ct-navbar-inner.row, .gradio-container .ct-nav-links.row { display: flex !important; flex-direction: row !important; align-items: center !important; flex-wrap: nowrap !important; } .ct-navbar-logo-wrap, .ct-navbar-logo-wrap > div { display: flex !important; flex: 0 0 auto !important; align-items: center !important; } .ct-logo { font-family: 'Shrikhand', Georgia, serif; font-size: clamp(1.45rem, 2.2vw, 2rem); color: var(--ct-red); letter-spacing: 0; line-height: 1; text-transform: lowercase; display: flex; align-items: center; gap: .55rem; white-space: nowrap; } .ct-logo-title { font-family: 'Shrikhand', Georgia, serif !important; font-size: clamp(1.45rem, 2.2vw, 2rem) !important; color: var(--ct-red) !important; line-height: 1 !important; text-transform: lowercase !important; white-space: nowrap !important; } .ct-logo img { height: 24px !important; width: 24px !important; object-fit: contain; } .ct-nav-links { display: flex !important; flex-direction: row !important; align-items: center !important; justify-content: flex-end !important; gap: .45rem !important; flex-wrap: nowrap !important; min-width: 0 !important; overflow-x: auto !important; opacity: 1 !important; pointer-events: auto !important; scrollbar-width: none; } .ct-nav-links > div { display: flex !important; flex-direction: row !important; align-items: center !important; gap: .45rem !important; flex-wrap: nowrap !important; } .ct-nav-links::-webkit-scrollbar { height: 0; } .ct-nav-links button, .ct-nav-links button.secondary { width: auto !important; min-width: unset !important; white-space: nowrap !important; padding: .55rem .8rem !important; background: transparent !important; border: 1px solid transparent !important; border-radius: 0 !important; box-shadow: none !important; color: var(--ct-red) !important; font-family: 'Space Grotesk', sans-serif !important; font-size: .72rem !important; font-weight: 800 !important; letter-spacing: .06em !important; text-transform: uppercase !important; } .ct-nav-links button:hover, .ct-nav-links button.secondary:hover { background: var(--ct-red) !important; color: var(--ct-cream) !important; transform: translateY(-1px) !important; } .ct-page { width: min(1480px, calc(100% - 2rem)) !important; max-width: 1480px !important; margin: 1.25rem auto 2.5rem !important; padding: clamp(1rem, 2.4vw, 2rem) clamp(1rem, 5vw, 4rem); box-sizing: border-box; background: transparent !important; border: none !important; border-radius: 0 !important; box-shadow: none !important; } .ct-page > div { width: 100% !important; max-width: 100% !important; } .gradio-container .ct-page, .gradio-container .ct-page.column, .gradio-container [id^="ct-page-"] { width: min(1480px, calc(100% - 2rem)) !important; max-width: 1480px !important; margin-left: auto !important; margin-right: auto !important; align-self: center !important; } .ct-page:before { content: none !important; } .ct-page-heading, .ct-card-title { font-family: 'Bodoni Moda', Georgia, serif !important; color: var(--ct-red) !important; font-weight: 500 !important; letter-spacing: 0 !important; text-transform: none !important; line-height: 1 !important; border-bottom: 1px solid var(--ct-line) !important; margin: 0 0 1.15rem !important; padding-bottom: .65rem !important; background: transparent !important; display: block !important; } .ct-page-heading { font-size: clamp(2.1rem, 5vw, 4.2rem) !important; } .ct-card-title { font-size: clamp(1.45rem, 2.5vw, 2.4rem) !important; } .ct-card-title-inline, .ct-section-h2, .ct-empty-title, .ct-wear-title, .ct-sc-title { font-family: 'Bodoni Moda', Georgia, serif !important; color: var(--ct-red) !important; letter-spacing: 0 !important; line-height: 1 !important; background: transparent !important; } .ct-script, .ct-caption, .ct-hero-sub { font-family: 'Sacramento', 'Brush Script MT', cursive !important; color: var(--ct-red) !important; } .ct-col-flat, .ct-col-flat > div, .ct-col-flat > .block { background: transparent !important; border: none !important; box-shadow: none !important; } .ct-card, .ct-sc-root, .ct-history-accordion, .ct-reason-panel, .ct-wear-left, .ct-stylist-empty, .ct-envelope-card, .ct-journal-frame, .ct-cta-strip { background: rgba(255, 246, 219, 0.6) !important; border: 1px solid rgba(147, 5, 0, .20) !important; border-radius: 0 !important; box-shadow: 8px 8px 0 rgba(147, 5, 0, .10) !important; } .ct-card, .ct-envelope-card, .ct-journal-inner, .ct-cta-strip { padding: 1.35rem !important; } .ct-card-dashed { border-style: dashed !important; } .ct-card:hover { box-shadow: 10px 10px 0 rgba(147, 5, 0, .14) !important; } .ct-card-header { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; } .gradio-container button.primary, .gradio-container button.secondary, .ct-cta-btn, .ct-cta-btn-outline, .ct-sc-dl-btn, .ct-sc-btn { border-radius: 0 !important; font-family: 'Space Grotesk', sans-serif !important; font-weight: 800 !important; letter-spacing: .05em !important; text-transform: uppercase !important; transition: transform .16s ease, box-shadow .16s ease, background .16s ease !important; } .gradio-container button.primary, .ct-cta-btn { width: 100% !important; background: var(--ct-red) !important; border: 1px solid var(--ct-red) !important; color: var(--ct-cream) !important; box-shadow: 5px 5px 0 var(--ct-blue) !important; } .gradio-container button.secondary, .ct-cta-btn-outline, .ct-sc-dl-btn, .ct-sc-btn { width: 100% !important; background: var(--ct-blue) !important; border: 1px solid var(--ct-red) !important; color: var(--ct-red) !important; box-shadow: 4px 4px 0 rgba(147, 5, 0, .18) !important; } .gradio-container button:hover, .ct-cta-btn:hover, .ct-sc-dl-btn:hover, .ct-sc-btn:hover { transform: translate(-2px, -2px) !important; box-shadow: 7px 7px 0 rgba(147, 5, 0, .24) !important; } .gradio-container button:active { transform: translate(1px, 1px) !important; box-shadow: 2px 2px 0 rgba(147, 5, 0, .2) !important; } .gradio-container fieldset { display: flex !important; gap: .45rem !important; flex-wrap: wrap !important; border: none !important; background: transparent !important; padding: 0 !important; } .gradio-container fieldset legend, .gradio-container label, .gradio-container .block-label { font-family: 'Space Grotesk', sans-serif !important; color: var(--ct-red) !important; font-weight: 700 !important; letter-spacing: .03em !important; } .gradio-container fieldset label { background: var(--ct-cream) !important; border: 1px solid var(--ct-line) !important; border-radius: 0 !important; padding: .36rem .8rem !important; cursor: pointer !important; } .gradio-container fieldset label:has(input[type=radio]:checked) { background: var(--ct-red) !important; color: var(--ct-cream) !important; border-color: var(--ct-red) !important; } .gradio-container fieldset input[type=radio] { position:absolute; opacity:0; width:0; height:0; } .gradio-container input[type=range] { accent-color: var(--ct-red) !important; } .gradio-container input[type=text], .gradio-container input[type=number], .gradio-container textarea, .gradio-container input[type=email], .gradio-container select { border-radius: 0 !important; border: 1px solid rgba(147, 5, 0, .28) !important; background: rgba(255, 246, 219, 0.6) !important; color: var(--ct-red) !important; font-family: 'Space Grotesk', sans-serif !important; box-shadow: none !important; } .gradio-container input:focus, .gradio-container textarea:focus, .gradio-container select:focus { outline: 2px solid var(--ct-blue) !important; border-color: var(--ct-red) !important; } .gradio-container input::placeholder, .gradio-container textarea::placeholder { color: rgba(147, 5, 0, .48) !important; } .gradio-container .image-container, .gradio-container .upload-container { border-radius: 0 !important; border-color: var(--ct-line) !important; } .ct-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1rem; } .ct-polaroid { background: var(--ct-cream) !important; border: 1px solid rgba(147, 5, 0, .22) !important; border-radius: 0 !important; padding: .65rem .65rem 1.15rem !important; box-shadow: 5px 5px 0 rgba(147, 5, 0, .10) !important; position: relative; text-align: center; transition: transform .18s ease, box-shadow .18s ease; } .ct-polaroid:hover { transform: rotate(-1deg) translateY(-2px); box-shadow: 7px 7px 0 rgba(147, 5, 0, .16) !important; } .ct-photo { width:100%; height:160px; overflow:hidden; background: #fff; display:flex; align-items:center; justify-content:center; margin-bottom:.55rem; border:1px solid rgba(147, 5, 0, .12); } .ct-photo img { width:100%; height:100%; object-fit:cover; display:block; } .ct-caption { font-size: 1.35rem; line-height: 1.05; } .ct-meta { font-size: .68rem; color: var(--ct-muted); margin-top:.18rem; text-transform:uppercase; letter-spacing:.04em; } .ct-selected-pill { position:absolute; top:.75rem; right:.75rem; background:var(--ct-red); color:var(--ct-cream); padding:.25rem .55rem; font-size:.65rem; text-transform:uppercase; letter-spacing:.06em; } .ct-clip { position:absolute; top:-8px; left:50%; width:48px; height:14px; transform:translateX(-50%) rotate(-2deg); background:rgba(149,187,234,.78); border:1px solid rgba(147,5,0,.18); } .ct-clip:after { display:none; } .ct-landing { display:flex; flex-direction:column; gap:2rem; } .ct-hero { min-height: 520px; display:grid; grid-template-columns: minmax(260px,.85fr) minmax(300px,1fr); align-items:center; gap: clamp(1.5rem, 4vw, 4rem); padding: clamp(1rem, 4vw, 3rem) 0 !important; background: transparent !important; border: none !important; border-radius: 0 !important; box-shadow: none !important; } .ct-hero-polaroids { display:flex; gap:.8rem; flex-wrap:wrap; justify-content:center; } .ct-hero-title { font-family: 'Bodoni Moda', Georgia, serif !important; font-size: clamp(4rem, 10vw, 8.5rem) !important; font-weight: 500 !important; line-height: .82 !important; color: var(--ct-red) !important; letter-spacing: 0 !important; margin: 0 0 1rem !important; } .ct-hero-title em { display:block; font-family:'Sacramento', cursive !important; font-size:.62em; font-style:normal; color:var(--ct-red) !important; margin-top:.08em; } .ct-hero-sub { font-size: clamp(2rem, 4.5vw, 4.3rem) !important; margin:0 0 .8rem !important; line-height:.9 !important; } .ct-hero-desc, .ct-body-text, .ct-reason-panel p, .ct-sc-note { color: var(--ct-muted) !important; line-height: 1.6 !important; } .ct-ribbon-divider { display:flex; align-items:center; gap:1rem; } .ct-ribbon-divider:before, .ct-ribbon-divider:after { content:''; flex:1; border-top:1px solid var(--ct-line); } .ct-divider-dashed, .ct-panel-divider { border-top:1px dashed var(--ct-line) !important; margin:.75rem 0 1rem; } .ct-scrapbook { display:grid; grid-template-columns:1fr 1fr; gap:1.25rem; } .ct-journal-frame { padding:0 !important; overflow:hidden; } .ct-journal-stripe, .ct-envelope-top { height:16px; background: repeating-linear-gradient(90deg, var(--ct-red) 0 18px, #FED4E0 18px 36px) !important; margin:0 !important; } .ct-quote-box, .ct-reasoning, .ct-reasoning-pink, .ct-progress-wrapper { background: var(--ct-blue) !important; border: 1px solid var(--ct-red) !important; border-radius: 0 !important; color: var(--ct-red) !important; } .ct-feature-list { list-style:none; padding:0; margin:.75rem 0 0; } .ct-feature-list li { border-bottom:1px solid var(--ct-line); padding:.55rem 0; color:var(--ct-red); font-family:'Space Grotesk', sans-serif; } .ct-feature-list li:before { content:'+'; color:var(--ct-red); margin-right:.55rem; font-weight:800; } .ct-cta-strip { text-align:center; background: var(--ct-blue) !important; } .ct-sc-root { max-width: 680px; margin:0 auto 1.5rem; overflow:hidden; background: rgba(247,240,230,.98) !important; border: 1px solid rgba(147,5,0,.18) !important; box-shadow: 8px 8px 0 rgba(255,199,214,.55) !important; } .ct-sc-header { display:flex; justify-content:space-between; align-items:center; gap:1rem; padding:.85rem 1rem; border-bottom:1px dashed rgba(147,5,0,.22); background: rgba(255,199,214,.28); } .ct-sc-body { padding:1rem 1.15rem 1.15rem; } .ct-sc-slide { display:grid; gap:.9rem; } .ct-sc-img-wrap { position:relative; width:min(100%, 340px); height:410px; margin:0 auto; border:1px solid rgba(147,5,0,.18); background:#fff; display:flex; align-items:center; justify-content:center; overflow:hidden; } .ct-sc-img { width:100%; height:100%; object-fit:contain; display:block; } .ct-sc-badge, .ct-sc-energy, .ct-hist-badge, .ct-garment-chip { background: rgba(255,199,214,.72) !important; color: var(--ct-red) !important; border: 1px dashed rgba(147,5,0,.38) !important; border-radius: 0 !important; } .ct-sc-badge { position:absolute; left:.65rem; bottom:.65rem; padding:.18rem .45rem; font-size:.72rem; font-weight:800; text-transform:uppercase; letter-spacing:.04em; } .ct-sc-energy { display:inline-block; width:auto !important; justify-self:start; padding:.25rem .65rem; font-size:.78rem; } .ct-sc-info { display:grid; gap:.4rem; max-width: 520px; margin: 0 auto; width: 100%; } .ct-sc-name { font-family:'Bodoni Moda', Georgia, serif; font-size:1.25rem; font-weight:700; color:var(--ct-red); } .ct-sc-meta, .ct-hist-occasion, .ct-feedback-note, .ct-empty-sm { color:var(--ct-muted) !important; } .ct-sc-dl-btn { justify-self:start; margin-top:.25rem; text-decoration:none !important; background:var(--ct-red) !important; color:var(--ct-cream) !important; border:1px solid var(--ct-red) !important; padding:.5rem .9rem !important; font-size:.78rem !important; font-weight:800 !important; letter-spacing:.06em !important; text-transform:uppercase !important; } .ct-stylist-empty { min-height:360px; display:flex; flex-direction:column; justify-content:center; align-items:center; text-align:center; padding:2rem; } .ct-empty { text-align:center; padding:2.5rem 1rem; color:var(--ct-muted); font-family:'Bodoni Moda', Georgia, serif; font-size:1.45rem; } .ct-wear-result { max-width:980px; margin:0 auto; } .ct-wear-kicker, .ct-side-label { color:var(--ct-muted); font-size:.72rem; font-weight:800; text-transform:uppercase; letter-spacing:.08em; } .ct-wear-title { text-align:center; font-size:clamp(2.4rem, 5vw, 4.8rem); line-height:.9; margin:.15rem 0 1.2rem; } .ct-wear-layout { display:grid; grid-template-columns:minmax(280px,1.1fr) minmax(260px,.9fr); gap:1.5rem; align-items:start; } .ct-result-piece-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.8rem; } .ct-result-piece { min-height:190px; background:var(--ct-cream); border:1px solid var(--ct-line); display:flex; flex-direction:column; justify-content:space-between; overflow:hidden; } .ct-result-piece img { width:100%; height:160px; object-fit:cover; display:block; } .ct-result-piece div { padding:.55rem .65rem .7rem; } .ct-result-piece strong { display:block; color:var(--ct-red); font-size:.82rem; } .ct-result-piece span, .ct-garment-chip small { color:var(--ct-muted); font-size:.64rem; text-transform:uppercase; } .ct-look-caption { margin-top:.8rem; padding:.7rem; text-align:center; color:var(--ct-red); border:1px solid var(--ct-line); font-family:'Sacramento', cursive; font-size:1.7rem; line-height:1; } .ct-garment-chip-grid { display:flex; flex-wrap:wrap; gap:.45rem; margin-bottom:1rem; } .ct-garment-chip { display:inline-flex; gap:.35rem; align-items:center; padding:.38rem .65rem; } .ct-reason-panel { padding:1rem !important; } .ct-slot-tools { margin-top:1rem; } .ct-slot-tools-title, .ct-slot-label { color:var(--ct-red); font-family:'Space Grotesk', sans-serif; font-weight:800; text-transform:uppercase; letter-spacing:.06em; } .ct-slot-row { align-items:center; } .ct-icon-button button { min-width:42px !important; } .ct-history { display:flex; flex-direction:column; gap:.45rem; } .ct-history-summary { list-style:none; cursor:pointer; padding:.65rem .85rem; } .ct-history-summary::-webkit-details-marker { display:none; } .ct-history-summary-inner { display:flex; justify-content:space-between; align-items:center; gap:.5rem; } .ct-history-body { border-top:1px dashed var(--ct-line); padding:.55rem .85rem .8rem; color:var(--ct-red); } .ct-progress { position:relative; width:100%; height:10px; background:rgba(247,240,230,.55); overflow:hidden; } .ct-progress-bar { position:absolute; top:0; left:-40%; width:40%; height:100%; background:var(--ct-red); animation:ct-progress 1.35s ease-in-out infinite; } @keyframes ct-progress { 0%{left:-40%;} 50%{left:50%;} 100%{left:120%;} } .ct-footer { width: 100vw !important; max-width: none !important; margin: 0 calc(50% - 50vw) 0 !important; padding: 1.2rem 2rem; background:var(--ct-red); color:white !important; border: 0; border-top: 1px solid rgba(247,240,230,.28); text-align:center; box-sizing: border-box; } .ct-footer-title { font-family:'Bodoni Moda', Georgia, serif; font-size:1.35rem; margin:0 0 .25rem; color:#FFF2D6 !important; } .ct-footer-sub { color:#F7E4BE !important; font-size:.78rem; margin:0; } /* Final navbar override: keep Gradio wrappers from collapsing/hiding links. */ .gradio-container .ct-navbar { width: 100vw !important; max-width: none !important; margin: 0 calc(50% - 50vw) !important; padding: 0 !important; display: flex !important; flex-direction: row !important; align-items: center !important; justify-content: center !important; overflow: visible !important; } .gradio-container .ct-navbar-inner { width: min(1280px, 100%) !important; display: flex !important; flex-direction: row !important; justify-content: space-between !important; align-items: center !important; gap: 1rem !important; padding: 0 1.5rem !important; box-sizing: border-box !important; } .gradio-container .ct-navbar-logo-wrap, .gradio-container .ct-navbar-logo-wrap > div { display: flex !important; flex: 0 0 auto !important; align-items: center !important; width: auto !important; min-width: max-content !important; } .gradio-container .ct-nav-links, .gradio-container .ct-nav-links > div { display: flex !important; flex-direction: row !important; align-items: center !important; justify-content: flex-end !important; gap: .45rem !important; flex-wrap: nowrap !important; width: auto !important; min-width: 0 !important; overflow-x: auto !important; opacity: 1 !important; pointer-events: auto !important; } .gradio-container #nav-home, .gradio-container #nav-closet, .gradio-container #nav-stylist, .gradio-container #nav-favorites, .gradio-container #nav-analytics, .gradio-container #nav-profile { display: flex !important; flex: 0 0 auto !important; width: auto !important; min-width: max-content !important; visibility: visible !important; opacity: 1 !important; } .gradio-container #nav-home button, .gradio-container #nav-closet button, .gradio-container #nav-stylist button, .gradio-container #nav-favorites button, .gradio-container #nav-analytics button, .gradio-container #nav-profile button { display: inline-flex !important; width: auto !important; min-width: max-content !important; visibility: visible !important; opacity: 1 !important; white-space: nowrap !important; padding: .55rem .8rem !important; background: transparent !important; color: var(--ct-red) !important; border-color: transparent !important; box-shadow: none !important; } .gradio-container #nav-home button:hover, .gradio-container #nav-closet button:hover, .gradio-container #nav-stylist button:hover, .gradio-container #nav-favorites button:hover, .gradio-container #nav-analytics button:hover, .gradio-container #nav-profile button:hover { background: var(--ct-red) !important; color: var(--ct-cream) !important; } @media (max-width: 850px) { .ct-navbar { position:relative !important; top:auto !important; flex-direction:row !important; height:auto !important; padding:.8rem !important; gap:.7rem !important; overflow-x:auto !important; align-items:center !important; } .ct-navbar::-webkit-scrollbar { height: 0; } .ct-logo { flex:0 0 auto; } .ct-nav-links { justify-content:flex-start !important; flex-wrap:nowrap !important; flex:0 0 auto !important; } .ct-nav-links button, .ct-nav-links button.secondary { white-space:nowrap !important; } .ct-page { width:100% !important; margin:1.25rem 0 !important; } .ct-hero, .ct-scrapbook, .ct-wear-layout { grid-template-columns:1fr !important; } .ct-hero { min-height:auto; } .ct-result-piece-grid { grid-template-columns:1fr; } } """ # ── Gradio Callbacks ────────────────────────────────────────────────────────── def switch_tab(tab): tabs = ["home","closet","stylist","favorites","analytics","profile"] return tuple(gr.update(visible=(tab==t)) for t in tabs) def on_category(cat, selected_id): return render_wardrobe_html(cat, selected_id) def on_delete(item_id, cat): w = [i for i in load_wardrobe() if i["id"]!=item_id] save_wardrobe(w) for p in [f"images/edited/{item_id}_edited.png", f"images/original/{item_id}.png"]: if os.path.exists(p): os.remove(p) return render_wardrobe_html(cat), render_analytics_html() def on_wear(item_id): wm = {i["id"]:i for i in load_wardrobe()} if item_id not in wm: return "", "", "home" wi = wm[item_id] html = f"""
{normalize_item_type(wi.get('type','item'))}
""" return item_id, html, "stylist" def on_anchor_select(selection, category): item_id = parse_item_id(selection) if not item_id: return "", "
No item selected.
", gr.update(visible=False), render_wardrobe_html(category, None) wm = {i["id"]:i for i in load_wardrobe()} item = wm.get(item_id) if not item: return "", "
Selected item not found.
", gr.update(visible=False), render_wardrobe_html(category, None) cols = ", ".join(item.get("color",[]) if isinstance(item.get("color"),list) else [item.get("color","")]) html = f"""
Selected item
{normalize_item_type(item.get('type','item'))}
{cols} · {item.get('fit','—')}
""" return item_id, html, gr.update(visible=True), render_wardrobe_html(category, item_id) def on_upload(path): if path is None: return (gr.update(visible=False),)+(("",)*10)+(None,) img = Image.open(path).convert("RGB") if isinstance(path,str) else Image.fromarray(path).convert("RGB") try: _, edited_path, image_id = process_image(img) edited = Image.open(edited_path).convert("RGB") except Exception as ex: print("FLUX fallback:", ex) image_id = str(uuid.uuid4()) img.save(os.path.join(ORIGINAL_IMAGE_DIR, f"{image_id}.png")) img.save(os.path.join(EDITED_IMAGE_DIR, f"{image_id}_edited.png")) edited = img attrs = {} if HAS_LOCAL_MODELS: try: attrs = extract_attributes(edited) except Exception as ex: print("extract_attributes fallback:", ex) if not attrs or "error" in attrs: attrs = {"category":"top","type":"shirt","color":["white"],"pattern":"none","style":"casual","fit":"regular","season":["all"],"formality":0.3,"description":"clothing item"} def first(v): return v[0] if isinstance(v,list) and v else (v or "") def join(v): return ", ".join(v) if isinstance(v,list) else (v or "") return ( gr.update(visible=True), image_id, attrs.get("category","top"), first(attrs.get("type","shirt")), join(attrs.get("color",[])), attrs.get("pattern","none"), first(attrs.get("style","casual")), attrs.get("fit","regular"), join(attrs.get("season",[])), float(attrs.get("formality",0.3)), attrs.get("description",""), fs_path(f"images/edited/{image_id}_edited.png"), gr.update(visible=False) ) def on_save(img_id, cat, typ, cols, pat, sty, fit, seasons, formality, desc, active_cat): item = { "id": img_id, "created_at": datetime.datetime.utcnow().isoformat(), "category": cat, "type": typ, "color": [c.strip() for c in cols.split(",") if c.strip()], "pattern": pat, "style": sty, "fit": fit, "season": [s.strip() for s in seasons.split(",") if s.strip()], "formality": float(formality), "description": desc, "image_original": f"images/original/{img_id}.png", "image_clean": f"images/edited/{img_id}_edited.png" } add_item(item) return ( render_wardrobe_html(active_cat), render_landing_html(), gr.update(choices=item_choices(), value=None), render_analytics_html(), gr.update(visible=False), gr.update(value=None) ) current_recs = [] def on_style(occasion, weather, temp, mood, style_pref, anchor): global current_recs wardrobe = load_wardrobe() if not wardrobe: return "
Upload clothes first before requesting outfit ideas.
", "Outfit 1 of 0", 0, "" recommendation_wardrobe = [item for item in wardrobe if item_has_image(item)] if anchor and not any(item["id"] == anchor for item in recommendation_wardrobe): anchor_item = next((item for item in wardrobe if item["id"] == anchor), None) if anchor_item: recommendation_wardrobe.append(anchor_item) if not recommendation_wardrobe: recommendation_wardrobe = wardrobe system = ( "You are Closet Twin, a private no-buy AI stylist for one real person. " "Your principle is: Same Energy, Not Same Clothes. " "The user already owns enough clothes, so never suggest shopping, prices, brands to buy, or similar products. " "Understand the user's requested energy naturally, like a friend would: silhouette, colors, formality, softness, structure, confidence, and effort level. " "You MUST respond ONLY with a valid JSON array containing exactly 3 outfit objects. " "Do not include markdown, backticks, commentary, or extra text. Only return the raw JSON array. " "Rules:\n" "1. Generate exactly 3 distinct outfits: safest wearable option, softer option, and more polished option.\n" "2. You MUST use ONLY the exact item IDs provided in the user's Wardrobe catalog. Do not invent any new IDs.\n" "3. Set items to null if not used.\n" "4. Put each item in the matching slot: dresses in dress, tops in top, bottoms in bottom, shoes in shoes, outerwear in outerwear, accessories in accessory.\n" "5. A dress is a complete main garment: if dress is not null, top and bottom must be null.\n" "6. If an anchor item is provided, include it in every outfit unless impossible.\n" "7. Be practical for someone deciding five minutes before leaving.\n" "8. Do not make body comments or imply body correction.\n" "Schema: [{\"name\":\"...\"," "\"energy_detected\":\"short comma-separated vibe phrase\"," "\"items\":{\"dress\":\"id_or_null\",\"top\":\"id_or_null\",\"bottom\":\"id_or_null\"," "\"shoes\":\"id_or_null\",\"outerwear\":\"id_or_null\",\"accessory\":\"id_or_null\"}," "\"reasoning\":\"2-3 sentences explaining why this works for the requested energy\"," "\"styling_note\":\"one tiny actionable styling note\"," "\"card_prompt\":\"short internal note for a clean flat-lay collage using only selected real items\"}]" ) catalog = [{ "id": i["id"], "category": i.get("category"), "type": i.get("type"), "color": i.get("color"), "pattern": i.get("pattern"), "style": i.get("style"), "fit": i.get("fit", "regular"), "season": i.get("season"), "formality": i.get("formality", 0.5), "description": i.get("description", ""), "has_image": item_has_image(i), } for i in recommendation_wardrobe] msg = (f"Wardrobe: {json.dumps(catalog)}\nOccasion: {occasion}\nWeather: {weather}, {temp}°C\n" f"Mood: {mood}\nPreferences: {style_pref}\nStyle profile: {get_style_profile()}\n" f"Anchor item (must include if not null): {anchor or 'None'}") try: if HAS_LOCAL_MODELS: print("Generating outfit ideas using local MiniCPM-V-4.6 model...") messages = [ {"role": "user", "content": system + "\n\nUser request:\n" + msg} ] inputs = get_processor().apply_chat_template( messages, tokenize=True, add_generation_prompt=True, return_dict=True, return_tensors="pt" ) inputs = {k: v.to(get_model().device) for k, v in inputs.items()} with torch.no_grad(): generated_ids = get_model().generate( **inputs, max_new_tokens=2048, temperature=0.4, do_sample=True ) generated_ids_trimmed = [ out_ids[len(in_ids):] for in_ids, out_ids in zip(inputs["input_ids"], generated_ids) ] raw = get_processor().batch_decode( generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False )[0] else: print("Local models unavailable. Calling Qwen API fallback...") r = client.chat_completion(model="Qwen/Qwen2.5-7B-Instruct", messages=[{"role":"system","content":system},{"role":"user","content":msg}], max_tokens=1600, temperature=0.4) raw = r.choices[0].message.content raw = raw.replace("```json","").replace("```","").strip() print("DEBUG raw model output:", repr(raw[:500])) import re as _re def parse_model_json(text): start_idx = text.find('[') if start_idx == -1: raise ValueError(f"No JSON array found in model output: {text[:200]}") decoder = json.JSONDecoder() try: parsed, _end = decoder.raw_decode(text, start_idx) return parsed except json.JSONDecodeError as decode_error: depth = 0 end_pos = None for i, ch in enumerate(text[start_idx:], start_idx): if ch == '[': depth += 1 elif ch == ']': depth -= 1 if depth == 0: end_pos = i break if end_pos is None: raise decode_error json_str = text[start_idx:end_pos+1] json_str = _re.sub(r'[\x00-\x1f]+', ' ', json_str) json_str = _re.sub(r',\s*(?=[\]}])', '', json_str) json_str = json_str.replace("'", '"') return json.loads(json_str) attempts = 3 parse_error = None for attempt in range(attempts): try: current_recs = parse_model_json(raw) break except Exception as error: parse_error = error if attempt < attempts - 1: msg = msg + "\n\nReminder: RETURN EXACTLY 3 OUTFIT OBJECTS IN A SINGLE JSON ARRAY ONLY. No extra text." if HAS_LOCAL_MODELS: messages = [{"role": "user", "content": system + "\n\nUser request:\n" + msg}] inputs = get_processor().apply_chat_template( messages, tokenize=True, add_generation_prompt=True, return_dict=True, return_tensors="pt" ) inputs = {k: v.to(get_model().device) for k, v in inputs.items()} with torch.no_grad(): generated_ids = get_model().generate( **inputs, max_new_tokens=2048, temperature=0.4, do_sample=True ) generated_ids_trimmed = [ out_ids[len(in_ids):] for in_ids, out_ids in zip(inputs["input_ids"], generated_ids) ] raw = get_processor().batch_decode( generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False )[0] else: r = client.chat_completion(model="Qwen/Qwen2.5-7B-Instruct", messages=[{"role":"system","content":system},{"role":"user","content":msg}], max_tokens=1600, temperature=0.4) raw = r.choices[0].message.content raw = raw.replace("```json", "").replace("```", "").strip() continue raise parse_error if not isinstance(current_recs, list): current_recs = [current_recs] current_recs = sanitize_outfits(current_recs, recommendation_wardrobe, anchor) current_recs = ensure_three_outfits(current_recs, recommendation_wardrobe, anchor) wm = {i["id"]:i for i in wardrobe} if not current_recs: return ( "
No valid no-buy outfit could be built from visible wardrobe items.
", "Outfit 1 of 0", 0, "" ) h = load_history() for o in current_recs: h.append({"id":str(uuid.uuid4()),"timestamp":datetime.datetime.now().isoformat(), "situation":{"occasion":occasion,"weather":weather,"temp":temp,"mood":mood}, "outfit":o,"rating":"generated"}) save_history(h) return render_outfits_html(current_recs, wm, 0), f"Outfit 1 of {len(current_recs)}", 0, "" except Exception as ex: return (f"
Stylist error: {ex}
", "Outfit 1 of 0", 0, "") def on_style_slide(direction, current_idx): global current_recs if not current_recs: return render_outfits_html([], {}, 0), "Outfit 1 of 0", 0, "" idx = int(current_idx or 0) idx = (idx + int(direction)) % len(current_recs) if idx < 0: idx += len(current_recs) return render_outfits_html(current_recs, {i["id"]:i for i in load_wardrobe()}, idx), f"Outfit {idx + 1} of {len(current_recs)}", idx, "" def generate_visual_card_for_index(idx_s): global current_recs try: idx = int(idx_s or 0) except Exception: raise ValueError("Generate an outfit first, then create an outfit card.") if not (0 <= idx < len(current_recs)): raise ValueError("Generate an outfit first, then create an outfit card.") wardrobe_map = {i["id"]: i for i in load_wardrobe()} outfit = current_recs[idx] card_path, url, item_count = create_outfit_collage(outfit, wardrobe_map) return outfit, card_path, url, item_count def visual_card_html(url): return f"""
Closet Twin outfit collage
""" def visual_card_error_html(message): return f"""
{escape(str(message))}
""" def on_generate_visual_card(idx_s): try: _outfit, _card_path, url, _item_count = generate_visual_card_for_index(idx_s) return visual_card_html(url) except Exception as ex: return visual_card_error_html(f"Outfit card generation failed: {ex}") def on_save_outfit_card(idx_s): try: outfit, card_path, url, _item_count = generate_visual_card_for_index(idx_s) except Exception as ex: return visual_card_error_html(f"Could not save outfit card: {ex}"), render_favorites_html(), render_saved_cards_html() favs = load_favorites() outfit_items = outfit.get("items", {}) outfit_item_set = {item_id for item_id in outfit_items.values() if item_id} existing = None for fav in favs: fav_item_set = {item_id for item_id in fav.get("items", {}).values() if item_id} if fav_item_set == outfit_item_set: existing = fav break if existing is None: existing = { "id": str(uuid.uuid4()), "saved_at": datetime.datetime.now().strftime("%Y-%m-%d"), } favs.append(existing) existing.update({ "name": outfit.get("name") or "Saved outfit card", "items": outfit_items, "reasoning": outfit.get("reasoning"), "energy_detected": outfit.get("energy_detected"), "styling_note": outfit.get("styling_note"), "card_prompt": outfit.get("card_prompt"), "flux_prompt": outfit.get("flux_prompt"), "card_path": card_path, "card_url": url, "card_saved_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"), }) save_favorites(favs) return visual_card_html(url), render_favorites_html(), render_saved_cards_html() def on_save_recreated_card(): try: _card_html, favorites_html, saved_cards_html = on_save_outfit_card(0) return ( "
Saved. This recreated outfit card is now in your profile.
", favorites_html, saved_cards_html, ) except Exception as ex: return ( f"
Could not save recreated card: {escape(str(ex))}
", render_favorites_html(), render_saved_cards_html(), ) def on_recreate_feedback(action): history_html, favorites_html, insight_html, analytics_html = on_feedback(0, action) labels = { "save": "Saved this recreated look.", "like": "Marked as wearable.", "dislike": "Marked as not this.", "too_formal": "Got it: too formal for this inspiration.", "too_basic": "Got it: too basic for this inspiration.", "wrong_vibe": "Got it: wrong vibe for this inspiration.", } message = labels.get(action, "Feedback saved.") return ( f"
{escape(message)}
", history_html, favorites_html, insight_html, analytics_html, ) def on_swap_item(idx_s, slot): global current_recs try: idx = int(idx_s or 0) except Exception: return render_outfits_html([], {}, 0), "Outfit 1 of 0", 0, "" if not (0 <= idx < len(current_recs)): return render_outfits_html([], {}, 0), "Outfit 1 of 0", 0, "" wardrobe = [item for item in load_wardrobe() if item_has_image(item)] wm = {item["id"]: item for item in wardrobe} outfit = current_recs[idx] slot = slot or "accessory" used = {item_id for item_id in outfit.get("items", {}).values() if item_id} current_id = outfit.get("items", {}).get(slot) used.discard(current_id) ref = normalize_reference_details({"description": outfit.get("energy_detected", ""), "style": outfit.get("energy_detected", "")}) choices = best_items_for_slot(wardrobe, ref, slot, used, limit=8) replacement = next((item for item in choices if item.get("id") != current_id), None) if replacement: outfit["items"][slot] = replacement["id"] if slot == "dress": outfit["items"]["top"] = None outfit["items"]["bottom"] = None elif slot in {"top", "bottom"}: outfit["items"]["dress"] = None current_recs[idx] = refresh_outfit(outfit, wm, adjusted=True) return render_outfits_html(current_recs, wm, idx), f"Outfit {idx + 1} of {len(current_recs)}", idx, "" def on_remove_item(idx_s, slot): global current_recs try: idx = int(idx_s or 0) except Exception: return render_outfits_html([], {}, 0), "Outfit 1 of 0", 0, "" if not (0 <= idx < len(current_recs)): return render_outfits_html([], {}, 0), "Outfit 1 of 0", 0, "" slot = slot or "accessory" outfit = current_recs[idx] if slot in outfit.get("items", {}): outfit["items"][slot] = None wm = {item["id"]: item for item in load_wardrobe()} current_recs[idx] = refresh_outfit(outfit, wm, adjusted=True) return render_outfits_html(current_recs, wm, idx), f"Outfit {idx + 1} of {len(current_recs)}", idx, "" def on_feedback(idx_s, action): global current_recs try: idx = int(idx_s) except: return render_history_html(), render_favorites_html(), get_style_profile(), render_analytics_html() if not (0 <= idx < len(current_recs)): return render_history_html(), render_favorites_html(), get_style_profile(), render_analytics_html() o = current_recs[idx] h = load_history() if action in ("like","dislike","too_formal","too_basic","wrong_vibe"): os_set = set(o.get("items",{}).values()) for log in reversed(h): if set(log.get("outfit",{}).get("items",{}).values())==os_set and log.get("rating")=="generated": log["rating"]=action; break save_history(h) elif action=="save": favs = load_favorites() os_set = set(o.get("items",{}).values()) if not any(set(f.get("items",{}).values())==os_set for f in favs): favs.append({"id":str(uuid.uuid4()),"saved_at":datetime.datetime.now().strftime("%Y-%m-%d"), "name":o.get("name"),"items":o.get("items"),"reasoning":o.get("reasoning"), "energy_detected":o.get("energy_detected"),"styling_note":o.get("styling_note"), "card_prompt":o.get("card_prompt"),"flux_prompt":o.get("flux_prompt")}) save_favorites(favs) return render_history_html(), render_favorites_html(), get_style_profile(), render_analytics_html() def on_del_fav(fid): save_favorites([f for f in load_favorites() if f["id"]!=fid]); return render_favorites_html() def _legacy_on_recreate(path): if path is None: return "
Upload an inspiration image to find matching wardrobe twins.
" img = Image.open(path).convert("RGB") if isinstance(path,str) else Image.fromarray(path).convert("RGB") details = {} if HAS_LOCAL_MODELS: try: details = extract_attributes(img) except: pass if not details or "error" in details: details = {"category":"top","type":"blazer","color":["beige"],"style":"tailored","description":"a tailored blazer outfit"} wardrobe = load_wardrobe() target_colors = [c.lower() for c in (details.get("color",[]) if isinstance(details.get("color"),list) else [details.get("color","")])] type_d = details.get("type","") if isinstance(type_d, list): type_d = type_d[0] if type_d else "" if not isinstance(type_d, str): type_d = str(type_d) type_d = type_d.lower() scored = [] for item in wardrobe: s = 0 if item.get("category")==details.get("category"): s+=3 item_type = item.get("type","") if isinstance(item_type, list): item_type = " ".join(item_type) if not isinstance(item_type, str): item_type = str(item_type) if type_d and type_d in item_type.lower(): s+=2 item_cols = [c.lower() for c in (item.get("color",[]) if isinstance(item.get("color"),list) else [item.get("color","")])] if any(c in target_colors for c in item_cols): s+=2 if item.get("style")==details.get("style"): s+=1 if s>1: scored.append((s,item)) scored.sort(key=lambda x:-x[0]) matches = [x[1] for x in scored[:4]] grid = '
'+"".join(f"""
{normalize_item_type(i.get('type','item'))}
{', '.join(i.get('color',[]) if isinstance(i.get('color'),list) else [i.get('color','')])}
""" for i in matches)+"
" if matches else "
No close matches found. Upload more items to improve results.
" return f"""
inspiration analysis

Detected: {details.get('description','')} · Colors: {', '.join(target_colors)} · Style: {details.get('style','')}

closet matches

Items already in your wardrobe that can recreate this look:

{grid}
""" def on_recreate(path, note=""): global current_recs if path is None: return "
Upload an inspiration image to find matching wardrobe twins.
" img = Image.open(path).convert("RGB") if isinstance(path,str) else Image.fromarray(path).convert("RGB") details = {} if HAS_LOCAL_MODELS: try: details = extract_attributes(img) except Exception: details = {} if not details or "error" in details: details = analyze_reference_image_fallback(img, note) ref = normalize_reference_details(details, note) wardrobe = [item for item in load_wardrobe() if item_has_image(item)] if not wardrobe: return "
Add wardrobe items before recreating a look.
" outfit = build_recreated_outfit(wardrobe, ref) current_recs = [outfit] history = load_history() history.append({ "id": str(uuid.uuid4()), "timestamp": datetime.datetime.now().isoformat(), "situation": { "occasion": "Recreate Look", "weather": "reference image", "temp": None, "mood": ref.get("description", "reference look"), }, "outfit": outfit, "rating": "generated", }) save_history(history) wm = {item["id"]: item for item in wardrobe} try: _card_path, card_url, _count = create_outfit_collage(outfit, wm) card_html = f"
Recreated closet outfit
" except Exception as ex: card_html = f"
Could not create card: {escape(str(ex))}
" relevant_slots = list(ref.get("slots") or []) if not relevant_slots: relevant_slots = [slot for slot, item_id in outfit.get("items", {}).items() if item_id] relevant_slots = [slot for slot in ["dress", "top", "bottom", "outerwear", "shoes", "accessory"] if slot in set(relevant_slots)] slot_sections = [] for slot in relevant_slots: matches = best_items_for_slot(wardrobe, ref, slot, set(), limit=3) if not matches: continue mini = "".join(f"""
{normalize_item_type(i.get('type','item'))}
""" for i in matches) slot_sections.append(f"""
{slot} matches
{mini}
""") selected_html = render_outfits_html([outfit], wm, 0) color_text = ", ".join(ref.get("colors") or ["not detected"]) style_text = ", ".join(sorted(ref.get("style_terms") or [])) or "same energy" gap_html = closet_gap_notes(outfit, ref, wm) return f"""
recreated from your closet

Detected: {escape(str(ref.get('description','reference look')))}
Energy: {escape(style_text)}
Colors: {escape(color_text)}

{gap_html} {card_html}
wear this
{selected_html}
piece-by-piece matches
{''.join(slot_sections)}
""" def on_recreate_to_stylist(path, note=""): if path is None: return ( "
upload an inspiration first

Add a Pinterest or Instagram screenshot to recreate its energy from your closet.

", "Outfit 1 of 0", 0, "", ) on_recreate(path, note) wm = {item["id"]: item for item in load_wardrobe()} visual_html = "" if current_recs: try: _card_path, card_url, _count = create_outfit_collage(current_recs[0], wm) visual_html = visual_card_html(card_url) except Exception: visual_html = "" return render_outfits_html(current_recs, wm, 0), f"Outfit 1 of {len(current_recs)}", 0, visual_html def on_avatar(path): avatar_path = os.path.join(IMAGES_DIR, "profile_avatar.png") if path is None: if fs_exists(avatar_path): try: os.remove(avatar_path) except: pass return render_badge_html() img = Image.open(path).convert("RGB") if isinstance(path,str) else Image.fromarray(path).convert("RGB") img.save(avatar_path) return render_badge_html() def on_confirm_and_upload(path): upload_result = on_upload(path) if path is None: return ( gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), "", "top", "", "", "none", "casual", "regular", "all", 0.3, "", None, ) meta_visible, image_id, cat, typ, cols, pat, sty, fit, seasons, formality, desc, preview, processing_hidden = upload_result return ( gr.update(visible=False), processing_hidden, meta_visible, image_id, cat, typ, cols, pat, sty, fit, seasons, formality, desc, preview, ) def on_profile_save(name, bio): p=load_profile(); p["name"]=name; p["bio"]=bio; save_profile(p) return render_badge_html(), f"

{get_style_profile()}

" def on_profile_bg(path): if path is None: if os.path.exists(PROFILE_BG_PATH): try: os.remove(PROFILE_BG_PATH) except Exception: pass return render_badge_html() img = Image.open(path).convert("RGB") if isinstance(path, str) else Image.fromarray(path).convert("RGB") img.save(PROFILE_BG_PATH) return render_badge_html() # ── Gradio App ───── # Static assets are mounted on demo.app after the UI is built (see launch block). # Enhance inspo images to higher-quality variants on startup if present def _enhance_inspo(src_name, dst_name, scale=1.6): try: p = os.path.join(FRONTEND_DIR, 'ui', src_name) if not os.path.exists(p): return False img = Image.open(p).convert('RGB') w,h = img.size new = img.resize((int(w*scale), int(h*scale)), resample=Image.LANCZOS) # sharpen a couple of times gently for _ in range(2): new = new.filter(ImageFilter.SHARPEN) dst = os.path.join(FRONTEND_DIR, 'ui', dst_name) new.save(dst, quality=92) return True except Exception as e: print('Could not enhance', src_name, e) return False # Try to create high-def background variants _enhance_inspo('inspo7.png', 'inspo7_hd.png', scale=1.4) _enhance_inspo('inspo6.png', 'inspo6_hd.png', scale=1.6) FONT_IMPORT = """ """ JS_HELPERS = """ window.ctCurrentSlideIndex = 0; function ctUpdateCarouselIndicator() { var indicator = document.getElementById('ct-carousel-indicator'); var slides = document.querySelectorAll('.ct-outfit-slide'); if (!indicator || !slides.length) return; var idx = window.ctCurrentSlideIndex; if (idx < 0 || idx >= slides.length) idx = 0; indicator.innerText = 'Outfit ' + (idx + 1) + ' of ' + slides.length; } function ctShowSlide(index, selector) { selector = selector || '.ct-outfit-slide'; var slides = document.querySelectorAll(selector); if (!slides.length) return; index = ((index % slides.length) + slides.length) % slides.length; slides.forEach(function(slide, i) { slide.style.display = i === index ? 'block' : 'none'; }); window.ctCurrentSlideIndex = index; if (selector === '.ct-outfit-slide') { ctUpdateCarouselIndicator(); } } function ctShiftCarousel(dir, selector) { ctShowSlide(window.ctCurrentSlideIndex + Number(dir), selector); } window._ctSc = window._ctSc || {}; window.ctScNav = function(uid, total, dir) { if (!uid) return; total = Number(total) || 0; if (total <= 0) return; var current = Number(window._ctSc[uid] || 0); var next = ((current + Number(dir)) % total + total) % total; window._ctSc[uid] = next; var slides = document.querySelectorAll('#' + uid + ' .ct-sc-slide'); if (slides.length) { slides.forEach(function(slide, idx) { slide.style.display = idx === next ? 'block' : 'none'; }); } else { var curEl = document.getElementById('ct-sc-slide-' + current); if (curEl) curEl.style.display = 'none'; var nextEl = document.getElementById('ct-sc-slide-' + next); if (nextEl) nextEl.style.display = 'block'; } var ctr = document.getElementById(uid + '_ctr'); if (ctr) ctr.textContent = (next + 1) + ' / ' + total; } function triggerGradioFeedback(idx, action) { var idWrapper = document.getElementById('hidden_feedback_id'); var actWrapper = document.getElementById('hidden_feedback_action'); var btnWrapper = document.getElementById('hidden_feedback_btn'); if (!idWrapper || !actWrapper || !btnWrapper) return; var idInput = idWrapper.querySelector('input, textarea'); var actInput = actWrapper.querySelector('input, textarea'); var button = btnWrapper.querySelector('button, input[type="button"], input[type="submit"]'); if (!idInput || !actInput || !button) return; idInput.value = idx.toString(); actInput.value = action; idInput.dispatchEvent(new Event('input', {bubbles:true})); actInput.dispatchEvent(new Event('input', {bubbles:true})); setTimeout(function() { button.click(); }, 50); } document.addEventListener('click', function(event) { // CTA nav buttons use data-ct-nav instead of inline onclick var ctNavTrigger = event.target.closest('[data-ct-nav]'); if (ctNavTrigger) { event.preventDefault(); var tab = ctNavTrigger.dataset.ctNav; if (typeof window.ctNavTo === 'function') { window.ctNavTo(tab); } else { console.warn('ctNavTo not defined at click time for tab:', tab); } return; } var savedNav = event.target.closest('button[data-ct-sc-nav-dir]'); if (savedNav) { event.preventDefault(); var uid = savedNav.dataset.ctScUid; var total = Number(savedNav.dataset.ctScTotal) || 0; var dir = Number(savedNav.dataset.ctScNavDir) || 0; window.ctScNav(uid, total, dir); return; } var navButton = event.target.closest('button.ct-carousel-button'); if (navButton) { event.preventDefault(); ctShiftCarousel(navButton.dataset.dir || 0); return; } var feedbackButton = event.target.closest('button.ct-feedback-action'); if (feedbackButton) { event.preventDefault(); var idx = parseInt(feedbackButton.dataset.idx || '0', 10); triggerGradioFeedback(idx, feedbackButton.dataset.action); return; } }); window.addEventListener('load', function() { ctShowSlide(window.ctCurrentSlideIndex || 0); }); // ── Navigation helper: navigate to a named tab ────────────────────────────── window.ctFireClick = function(element) { if (!element) return false; if (typeof element.click === 'function') { element.click(); return true; } var event = new MouseEvent('click', { bubbles: true, cancelable: true }); return element.dispatchEvent(event); } window.ctNavTo = function(tab) { var pageMap = { home: 'ct-page-home', closet: 'ct-page-closet', stylist: 'ct-page-stylist', favorites: 'ct-page-favorites', analytics: 'ct-page-analytics', profile: 'ct-page-profile' }; if (pageMap[tab]) { Object.keys(pageMap).forEach(function(key) { var page = document.getElementById(pageMap[key]); if (!page) return; page.style.display = key === tab ? 'block' : 'none'; page.hidden = key !== tab; page.setAttribute('aria-hidden', key === tab ? 'false' : 'true'); }); window.scrollTo({ top: 0, behavior: 'smooth' }); return; } var tabLabels = { home: 'Home', closet: 'My Closet', stylist: 'AI Stylist', recreate: 'Recreate', favorites: 'Favorites', analytics: 'Analytics', profile: 'Profile' }; var idMap = { home: 'nav-home', closet: 'nav-closet', stylist: 'nav-stylist', recreate: 'nav-recreate', favorites: 'nav-favorites', analytics: 'nav-analytics', profile: 'nav-profile' }; var targetLabel = tabLabels[tab]; var targetId = idMap[tab]; if (!targetLabel && !targetId) return; function synthClick(el) { if (!el) return false; try { el.click(); return true; } catch (e) {} var evs = ['pointerdown','mousedown','pointerup','mouseup','click']; for (var i = 0; i < evs.length; i++) { try { el.dispatchEvent(new MouseEvent(evs[i], { bubbles: true, cancelable: true })); } catch (e) {} } return true; } // 1) Try exact id if (targetId) { var wrapper = document.getElementById(targetId); if (wrapper) { var btn = wrapper.querySelector('button, [role="button"], input[type="button"], input[type="submit"], a'); if (btn && synthClick(btn)) return; if (synthClick(wrapper)) return; } // try id contains var candidates = document.querySelectorAll('[id*="' + targetId + '"]'); for (var k = 0; k < candidates.length; k++) { var c = candidates[k]; var cb = c.querySelector('button, [role="button"], input[type="button"], input[type="submit"], a'); if (cb && synthClick(cb)) return; if (synthClick(c)) return; } } // 2) Try matching label text if (targetLabel) { var norm = targetLabel.trim().toLowerCase(); var els = document.querySelectorAll('button, [role="button"], a, input[type="button"], input[type="submit"]'); for (var m = 0; m < els.length; m++) { var e = els[m]; var txt = (e.textContent || e.value || '').trim().toLowerCase(); if (!txt) continue; if (txt === norm || txt.indexOf(norm) !== -1) { if (synthClick(e)) return; } } } // 3) fallback to data-nav selector var fallback = document.querySelector('[data-nav="' + tab + '"] button, [data-nav="' + tab + '"]'); if (fallback) { synthClick(fallback); return; } console.warn('ctNavTo: could not locate nav target for', tab); } """ NAVBAR_HTML = """
""" FOOTER_HTML = """ """ print("DEBUG: About to create Gradio theme") ct_theme = gr.themes.Soft( primary_hue=gr.themes.colors.rose, secondary_hue=gr.themes.colors.emerald, neutral_hue=gr.themes.colors.stone, font=gr.themes.GoogleFont("Space Grotesk"), font_mono=gr.themes.GoogleFont("Space Grotesk"), ).set( body_background_fill="transparent", body_background_fill_dark="transparent", background_fill_primary="#FDFBFC", background_fill_secondary="#FDFBFC", block_background_fill="transparent", block_background_fill_dark="transparent", border_color_primary="rgba(147,5,0,0.22)", block_border_color="rgba(147,5,0,0.22)", block_border_width="1px", block_shadow="0 4px 20px rgba(140,114,101,0.06)", block_radius="0px", block_label_background_fill="transparent", block_label_border_color="transparent", block_label_shadow="none", block_title_text_color="#930500", block_title_text_weight="600", block_title_text_size="0.9rem", input_background_fill="#FDFBFC", input_border_color="rgba(147,5,0,0.28)", input_border_color_focus="#930500", input_shadow="none", input_shadow_focus="0 0 0 3px rgba(179,116,128,0.15)", input_radius="0px", button_primary_background_fill="#930500", button_primary_background_fill_hover="#B10B07", button_primary_text_color="#ffffff", button_primary_border_color="transparent", button_secondary_background_fill="#C9DAEA", button_secondary_background_fill_hover="#C8DCF6", button_secondary_text_color="#930500", button_secondary_border_color="#930500", button_large_radius="0px", button_large_padding="0.6rem 1.8rem", button_small_radius="0px", slider_color="#930500", checkbox_background_color="#FDFBFC", checkbox_border_color="rgba(147,5,0,0.28)", checkbox_label_background_fill="#FDFBFC", checkbox_label_background_fill_hover="#C9DAEA", checkbox_label_border_color="rgba(147,5,0,0.28)", checkbox_label_text_color="#930500", table_border_color="rgba(147,5,0,.22)", panel_background_fill="transparent", panel_border_color="transparent", ) print("DEBUG: Gradio theme created successfully") print("DEBUG: About to create gr.Blocks and build UI") try: with gr.Blocks( title="Closet Twin - AI Wardrobe Assistant", theme=ct_theme, css=CUSTOM_CSS, head=FONT_IMPORT + "", ) as demo: print("DEBUG: Inside gr.Blocks context") # Inject fonts and CSS (Gradio 6: theme and head are now passed to launch()) gr.HTML("", elem_id="ct-style-injector") hidden_feedback_id = gr.Textbox(visible=False, elem_id="hidden_feedback_id") hidden_feedback_action = gr.Textbox(visible=False, elem_id="hidden_feedback_action") hidden_feedback_btn = gr.Button(visible=False, elem_id="hidden_feedback_btn") anchor_state = gr.State("") style_slide_index = gr.State(0) with gr.Row(elem_classes="ct-navbar"): with gr.Row(elem_classes="ct-navbar-inner"): gr.HTML('
closet twin
', elem_classes="ct-navbar-logo-wrap") with gr.Row(elem_classes="ct-nav-links"): nav_home = gr.Button("Home", elem_id="nav-home", variant="secondary") nav_closet = gr.Button("My Closet", elem_id="nav-closet", variant="secondary") nav_stylist = gr.Button("AI Stylist", elem_id="nav-stylist", variant="secondary") nav_favorites = gr.Button("Favorites", elem_id="nav-favorites", variant="secondary") nav_analytics = gr.Button("Analytics", elem_id="nav-analytics", variant="secondary") nav_profile = gr.Button("Profile", elem_id="nav-profile", variant="secondary") with gr.Column(visible=True, elem_id="ct-page-home", elem_classes="ct-page") as page_home: landing_out = gr.HTML(value=render_landing_html()) with gr.Row(elem_classes="ct-landing-ctas"): landing_cta_closet = gr.Button("Open My Closet", elem_id="landing-closet-btn", variant="primary") landing_cta_stylist = gr.Button("Get Styled Now", elem_id="landing-stylist-btn", variant="secondary") with gr.Column(visible=False, elem_id="ct-page-closet", elem_classes="ct-page") as page_closet: gr.HTML('
My Digital Wardrobe
') with gr.Row(equal_height=False): with gr.Column(scale=3, elem_classes="ct-col-flat"): cat_radio = gr.Radio(choices=["all","top","bottom","shoes","outerwear","accessory","dress"], value="all", label="Filter by category") closet_grid = gr.HTML(render_wardrobe_html()) with gr.Column(scale=2, elem_classes="ct-card"): gr.HTML('
Add a Garment
') upload_img = gr.Image(type="filepath", label="Upload photo") confirm_upload_btn = gr.Button("Confirm Adding Outfit", variant="primary", visible=False) processing_html = gr.HTML("
Processing uploaded image...
", visible=False) with gr.Column(visible=False) as meta_col: gr.HTML('

Review AI-extracted attributes:

') tmp_id = gr.Textbox(visible=False) new_cat = gr.Dropdown(choices=["top","bottom","shoes","outerwear","accessory","dress"], label="Category") new_type = gr.Textbox(label="Type") new_cols = gr.Textbox(label="Colors (comma-separated)") new_pat = gr.Textbox(label="Pattern") new_sty = gr.Textbox(label="Style") new_fit = gr.Dropdown(choices=["oversized","slim","regular","unknown"], label="Fit") new_seas = gr.Textbox(label="Seasons (comma-separated)") new_form = gr.Slider(0, 1, .3, step=.05, label="Formality (0=casual, 1=formal)") new_desc = gr.TextArea(label="Description") new_prev = gr.Image(label="Processed image", height=180) closet_save_btn = gr.Button("Save to Wardrobe", variant="primary") with gr.Column(visible=False, elem_id="ct-page-stylist", elem_classes="ct-page") as page_stylist: gr.HTML('
what are we dressing for?
') with gr.Row(equal_height=False): with gr.Column(scale=2): with gr.Column(elem_classes="ct-card"): with gr.Tabs(): with gr.Tab("Describe the vibe"): occ = gr.Dropdown(["University Presentation","Coffee with Friends","Wedding","Family Dinner","Casual Weekend","Job Interview","Beach Outing"], value="Coffee with Friends", label="Occasion") with gr.Row(): wth = gr.Dropdown(["Sunny","Rainy","Snowy","Windy","Overcast"], value="Sunny", label="Weather") tmp = gr.Slider(-10, 40, 22, step=1, label="Temp (°C)") mood = gr.Dropdown(["Casual","Elegant","Romantic","Cozy","Bold"], value="Casual", label="Mood") pref = gr.Textbox(label="Optional preferences", placeholder="e.g. layers, pastel tones...") gr.HTML("
style around one piece

Optional: choose a garment from your wardrobe.

") wear_choice = gr.Dropdown(choices=item_choices(), label="Choose an item to wear", interactive=True) anchor_prev = gr.HTML("") clear_btn = gr.Button("Clear Item", variant="secondary", visible=False) gen_btn = gr.Button("Generate Outfit Ideas", variant="primary") with gr.Tab("Use inspiration"): gr.HTML('

Drop a Pinterest or Instagram screenshot to recreate the energy.

') stylist_inspo_img = gr.Image(type="filepath", label="Pinterest / Instagram screenshot") stylist_inspo_note = gr.Textbox(label="Optional note", placeholder="olive tank, sage wide-leg pants, brown sandals") stylist_inspo_btn = gr.Button("Recreate This Energy", variant="primary") with gr.Column(scale=3, elem_classes="ct-col-flat"): gr.HTML('
AI Recommendations
') outfits_out = gr.HTML(render_outfits_html([], {}, 0)) with gr.Row(): prev_outfit = gr.Button('↩ Previous', variant='secondary') next_outfit = gr.Button('Next ↪', variant='secondary') with gr.Row(): like_btn = gr.Button('Wear this', variant='primary') dislike_btn = gr.Button('Not this', variant='secondary') stylist_save_btn = gr.Button('Save look', variant='secondary') with gr.Row(): too_formal_btn = gr.Button('Too formal', variant='secondary') too_basic_btn = gr.Button('Too basic', variant='secondary') wrong_vibe_btn = gr.Button('Wrong vibe', variant='secondary') with gr.Column(elem_classes="ct-slot-tools"): gr.HTML('
adjust one item
') slot_buttons = {} for slot_name in ["dress", "top", "bottom", "shoes", "outerwear", "accessory"]: with gr.Row(elem_classes="ct-slot-row"): gr.HTML(f'
{slot_name}
') slot_buttons[(slot_name, "swap")] = gr.Button("↻", variant="secondary", elem_classes="ct-icon-button") slot_buttons[(slot_name, "remove")] = gr.Button("x", variant="secondary", elem_classes="ct-icon-button") gr.HTML('
outfit card
') with gr.Row(): visual_card_btn = gr.Button('Generate Card', variant='secondary') save_card_btn = gr.Button('Save Card to Profile', variant='primary') visual_card_out = gr.HTML("") carousel_status = gr.HTML('
Outfit 1 of 0
') with gr.Column(visible=False, elem_id="ct-page-favorites", elem_classes="ct-page") as page_favorites: with gr.Row(): with gr.Column(scale=3): gr.HTML('
saved outfits
') favs_out = gr.HTML(render_favorites_html()) with gr.Column(scale=2): with gr.Accordion("Styling History", open=False): hist_out = gr.HTML(render_history_html()) with gr.Column(visible=False, elem_id="ct-page-analytics", elem_classes="ct-page") as page_analytics: gr.HTML('
wardrobe insights
') analytics_out = gr.HTML(render_analytics_html()) with gr.Column(visible=False, elem_id="ct-page-profile", elem_classes="ct-page") as page_profile: badge_out = gr.HTML(render_badge_html()) gr.HTML("
Edit Profile
") with gr.Row(): with gr.Column(scale=1, elem_classes="ct-card"): pname = gr.Textbox(label="Name", value=load_profile().get("name","")) pbio = gr.TextArea(label="Style bio", value=load_profile().get("bio","")) with gr.Accordion("Update Images", open=False): pavat = gr.Image(type="filepath", label="Profile photo (avatar)") gr.HTML('

Upload a custom banner background for your profile card:

') pbanner = gr.Image(type="filepath", label="Banner background image") psave = gr.Button("Save Profile", variant="primary") with gr.Column(scale=1, elem_classes="ct-card"): gr.HTML("
Stylist Insight
") insight_out = gr.HTML(f"

{get_style_profile()}

") with gr.Column(elem_classes="ct-card", elem_id="ct-saved-cards-section"): gr.HTML("
saved outfit cards
") saved_cards_idx = gr.State(0) saved_cards_out = gr.HTML(render_saved_cards_html(0)) with gr.Row(): prev_saved_btn = gr.Button('↩ prev', variant='secondary') next_saved_btn = gr.Button('next ↪', variant='secondary') gr.HTML(FOOTER_HTML) ALL_PAGES = [page_home, page_closet, page_stylist, page_favorites, page_analytics, page_profile] nav_home.click(lambda: switch_tab('home'), [], ALL_PAGES) nav_closet.click(lambda: switch_tab('closet'), [], ALL_PAGES) nav_stylist.click(lambda: switch_tab('stylist'), [], ALL_PAGES) nav_favorites.click(lambda: switch_tab('favorites'), [], ALL_PAGES) nav_analytics.click(lambda: switch_tab('analytics'), [], ALL_PAGES) nav_profile.click(lambda: switch_tab('profile'), [], ALL_PAGES) try: landing_cta_closet.click(lambda: switch_tab('closet'), [], ALL_PAGES) landing_cta_stylist.click(lambda: switch_tab('stylist'), [], ALL_PAGES) except Exception: # If variables not in scope for any reason, ignore quietly pass # Saved cards server-side navigation try: def on_saved_cards_nav(direction, current_idx): cards = [fav for fav in load_favorites() if fav.get('card_url')] if not cards: return render_saved_cards_html(0), 0 total = len(cards) idx = int(current_idx or 0) idx = (idx + int(direction)) % total if idx < 0: idx += total return render_saved_cards_html(idx), idx prev_saved_btn.click(lambda i: on_saved_cards_nav(-1, i), [saved_cards_idx], [saved_cards_out, saved_cards_idx]) next_saved_btn.click(lambda i: on_saved_cards_nav(1, i), [saved_cards_idx], [saved_cards_out, saved_cards_idx]) except Exception: pass cat_radio.change(on_category, [cat_radio, anchor_state], [closet_grid]) wear_choice.change(on_anchor_select, [wear_choice, cat_radio], [anchor_state, anchor_prev, clear_btn, closet_grid]) def on_image_change(path): if path is None: return gr.update(visible=False), gr.update(visible=False), gr.update(visible=False) return gr.update(visible=True), gr.update(visible=False), gr.update(visible=False) upload_img.change( on_image_change, inputs=[upload_img], outputs=[confirm_upload_btn, meta_col, processing_html] ) confirm_upload_btn.click( on_confirm_and_upload, inputs=[upload_img], outputs=[ confirm_upload_btn, processing_html, meta_col, tmp_id, new_cat, new_type, new_cols, new_pat, new_sty, new_fit, new_seas, new_form, new_desc, new_prev ] ) closet_save_btn.click(on_save, [tmp_id, new_cat, new_type, new_cols, new_pat, new_sty, new_fit, new_seas, new_form, new_desc, cat_radio], [closet_grid, landing_out, wear_choice, analytics_out, meta_col, upload_img]) clear_btn.click(lambda category: ("", "
No item selected.
", gr.update(visible=False), render_wardrobe_html(category, None), gr.update(choices=item_choices(), value=None)), [cat_radio], [anchor_state, anchor_prev, clear_btn, closet_grid, wear_choice]) gen_btn.click(on_style, [occ, wth, tmp, mood, pref, anchor_state], [outfits_out, carousel_status, style_slide_index, visual_card_out]) stylist_inspo_btn.click( on_recreate_to_stylist, [stylist_inspo_img, stylist_inspo_note], [outfits_out, carousel_status, style_slide_index, visual_card_out], ) prev_outfit.click(lambda idx: on_style_slide(-1, idx), [style_slide_index], [outfits_out, carousel_status, style_slide_index, visual_card_out]) next_outfit.click(lambda idx: on_style_slide(1, idx), [style_slide_index], [outfits_out, carousel_status, style_slide_index, visual_card_out]) like_btn.click(lambda idx: on_feedback(idx, 'like'), [style_slide_index], [hist_out, favs_out, insight_out, analytics_out]) dislike_btn.click(lambda idx: on_feedback(idx, 'dislike'), [style_slide_index], [hist_out, favs_out, insight_out, analytics_out]) stylist_save_btn.click(lambda idx: on_feedback(idx, 'save'), [style_slide_index], [hist_out, favs_out, insight_out, analytics_out]) too_formal_btn.click(lambda idx: on_feedback(idx, 'too_formal'), [style_slide_index], [hist_out, favs_out, insight_out, analytics_out]) too_basic_btn.click(lambda idx: on_feedback(idx, 'too_basic'), [style_slide_index], [hist_out, favs_out, insight_out, analytics_out]) wrong_vibe_btn.click(lambda idx: on_feedback(idx, 'wrong_vibe'), [style_slide_index], [hist_out, favs_out, insight_out, analytics_out]) for slot_name in ["dress", "top", "bottom", "shoes", "outerwear", "accessory"]: slot_buttons[(slot_name, "swap")].click( lambda idx, slot=slot_name: on_swap_item(idx, slot), [style_slide_index], [outfits_out, carousel_status, style_slide_index, visual_card_out], ) slot_buttons[(slot_name, "remove")].click( lambda idx, slot=slot_name: on_remove_item(idx, slot), [style_slide_index], [outfits_out, carousel_status, style_slide_index, visual_card_out], ) visual_card_btn.click(on_generate_visual_card, [style_slide_index], [visual_card_out]) save_card_btn.click(on_save_outfit_card, [style_slide_index], [visual_card_out, favs_out, saved_cards_out]) hidden_feedback_btn.click(on_feedback, [hidden_feedback_id, hidden_feedback_action], [hist_out, favs_out, insight_out, analytics_out]) pavat.change(on_avatar, pavat, badge_out) pbanner.change(on_profile_bg, pbanner, badge_out) psave.click(on_profile_save, [pname, pbio], [badge_out, insight_out]) except Exception as e: print(f"ERROR building Gradio UI: {type(e).__name__}: {e}") import traceback traceback.print_exc() raise # Detect if running on HuggingFace Spaces SPACE_ENV_VARS = ["SPACE_ID", "HUGGINGFACE_SPACE_ID", "HF_SPACE_ID"] IS_SPACES = any(os.getenv(v) for v in SPACE_ENV_VARS) print(f"DEBUG: IS_SPACES = {IS_SPACES}") print("DEBUG: About to run launch block") demo.allowed_paths = [FRONTEND_DIR] print(f"DEBUG: allowed_paths = {demo.allowed_paths}") if __name__ == "__main__": try: server_name = "0.0.0.0" if IS_SPACES else os.getenv("GRADIO_SERVER_NAME", "127.0.0.1") server_port = int(os.getenv("PORT") or os.getenv("GRADIO_SERVER_PORT") or "7860") print(f"DEBUG: Launching Gradio on {server_name}:{server_port} (spaces={IS_SPACES})") demo.launch( server_name=server_name, server_port=server_port, allowed_paths=[FRONTEND_DIR], show_error=True, ) except Exception as e: print(f"ERROR during demo.launch: {type(e).__name__}: {e}") import traceback traceback.print_exc() raise else: print("DEBUG: HF Spaces will import `demo` and launch it automatically") print("DEBUG: All initialization complete!")