""" Personal Style Matcher — Hugging Face Space application ========================================================= An elegant, production-ready Gradio app that recommends fashion looks from the "Fashion Stylist Multimodal v2" catalog (lihicarmeli/fashion-stylist-multimodal-v2). Features: * Embedding model : openai/clip-vit-base-patch32 * Vector search : FAISS flat-L2 index with a 3-tier demographic fallback filter * GenAI component : Optional one-line AI stylist note via Qwen/Qwen2.5-0.5B-Instruct * Style Guardrail : Strict exclusion of orange tones across all data visualizations * UI/UX Aesthetic : Clean, modern, high-end luxury pastel and gold layout """ import os import html import hashlib import colorsys import traceback import urllib.parse import numpy as np import pandas as pd import torch import faiss from datasets import load_dataset from transformers import AutoModel, AutoProcessor import gradio as gr # ============================================================================= # Constants & Repositories # ============================================================================= DATASET_REPO = "lihicarmeli/fashion-stylist-multimodal-v2" EMBED_MODEL_REPO = "openai/clip-vit-base-patch32" # Best speed/size/effort trade-off CAPTION_MODEL_REPO = "Qwen/Qwen2.5-0.5B-Instruct" # Small instruction-tuned LM EMBED_CACHE_PATH = "catalog_image_embeddings.npy" SKIN_DEPTH_ORDER = ["fair", "light", "ivory", "porcelain", "medium", "olive", "tan", "deep", "dark", "ebony"] AGE_ORDER = ["teen", "young adult", "adult", "mature"] UNDERTONE_ORDER = ["warm", "neutral", "cool"] EYE_COLOR_CHOICES = ["Brown", "Dark Brown", "Hazel", "Amber", "Green", "Blue", "Gray"] WARM_EYES = {"brown", "dark brown", "hazel", "amber"} COOL_EYES = {"green", "blue", "gray", "grey"} AUTO_DETECT_LABEL = "Auto-detect from photo" COMPONENT_RETAILERS = { "TOP": "zara", "BOTTOM": "hm", "SHOES": "asos", "ACCESSORY": "mango", } # Optimized and validated production URLs for fashion e-commerce search engines RETAILER_SEARCH_URLS = { "zara": "https://www.zara.com/us/en/search?searchTerm={query}§ion={section}", "hm": "https://www2.hm.com/en_us/search-results.html?q={query}", "asos": "https://www.asos.com/us/search/?q={query}", "mango": "https://shop.mango.com/us/en/search?kw={query}", "shein": "https://us.shein.com/pdsearch/{query}/", } SKIN_HEX = { "fair": "#F8DFC0", "light": "#E8C99A", "medium": "#C68642", "olive": "#8D6346", "tan": "#7B4F2E", "dark": "#3B1F0E", "deep": "#2A1208" } # ============================================================================= # Color Utilities & Orange Guardrails # ============================================================================= NAMED_COLOR_HEX = { # Multi-word fashion phrases (Strictly no orange or bright orange) "ice white": "#F5F5F0", "warm white": "#FAF3E8", "deep jewel": "#5B2C6F", "bold blue": "#1E5AA8", "royal blue": "#4169E1", "cobalt blue": "#0047AB", "powder blue": "#B0E0E6", "rich gold": "#C9A227", "warm beige": "#E8D9B5", "warm brown": "#8B5A2B", "cool red": "#C8102E", "bright red": "#EE2C2C", "brick red": "#9B3A2C", "deep teal": "#114B5F", "deep olive": "#4B5320", "deep purple": "#4B1A6B", "forest green": "#1B4332", "bright pink": "#FF2D87", "dusty rose": "#C68893", "blush pink": "#F4C2C2", "grass green": "#3DA35D", "slate gray": "#6E7B8B", "golden yellow": "#F5C518", "true red": "#C8102E", "bright warm red": "#E0382D", "very deep": "#3A2A22", "cool blue": "#3B6FCC", # Single words (Tangerine, Orange, Terracotta, Coral, Rust mapped to rich gold/warm camel tones) "white": "#FFFFFF", "black": "#1A1A1A", "ivory": "#FFFFF0", "silver": "#C0C0C0", "gold": "#D4AF37", "amber": "#FFBF00", "tangerine": "#C9A227", "coral": "#D4AF37", "peach": "#FFCBA4", "terracotta": "#8B5A2B", "olive": "#708238", "beige": "#E8D9B5", "tan": "#C19A6B", "khaki": "#C3B091", "emerald": "#2E8B57", "teal": "#218380", "turquoise": "#30D5C8", "mint": "#98D8C8", "navy": "#1B1F3B", "blue": "#3B6FCC", "lavender": "#B497D6", "plum": "#8E4585", "magenta": "#C2185B", "fuchsia": "#D6336C", "purple": "#7B4397", "maroon": "#7A2E3B", "burgundy": "#6E0D25", "crimson": "#A11D33", "red": "#D63447", "pink": "#E07A9E", "rose": "#D17B96", "yellow": "#F4C542", "mustard": "#C9A227", "orange": "#8B5A2B", "brown": "#7B4B2A", "chocolate": "#5A3A22", "copper": "#B6622A", "rust": "#7B4B2A", "green": "#3F8D5C", "grey": "#9A9A9A", "gray": "#9A9A9A", "charcoal": "#3B3B3B", "cream": "#F1E8DA", } def clean_hex_color(hex_str): """Ensures hex color code format is valid and overrides any orange spectrum shades.""" h_str = str(hex_str).strip().lower() if not h_str.startswith("#"): h_str = "#" + h_str orange_overrides = { "#ff6b1a": "#C9A876", "#ff7f50": "#D4AF37", "#e8722c": "#9C8170", "#cb6015": "#8B5A2B", "#f28500": "#C9A876", "#c0654d": "#AF6E4D", "#9e4624": "#7B4B2A" } if h_str in orange_overrides: return orange_overrides[h_str] if len(h_str) == 7: try: r = int(h_str[1:3], 16) / 255.0 g = int(h_str[3:5], 16) / 255.0 b = int(h_str[5:7], 16) / 255.0 h, l, s = colorsys.rgb_to_hls(r, g, b) if 0.03 <= h <= 0.13: # Orange hue boundary h += 0.25 # Shift to a graceful soft sage/beige space r, g, b = colorsys.hls_to_rgb(h, 0.65, 0.55) return "#{:02X}{:02X}{:02X}".format(int(r * 255), int(g * 255), int(b * 255)) except: pass return hex_str def resolve_color_hex(text): if not text: return None t = str(text).lower() for phrase in sorted(NAMED_COLOR_HEX, key=len, reverse=True): if " " in phrase and phrase in t: return clean_hex_color(NAMED_COLOR_HEX[phrase]) for word in t.replace(",", " ").split(): if word in NAMED_COLOR_HEX: return clean_hex_color(NAMED_COLOR_HEX[word]) return None def text_to_pastel_hex(text): digest = hashlib.md5(str(text).encode("utf-8")).hexdigest() hue = int(digest[:4], 16) / 65535.0 if 0.03 <= hue <= 0.13: # Intercept generated orange hues safely hue += 0.25 r, g, b = colorsys.hls_to_rgb(hue, 0.65, 0.60) return "#{:02X}{:02X}{:02X}".format(int(r * 255), int(g * 255), int(b * 255)) def swatch_color_for(*texts): for t in texts: hexcode = resolve_color_hex(t) if hexcode: return hexcode joined = " ".join(str(t) for t in texts if t) return text_to_pastel_hex(joined or "style") # ============================================================================= # Seasonal Color Profiles (Clean Palettes without Orange Shades) # ============================================================================= SEASON_INFO = { "Spring": { "blurb": "Warm and light — your glow loves clear, fresh colors with a golden undertone.", "palette": [("Soft Gold", "#D4AF37"), ("Peach", "#FFCBA4"), ("Golden Yellow", "#F4C542"), ("Grass Green", "#3DA35D"), ("Turquoise", "#30D5C8"), ("Ivory", "#FFFFF0")], }, "Autumn": { "blurb": "Warm and rich — earthy, spiced tones make your natural warmth shine.", "palette": [("Warm Brown", "#8B5A2B"), ("Olive", "#708238"), ("Mustard", "#C9A227"), ("Chocolate", "#5A3A22"), ("Camel", "#C19A6B"), ("Forest Green", "#1B4332")], }, "Summer": { "blurb": "Cool and soft — muted, misty colors flatter your cool undertone beautifully.", "palette": [("Power Blue", "#B0E0E6"), ("Lavender", "#B497D6"), ("Rose Pink", "#D17B96"), ("Soft Teal", "#5F9EA0"), ("Dusty Mauve", "#A97C8A"), ("Slate Gray", "#6E7B8B")], }, "Winter": { "blurb": "Cool and deep — bold, high-contrast colors match your striking cool undertone.", "palette": [("True Red", "#C8102E"), ("Royal Blue", "#4169E1"), ("Emerald", "#2E8B57"), ("Black", "#1A1A1A"), ("White", "#FFFFFF"), ("Magenta", "#C2185B")], }, "Soft Spring": { "blurb": "A gentle warm-neutral mix — soft, peachy tones suit you better than stark contrast.", "palette": [("Soft Peach", "#F2C6A0"), ("Honey", "#E2B765"), ("Sage Green", "#9CAF88"), ("Camel", "#C19A6B"), ("Warm Ivory", "#F5EFE0"), ("Apricot", "#FBCEB1")], }, "Soft Autumn": { "blurb": "A muted warm-neutral mix — soft earth tones bring out your warmth without overpowering it.", "palette": [("Warm Earth", "#8B5A2B"), ("Sage", "#8A9A5B"), ("Caramel", "#AF6E4D"), ("Warm Taupe", "#9C8170"), ("Moss", "#6B7A4F"), ("Dusty Gold", "#B79766")], }, "Soft Summer": { "blurb": "A gentle cool-neutral mix — soft, dusty colors are more flattering than bright ones.", "palette": [("Dusty Rose", "#C68893"), ("Soft Lilac", "#C6B4D6"), ("Sage Gray", "#A6AD9E"), ("Mauve", "#9C7A8A"), ("Soft Denim", "#7C93A8"), ("Pearl Gray", "#C9C5C0")], }, "Soft Winter": { "blurb": "A muted cool-neutral mix — clear but gentle colors balance your cool undertone.", "palette": [("Plum", "#8E4585"), ("Slate Blue", "#5B6C8F"), ("Charcoal", "#3B3B3B"), ("Berry", "#7A2E4D"), ("Icy Pink", "#E7C6CE"), ("Steel Gray", "#71797E")], }, } def skin_depth_flag(skin_tone): s = str(skin_tone).lower().strip() if s in SKIN_DEPTH_ORDER: idx = SKIN_DEPTH_ORDER.index(s) midpoint = len(SKIN_DEPTH_ORDER) / 2 else: idx, midpoint = 1, 2 return "deep" if idx >= midpoint else "light" def derive_color_profile(skin_tone, undertone, eye_color=None): undertone = str(undertone).lower().strip() depth = skin_depth_flag(skin_tone) eye = str(eye_color).lower().strip() if eye_color else "" if undertone == "warm": season = "Spring" if depth == "light" else "Autumn" elif undertone == "cool": season = "Summer" if depth == "light" else "Winter" else: leans_warm = eye in WARM_EYES leans_cool = eye in COOL_EYES if depth == "light": season = "Soft Summer" if leans_cool and not leans_warm else "Soft Spring" else: season = "Soft Winter" if leans_cool and not leans_warm else "Soft Autumn" info = SEASON_INFO.get(season, SEASON_INFO["Spring"]) return season, info["blurb"], info["palette"] # ============================================================================= # Core Engine: Skin Heuristics & Search Pipeline # ============================================================================= def estimate_skin_tone_undertone(pil_image, available_skin_tones): img = pil_image.convert("RGB").resize((160, 160)) arr = np.asarray(img).astype(np.float32) h, w, _ = arr.shape y0, y1 = int(h * 0.15), int(h * 0.75) x0, x1 = int(w * 0.30), int(w * 0.70) crop = arr[y0:y1, x0:x1, :] r, g, b = crop[..., 0], crop[..., 1], crop[..., 2] y_ = 0.299 * r + 0.587 * g + 0.114 * b cb = 128 - 0.168736 * r - 0.331264 * g + 0.5 * b cr = 128 + 0.5 * r - 0.418688 * g - 0.081312 * b skin_mask = (y_ > 60) & (cb > 85) & (cb < 135) & (cr > 135) & (cr < 180) pixels = crop.reshape(-1, 3) if skin_mask.sum() < 50 else crop[skin_mask] mean_rgb = pixels.mean(axis=0) brightness = float(0.299 * mean_rgb[0] + 0.587 * mean_rgb[1] + 0.114 * mean_rgb[2]) available = {str(s).lower() for s in available_skin_tones} ordered = [s for s in SKIN_DEPTH_ORDER if s in available] or list(available_skin_tones) n = len(ordered) frac = 1.0 - min(max(brightness / 255.0, 0.0), 1.0) bucket_idx = min(int(frac * n), n - 1) if n else 0 skin_tone_guess = ordered[bucket_idx] if n else "medium" diff = float(mean_rgb[0] - mean_rgb[2]) if diff > 8: undertone_guess = "warm" elif diff < -8: undertone_guess = "cool" else: undertone_guess = "neutral" swatch_hex = "#{:02X}{:02X}{:02X}".format( *[int(min(max(c, 0), 255)) for c in mean_rgb] ) return skin_tone_guess, undertone_guess, swatch_hex def build_feature_sentence(skin_tone, undertone, style_preference, gender=None, age_group=None, eye_color=None): descriptor = " ".join(p for p in [age_group, gender] if p) or "person" sentence = ( f"a {descriptor} with {skin_tone} skin tone and {undertone} undertone, " f"wearing a {style_preference} style outfit" ) if eye_color: sentence += f", {str(eye_color).lower()} eyes" return sentence @torch.no_grad() def embed_query_image(pil_image, model, processor, device): inputs = processor(images=pil_image.convert("RGB"), return_tensors="pt").to(device) outputs = model.get_image_features(**inputs) feats = outputs.pooler_output if hasattr(outputs, "pooler_output") else outputs return feats.cpu().numpy().astype("float32") @torch.no_grad() def embed_query_text(sentence, model, processor, device): inputs = processor(text=[sentence], return_tensors="pt", padding=True, truncation=True).to(device) outputs = model.get_text_features(**inputs) feats = outputs.pooler_output if hasattr(outputs, "pooler_output") else outputs return feats.cpu().numpy().astype("float32") def faiss_filtered_search(query_emb, faiss_index, df_pool, top_k=3, exclude_idx=None, gender=None, age_group=None): query_emb = np.array(query_emb, dtype="float32").reshape(1, -1).copy() faiss.normalize_L2(query_emb) k = min(len(df_pool), faiss_index.ntotal) distances, indices = faiss_index.search(query_emb, k) distances, indices = distances[0], indices[0] def collect(filter_fn): kept_i, kept_d = [], [] for idx, dist in zip(indices, distances): if idx == -1 or (exclude_idx is not None and idx == exclude_idx): continue row = df_pool.iloc[idx] if not filter_fn(row): continue kept_i.append(int(idx)) kept_d.append(float(dist)) if len(kept_i) == top_k: break return kept_i, kept_d def gender_match(row): return gender is None or str(row["gender"]).lower() == str(gender).lower() def age_match(row): return age_group is None or str(row["age_group"]).lower() == str(age_group).lower() kept_i, kept_d = collect(lambda row: gender_match(row) and age_match(row)) tier = 1 if len(kept_i) < top_k: kept_i, kept_d = collect(gender_match) tier = 2 if len(kept_i) < top_k: kept_i, kept_d = collect(lambda row: True) tier = 3 idx_arr = np.array(kept_i) rows = df_pool.iloc[idx_arr] if len(idx_arr) else df_pool.iloc[0:0] return idx_arr, rows, np.array(kept_d), tier def normalize_gender(gender): g = str(gender).strip().lower() if gender is not None else "" if g in ("male", "man", "men", "m"): return "men" if g in ("female", "woman", "women", "f"): return "women" return "women" def component_shop_link(retailer, item_text, gender=None): """Build a robust, live retailer search URL for one outfit component.""" dept = normalize_gender(gender) text = str(item_text).strip() # URL encoding to safely preserve spacing and text literals across browsers query_encoded = urllib.parse.quote(text) if retailer == "zara": section = "MAN" if dept == "men" else "WOMAN" return RETAILER_SEARCH_URLS["zara"].format(query=query_encoded, section=section) if retailer == "asos": return RETAILER_SEARCH_URLS["asos"].format(query=query_encoded) # Standardizing search queries for H&M, Mango, and Shein by prepending department contexts gender_prefix = "mens" if dept == "men" else "womens" combined_query = urllib.parse.quote(f"{gender_prefix} {text}") if retailer in RETAILER_SEARCH_URLS: return RETAILER_SEARCH_URLS[retailer].format(query=combined_query) return f"https://www.google.com/search?q={query_encoded}" # ============================================================================= # HTML Luxury Rendering Engine # ============================================================================= def render_profile_card_html(season, blurb, palette): chips = "".join( f'
{html.escape(name)}
' for name, hexcode in palette ) return f"""
YOUR COLOR PROFILE
{html.escape(season)}
{html.escape(blurb)}
{chips}
""" def render_caption_html(caption): return ( '
🪄 AI Stylist note: ' f'“{html.escape(caption)}”
' ) def _component_html(label, text, retailer, gender): hexcode = swatch_color_for(text) link = component_shop_link(retailer, text, gender) return f"""
{label}
{html.escape(str(text))}
Shop ↗
""" def render_look_card_html(look_number, row, score_pct): avatar_hex = swatch_color_for(row.get("primary_color"), row.get("secondary_color")) gender = row.get("gender") components = [ ("TOP", row.get("outfit_top", ""), COMPONENT_RETAILERS["TOP"]), ("BOTTOM", row.get("outfit_bottom", ""), COMPONENT_RETAILERS["BOTTOM"]), ("SHOES", row.get("outfit_shoes", ""), COMPONENT_RETAILERS["SHOES"]), ("ACCESSORY", row.get("outfit_accessory", ""), COMPONENT_RETAILERS["ACCESSORY"]), ] comp_html = "".join(_component_html(label, text, retailer, gender) for label, text, retailer in components) style_pref = html.escape(str(row.get("style_preference", ""))) skin_tone = html.escape(str(row.get("skin_tone", ""))) colors_line = html.escape(str(row.get("recommended_colors", ""))) return f"""
Look #{look_number}
{style_pref} · {skin_tone} skin · {score_pct}% match
{comp_html}
""" def render_results_html(profile_html, look_cards_html_list, note=None): cards = "".join(look_cards_html_list) note_html = f'
{html.escape(note)}
' if note else "" return f"""
{profile_html}
YOUR MATCHED LOOKS
Top 3 outfits from your personal style dataset
{note_html}
{cards}
""" def render_error_html(message): return f"""
Something went wrong
{html.escape(str(message))}
""" def render_placeholder_html(): return """
Upload a photo or pick your features, then press “Find My Looks” to see your personal color profile and your top 3 matched outfits.
""" # ============================================================================= # Dataset & Model Loading # ============================================================================= def order_choices(values, preferred_order): vals = {str(v) for v in values} ordered = [p for p in preferred_order if p in vals] remaining = sorted(v for v in vals if v not in ordered) return ordered + remaining def load_catalog(): print(f"Loading dataset '{DATASET_REPO}' from Hub...") ds = load_dataset(DATASET_REPO) train = ds["train"] df = train.to_pandas() images = [train[i]["image_improved"] for i in range(len(train))] return df, images def load_embedding_model(): device = "cuda" if torch.cuda.is_available() else "cpu" processor = AutoProcessor.from_pretrained(EMBED_MODEL_REPO) model = AutoModel.from_pretrained(EMBED_MODEL_REPO).to(device).eval() return model, processor, device @torch.no_grad() def embed_catalog_images(model, processor, images, device, batch_size=32): embs = [] for i in range(0, len(images), batch_size): batch = [im.convert("RGB") for im in images[i:i + batch_size]] inputs = processor(images=batch, return_tensors="pt").to(device) outputs = model.get_image_features(**inputs) feats = outputs.pooler_output if hasattr(outputs, "pooler_output") else outputs feats = feats / feats.norm(dim=-1, keepdim=True) embs.append(feats.cpu().numpy()) return np.vstack(embs).astype("float32") def build_faiss_index(df, images, model, processor, device): image_embeddings = None if os.path.exists(EMBED_CACHE_PATH): try: cached = np.load(EMBED_CACHE_PATH) if cached.shape[0] == len(df): image_embeddings = cached except: pass if image_embeddings is None: image_embeddings = embed_catalog_images(model, processor, images, device) try: np.save(EMBED_CACHE_PATH, image_embeddings) except: pass dim = image_embeddings.shape[1] index = faiss.IndexFlatL2(dim) normalized = image_embeddings.copy() faiss.normalize_L2(normalized) index.add(normalized) return index def pick_quickstarts(df, n=3): eye_cycle = ["Brown", "Hazel", "Blue"] seen_styles, starters = set(), [] for _, row in df.iterrows(): style = row["style_preference"] if style in seen_styles: continue seen_styles.add(style) starters.append({ "skin_tone": row["skin_tone"], "undertone": row["undertone"], "style": style, "gender": row["gender"], "age_group": row["age_group"], "eye_color": eye_cycle[len(starters) % len(eye_cycle)], }) if len(starters) == n: break return starters # ============================================================================= # Custom UI Styling (Luxury Minimalist Theme) # ============================================================================= CUSTOM_CSS = """ @import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@200;300;400;500;600;700&family=Inter:wght@400;500;600;700&display=swap'); * { box-sizing: border-box; } body { font-family: 'Inter', system-ui, sans-serif !important; background: #FDFBF7 !important; } .gradio-container { max-width: 960px !important; margin: auto !important; background: #FDFBF7 !important; } /* Custom UI Variables */ .fs-root-output { --cream: #FDFBF7; --white: #FFFFFF; --charcoal: #1A1A1A; --charcoal-soft: #3A3A3A; --text-muted: #948D80; --hairline: #EEEEEE; --rose: #C48793; --rose-soft: #F6ECEE; --sage-soft: #EFF3EA; --gold: #C9A876; --gold-soft: #F8F1E4; } /* Tabs Navigation Alignment */ .tab-nav { border-bottom: 1px solid #EAE6DD !important; background: transparent !important; gap: 10px !important; } .tab-nav button { font-family: 'Montserrat', sans-serif !important; font-size: 13px !important; font-weight: 500 !important; letter-spacing: 0.1em !important; text-transform: uppercase !important; padding: 14px 28px !important; background: transparent !important; border: none !important; color: #948D80 !important; } .tab-nav button.selected { color: #1A1A1A !important; border-bottom: 2px solid #C9A876 !important; font-weight: 600 !important; } /* App Header Styling */ .fs-app-title { font-family: 'Montserrat', sans-serif; font-weight: 400; font-size: 34px; letter-spacing: 0.05em; color: #1A1A1A; margin-bottom: 6px; text-transform: uppercase; text-align: center; } /* Modern Action Button */ button.lg { background: #1A1A1A !important; color: #FFFFFF !important; border: 1px solid #1A1A1A !important; border-radius: 999px !important; font-family: 'Montserrat', sans-serif !important; font-weight: 600 !important; font-size: 12px !important; letter-spacing: 0.15em !important; text-transform: uppercase !important; padding: 14px !important; transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) !important; box-shadow: 0px 4px 12px rgba(0,0,0,0.05) !important; } button.lg:hover { background: #3A3A3A !important; transform: translateY(-1px) !important; } /* Output Cards & Containers */ .fs-placeholder { color: #948D80; font-size: 14px; padding: 64px 24px; text-align: center; border: 1px solid #EEEEEE; border-radius: 24px; background: #FFFFFF; letter-spacing: 0.02em; box-shadow: 0px 10px 30px rgba(0,0,0,0.01); } .fs-profile-card { background: linear-gradient(135deg, #F6ECEE 0%, #F8F1E4 100%); border: 1px solid #EEEEEE; border-radius: 24px; padding: 32px 34px; margin-bottom: 20px; box-shadow: 0px 12px 30px rgba(0,0,0,0.02); } .fs-profile-eyebrow { color: #C48793; font-weight: 600; font-size: 11px; letter-spacing: .18em; text-transform: uppercase; margin-bottom: 10px; } .fs-profile-season { font-family: 'Montserrat', sans-serif; font-weight: 300; font-size: 32px; letter-spacing: 0.02em; color: #1A1A1A; margin-bottom: 10px; } .fs-profile-blurb { color: #3A3A3A; font-size: 14.5px; max-width: 650px; margin-bottom: 20px; line-height: 1.6; } .fs-chip-row { display: flex; gap: 10px; flex-wrap: wrap; } .fs-chip { display: inline-flex; align-items: center; gap: 8px; background: #FFFFFF; border: 1px solid #EEEEEE; border-radius: 999px; padding: 6px 14px 6px 6px; font-size: 12px; font-weight: 500; color: #3A3A3A; letter-spacing: 0.02em; } .fs-chip-dot { width: 16px; height: 16px; border-radius: 50%; display: inline-block; box-shadow: 0 0 0 2px #FFF, 0 1px 4px rgba(0,0,0,.1); } .fs-header { position: relative; background: #FFFFFF; border: 1px solid #EEEEEE; border-radius: 24px; padding: 26px 30px; margin-bottom: 20px; overflow: hidden; box-shadow: 0px 10px 25px rgba(0,0,0,0.01); } .fs-header-decoration { position: absolute; top: -40px; right: -40px; width: 120px; height: 120px; border-radius: 50%; background: radial-gradient(circle, #F8F1E4 0%, transparent 70%); } .fs-header-eyebrow { color: #1A1A1A; font-family: 'Montserrat', sans-serif; font-weight: 500; font-size: 14px; letter-spacing: .2em; text-transform: uppercase; margin-bottom: 6px; position: relative; z-index: 1; } .fs-header-sub { font-size: 13.5px; color: #948D80; letter-spacing: 0.02em; position: relative; z-index: 1; font-style: italic; } .fs-note { background: #F8F1E4; color: #7a5c34; border: 1px solid #EADFC4; border-radius: 14px; padding: 12px 18px; font-size: 13px; margin-bottom: 18px; letter-spacing: 0.01em; } .fs-caption { background: #EFF3EA; border: 1px solid #EEEEEE; border-radius: 18px; padding: 16px 20px; margin-bottom: 20px; font-size: 14px; color: #3A3A3A; } .fs-caption-label { font-weight: 600; color: #1A1A1A; } /* Grid Layout Matrix */ .fs-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } @media (max-width: 900px) { .fs-grid { grid-template-columns: 1fr; } } .fs-look-card { background: #FFFFFF; border: 1px solid #EEEEEE; border-radius: 24px; padding: 20px; box-shadow: 0px 12px 30px rgba(0,0,0,0.02); border-top: 3px solid #C9A876; } .fs-look-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid #EEEEEE; } .fs-look-avatar { width: 42px; height: 42px; border-radius: 50%; flex-shrink: 0; border: 2px solid #FFFFFF; box-shadow: 0 0 0 2px #F8F1E4, 0 4px 8px rgba(0,0,0,0.04); } .fs-look-title { font-family: 'Montserrat', sans-serif; font-weight: 400; font-size: 18px; letter-spacing: 0.02em; color: #1A1A1A; text-transform: uppercase; } .fs-look-sub { color: #948D80; font-size: 12px; margin-top: 2px; letter-spacing: 0.01em; } .fs-component { border: 1px solid #EEEEEE; border-radius: 18px; overflow: hidden; margin-bottom: 12px; transition: all .3s cubic-bezier(0.25, 0.8, 0.25, 1); background: #FFFFFF; } .fs-component:hover { transform: translateY(-2px); box-shadow: 0px 8px 20px rgba(0,0,0,0.03); border-color: #F8F1E4; } .fs-component:last-child { margin-bottom: 0; } .fs-component-swatch { height: 58px; display: flex; align-items: center; justify-content: center; } .fs-swatch-dot { width: 28px; height: 28px; border-radius: 50%; display: inline-block; box-shadow: 0 0 0 3px #FFFFFF, 0 3px 8px rgba(0,0,0,0.06); } .fs-component-body { padding: 12px 14px; background: #FFFFFF; border-top: 1px solid #EEEEEE; } .fs-component-label { color: #948D80; font-weight: 600; font-size: 9px; letter-spacing: .15em; text-transform: uppercase; margin-bottom: 4px; } .fs-component-name { font-weight: 500; color: #1A1A1A; font-size: 13px; margin-bottom: 10px; line-height: 1.35; letter-spacing: 0.01em; min-height: 34px; } .fs-shop-btn { display: block; text-align: center; background: #1A1A1A; color: #FFFFFF !important; padding: 8px 16px; border-radius: 999px; font-size: 10.5px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; text-decoration: none !important; transition: all 0.2s ease; } .fs-shop-btn:hover { background: #3A3A3A; } .fs-colors-footer { color: #948D80; font-size: 11px; margin-top: 10px; padding-top: 12px; border-top: 1px solid #EEEEEE; letter-spacing: 0.01em; font-style: italic; text-align: center; } .fs-error { background: #FBEEEE; border: 1px solid #F0D6D6; border-radius: 16px; padding: 16px; } .fs-error-title { color: #9A4A4A; font-weight: 600; margin-bottom: 2px; } .fs-error-msg { color: #7a5c5c; font-size: 13px; } footer { display: none !important; } """ # ============================================================================= # Demo Interface Builder # ============================================================================= def build_demo(df, model, processor, faiss_index, device): skin_tone_choices = order_choices(df["skin_tone"].unique(), SKIN_DEPTH_ORDER) undertone_choices = order_choices(df["undertone"].unique(), UNDERTONE_ORDER) style_choices = sorted(df["style_preference"].unique().tolist()) gender_choices = sorted(df["gender"].unique().tolist()) age_choices = order_choices(df["age_group"].unique(), AGE_ORDER) quickstarts = pick_quickstarts(df) caption_pipe_holder = {"pipe": None, "failed": False} def get_caption_pipe(): if caption_pipe_holder["pipe"] is None and not caption_pipe_holder["failed"]: try: from transformers import pipeline as hf_pipeline print(f"Loading GenAI model '{CAPTION_MODEL_REPO}'...") caption_pipe_holder["pipe"] = hf_pipeline( "text-generation", model=CAPTION_MODEL_REPO, device=0 if device == "cuda" else -1, ) except Exception as e: print(f"GenAI loading bypassed: {e}") caption_pipe_holder["failed"] = True return caption_pipe_holder["pipe"] def generate_caption(row): pipe = get_caption_pipe() if pipe is None: return None try: user_prompt = ( "Write one short, warm sentence (max 25 words) from a fashion stylist, " f"recommending this look: a {row['style_preference']} style outfit in " f"{row['primary_color']} and {row['secondary_color']}, best colors: " f"{row['recommended_colors']}. Be specific and stylish, no hashtags." ) messages = [{"role": "user", "content": user_prompt}] out = pipe(messages, max_new_tokens=40, do_sample=True, temperature=0.7) return out[0]["generated_text"][-1]["content"].strip() except Exception as e: print(f"[Caption skipped] {e}") return None def predict(mode, photo, photo_skin_override, photo_undertone_override, manual_skin, manual_undertone, style, gender, age_group, eye_color, want_caption): try: is_photo_mode = str(mode).startswith("📷") if is_photo_mode: if photo is None: return render_error_html( "Please upload a photo, or switch to “Manual Selection”." ) detected_skin, detected_undertone, _ = estimate_skin_tone_undertone( photo, skin_tone_choices ) skin_tone = ( detected_skin if photo_skin_override in (None, "", AUTO_DETECT_LABEL) else photo_skin_override ) undertone = ( detected_undertone if photo_undertone_override in (None, "", AUTO_DETECT_LABEL) else photo_undertone_override ) query_emb = embed_query_image(photo, model, processor, device) if style: style_emb = embed_query_text( f"wearing a {style} style outfit", model, processor, device ) query_emb = 0.75 * query_emb + 0.25 * style_emb else: skin_tone = manual_skin undertone = manual_undertone sentence = build_feature_sentence( skin_tone, undertone, style, gender, age_group, eye_color ) query_emb = embed_query_text(sentence, model, processor, device) indices, rows, distances, tier = faiss_filtered_search( query_emb, faiss_index, df, top_k=3, exclude_idx=None, gender=gender or None, age_group=age_group or None, ) if len(indices) == 0: return render_error_html( "No matching looks were found in the catalog for these filters — " "try a different style, gender, or age group." ) season, blurb, palette = derive_color_profile(skin_tone, undertone, eye_color) profile_html = render_profile_card_html(season, blurb, palette) scores = [max(0.0, min(1.0, 1.0 - d / 2.0)) for d in distances] look_cards = [ render_look_card_html(i + 1, rows.iloc[i], round(scores[i] * 100)) for i in range(len(rows)) ] extra_html = "" if want_caption: caption = generate_caption(rows.iloc[0]) if caption: extra_html = render_caption_html(caption) note = None if tier == 2: note = "We broadened the search beyond the exact age group to find your best matches." elif tier == 3: note = "We expanded the search beyond your filters so you still get great matches." return extra_html + render_results_html(profile_html, look_cards, note=note) except Exception as e: traceback.print_exc() return render_error_html(f"{type(e).__name__}: {e}") def toggle_mode(mode): is_photo = str(mode).startswith("📷") return gr.update(visible=is_photo), gr.update(visible=not is_photo) theme = gr.themes.Soft( primary_hue=gr.themes.colors.stone, secondary_hue=gr.themes.colors.stone, neutral_hue=gr.themes.colors.stone, font=gr.themes.GoogleFont("Inter"), ).set( body_background_fill="#FDFBF7", block_background_fill="#FFFFFF", block_border_color="#EAE6DD", block_radius="24px", container_radius="24px", block_shadow="0px 12px 35px rgba(0,0,0,0.02)", block_label_text_color="#948D80", block_label_text_weight="500", block_title_text_color="#1A1A1A", panel_background_fill="#FFFFFF", input_background_fill="#FDFBF7", input_border_color="#EAE6DD", input_border_color_focus="#C9A876", input_radius="14px", checkbox_border_radius="8px", checkbox_background_color="#FDFBF7", checkbox_background_color_selected="#C48793", checkbox_border_color_selected="#C48793", ) with gr.Blocks(css=CUSTOM_CSS, theme=theme, title="Personal Style Matcher") as demo: gr.HTML("""
Personal Color Styling

Personal Style Matcher

AI-powered outfit recommendations from your personal style dataset

""") with gr.Row(): with gr.Column(scale=1): mode = gr.Radio( ["📷 Upload My Photo", "🎛️ Manual Selection"], value="🎛️ Manual Selection", label="How would you like to start?", ) with gr.Group(visible=False) as photo_group: photo = gr.Image(label="Upload a clear, front-facing photo", type="pil") photo_skin_override = gr.Dropdown( [AUTO_DETECT_LABEL] + skin_tone_choices, value=AUTO_DETECT_LABEL, label="Skin tone (auto-detected — override if needed)", ) photo_undertone_override = gr.Dropdown( [AUTO_DETECT_LABEL] + undertone_choices, value=AUTO_DETECT_LABEL, label="Undertone (auto-detected — override if needed)", ) with gr.Group(visible=True) as manual_group: manual_skin = gr.Dropdown( skin_tone_choices, value=skin_tone_choices[0], label="Skin Tone" ) manual_undertone = gr.Dropdown( undertone_choices, value=undertone_choices[0], label="Undertone" ) style = gr.Dropdown( style_choices, value=style_choices[0], label="Clothing Style" ) with gr.Row(): gender = gr.Dropdown( gender_choices, value=gender_choices[0], label="Gender" ) age_group = gr.Dropdown( age_choices, value=age_choices[0], label="Age Group" ) eye_color = gr.Dropdown( EYE_COLOR_CHOICES, value=EYE_COLOR_CHOICES[0], label="Eye Color" ) want_caption = gr.Checkbox( label="✨ Add an AI stylist note (small GenAI text model)", value=False ) submit_btn = gr.Button("Find My Looks", variant="primary", size="lg") with gr.Column(scale=2): output_html = gr.HTML(value=render_placeholder_html()) mode.change(toggle_mode, inputs=mode, outputs=[photo_group, manual_group]) predict_inputs = [ mode, photo, photo_skin_override, photo_undertone_override, manual_skin, manual_undertone, style, gender, age_group, eye_color, want_caption, ] submit_btn.click(predict, inputs=predict_inputs, outputs=output_html) if quickstarts: example_rows = [ [ "🎛️ Manual Selection", None, AUTO_DETECT_LABEL, AUTO_DETECT_LABEL, qs["skin_tone"], qs["undertone"], qs["style"], qs["gender"], qs["age_group"], qs["eye_color"], False, ] for qs in quickstarts ] gr.Examples( examples=example_rows, inputs=predict_inputs, outputs=output_html, fn=predict, run_on_click=True, cache_examples=False, label="✨ Quick Starters — click one to see it in action", ) return demo # ============================================================================= # Runtime Entry Point # ============================================================================= if __name__ == "__main__": df, images = load_catalog() clip_model, clip_processor, device = load_embedding_model() faiss_index = build_faiss_index(df, images, clip_model, clip_processor, device) demo = build_demo(df, clip_model, clip_processor, faiss_index, device) demo.queue().launch()