| """ |
| 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 |
|
|
| |
| |
| |
|
|
| DATASET_REPO = "lihicarmeli/fashion-stylist-multimodal-v2" |
| EMBED_MODEL_REPO = "openai/clip-vit-base-patch32" |
| CAPTION_MODEL_REPO = "Qwen/Qwen2.5-0.5B-Instruct" |
| 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", |
| } |
|
|
| |
| 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" |
| } |
|
|
| |
| |
| |
|
|
| NAMED_COLOR_HEX = { |
| |
| "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", |
| |
| "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: |
| h += 0.25 |
| 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: |
| 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") |
|
|
| |
| |
| |
|
|
| 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"] |
|
|
| |
| |
| |
|
|
| 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() |
| |
| |
| 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) |
| |
| |
| 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}" |
|
|
| |
| |
| |
|
|
| def render_profile_card_html(season, blurb, palette): |
| chips = "".join( |
| f'<div class="fs-chip"><span class="fs-chip-dot" style="background:{hexcode}"></span>{html.escape(name)}</div>' |
| for name, hexcode in palette |
| ) |
| return f""" |
| <div class="fs-profile-card"> |
| <div class="fs-profile-eyebrow">YOUR COLOR PROFILE</div> |
| <div class="fs-profile-season">{html.escape(season)}</div> |
| <div class="fs-profile-blurb">{html.escape(blurb)}</div> |
| <div class="fs-chip-row">{chips}</div> |
| </div> |
| """ |
|
|
| def render_caption_html(caption): |
| return ( |
| '<div class="fs-caption">🪄 <span class="fs-caption-label">AI Stylist note:</span> ' |
| f'“{html.escape(caption)}”</div>' |
| ) |
|
|
| def _component_html(label, text, retailer, gender): |
| hexcode = swatch_color_for(text) |
| link = component_shop_link(retailer, text, gender) |
| return f""" |
| <div class="fs-component"> |
| <div class="fs-component-swatch" style="background:{hexcode}15;"> |
| <span class="fs-swatch-dot" style="background:{hexcode};"></span> |
| </div> |
| <div class="fs-component-body"> |
| <div class="fs-component-label">{label}</div> |
| <div class="fs-component-name">{html.escape(str(text))}</div> |
| <a class="fs-shop-btn" href="{link}" target="_blank" rel="noopener noreferrer">Shop ↗</a> |
| </div> |
| </div> |
| """ |
|
|
| 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""" |
| <div class="fs-look-card"> |
| <div class="fs-look-head"> |
| <span class="fs-look-avatar" style="background:{avatar_hex}"></span> |
| <div> |
| <div class="fs-look-title">Look #{look_number}</div> |
| <div class="fs-look-sub">{style_pref} · {skin_tone} skin · {score_pct}% match</div> |
| </div> |
| </div> |
| {comp_html} |
| <div class="fs-colors-footer">Recommended colors: {colors_line}</div> |
| </div> |
| """ |
|
|
| def render_results_html(profile_html, look_cards_html_list, note=None): |
| cards = "".join(look_cards_html_list) |
| note_html = f'<div class="fs-note">{html.escape(note)}</div>' if note else "" |
| return f""" |
| <div class="fs-root-output"> |
| {profile_html} |
| <div class="fs-header"> |
| <div class="fs-header-decoration"></div> |
| <div class="fs-header-eyebrow">YOUR MATCHED LOOKS</div> |
| <div class="fs-header-sub">Top 3 outfits from your personal style dataset</div> |
| </div> |
| {note_html} |
| <div class="fs-grid">{cards}</div> |
| </div> |
| """ |
|
|
| def render_error_html(message): |
| return f""" |
| <div class="fs-root-output"> |
| <div class="fs-error"> |
| <div class="fs-error-title">Something went wrong</div> |
| <div class="fs-error-msg">{html.escape(str(message))}</div> |
| </div> |
| </div> |
| """ |
|
|
| def render_placeholder_html(): |
| return """ |
| <div class="fs-root-output"> |
| <div class="fs-placeholder"> |
| Upload a photo or pick your features, then press |
| <strong>“Find My Looks”</strong> to see your personal color profile |
| and your top 3 matched outfits. |
| </div> |
| </div> |
| """ |
|
|
| |
| |
| |
|
|
| 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_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; } |
| """ |
|
|
| |
| |
| |
|
|
| 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(""" |
| <div style="text-align:center;padding:40px 20px 24px;"> |
| <div style="font-size:11px;font-weight:600;color:#C9A876;letter-spacing:.2em; |
| text-transform:uppercase;margin-bottom:8px;">Personal Color Styling</div> |
| <h1 class="fs-app-title">Personal Style Matcher</h1> |
| <p style="font-size:14px;color:#948D80;margin:0;font-style:italic;letter-spacing:0.02em;"> |
| AI-powered outfit recommendations from your personal style dataset |
| </p> |
| </div> |
| <div style="height:1px;background:linear-gradient(90deg,transparent,#EAE6DD,transparent); |
| margin:0 20px 28px;"></div> |
| """) |
|
|
| 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 |
|
|
| |
| |
| |
|
|
| 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() |