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"""
"
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"""
"""
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"""
"""
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"""
"
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"""
{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"""
favorites
my style
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
"""
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"""
"""
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"""
",
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.
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 "
"
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"""
"
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 = """
closet twin
"""
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('
')
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: