romi2001 commited on
Commit
d06f3c3
·
verified ·
1 Parent(s): f6e69c0

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +889 -0
app.py ADDED
@@ -0,0 +1,889 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Personal Style Matcher — Final Project, Part 5 (Hugging Face Space application)
3
+ ==================================================================================
4
+
5
+ A Gradio app that recommends fashion looks from the "Fashion Stylist
6
+ Multimodal v2" catalog (lihicarmeli/fashion-stylist-multimodal-v2 on the
7
+ Hugging Face Hub), using the exact same engine validated in Part 3:
8
+
9
+ * Embedding model : openai/clip-vit-base-patch32 (the model selected
10
+ after the multi-criteria evaluation in Part 3 — best speed/size/effort
11
+ trade-off, within 1.3 purity points of the larger CLIP variant).
12
+ * Vector search : a FAISS flat-L2 index over the catalog's CLIP image
13
+ embeddings, with the same 3-tier demographic fallback filter used in
14
+ the notebook (strict gender+age -> gender-only -> fully open search).
15
+ * GenAI component : an optional one-line AI stylist note generated by
16
+ a small instruction-tuned LM (Qwen/Qwen2.5-0.5B-Instruct), reusing the
17
+ exact prompt pattern from Part 4.
18
+
19
+ Two ways in, one engine:
20
+ Mode A — upload a photo. A lightweight heuristic samples the face region
21
+ to estimate a skin tone / undertone, and the photo itself is
22
+ embedded with CLIP's image encoder for the catalog search.
23
+ Mode B — pick your features from dropdowns. A natural-language sentence
24
+ is built from those features and embedded with CLIP's text
25
+ encoder, landing in the exact same vector space as the photos.
26
+
27
+ Deploy: push this file + requirements.txt to a new Hugging Face Space using
28
+ the "Gradio" SDK. No local data or model files are needed — both the
29
+ dataset and the embedding model are streamed directly from the Hub.
30
+ """
31
+
32
+ import os
33
+ import html
34
+ import hashlib
35
+ import colorsys
36
+ import traceback
37
+ import urllib.parse
38
+
39
+ import numpy as np
40
+ import torch
41
+
42
+ import faiss
43
+ from datasets import load_dataset
44
+ from transformers import AutoModel, AutoProcessor
45
+
46
+ import gradio as gr
47
+
48
+
49
+ # =============================================================================
50
+ # Constants
51
+ # =============================================================================
52
+
53
+ DATASET_REPO = "lihicarmeli/fashion-stylist-multimodal-v2"
54
+ EMBED_MODEL_REPO = "openai/clip-vit-base-patch32" # winning model from Part 3
55
+ CAPTION_MODEL_REPO = "Qwen/Qwen2.5-0.5B-Instruct" # small LM from Part 4
56
+ EMBED_CACHE_PATH = "catalog_image_embeddings.npy"
57
+
58
+ SKIN_DEPTH_ORDER = ["fair", "light", "ivory", "porcelain", "medium",
59
+ "olive", "tan", "deep", "dark", "ebony"]
60
+ AGE_ORDER = ["teen", "young adult", "adult", "mature"]
61
+ UNDERTONE_ORDER = ["warm", "neutral", "cool"]
62
+ EYE_COLOR_CHOICES = ["Brown", "Dark Brown", "Hazel", "Amber", "Green", "Blue", "Gray"]
63
+ WARM_EYES = {"brown", "dark brown", "hazel", "amber"}
64
+ COOL_EYES = {"green", "blue", "gray", "grey"}
65
+
66
+ AUTO_DETECT_LABEL = "Auto-detect from photo"
67
+
68
+ COMPONENT_RETAILERS = {
69
+ "TOP": "zara",
70
+ "BOTTOM": "hm",
71
+ "SHOES": "asos",
72
+ "ACCESSORY": "mango",
73
+ }
74
+
75
+ RETAILER_SEARCH_URLS = {
76
+ "zara": "https://www.zara.com/us/en/search?searchTerm={query}&section={section}",
77
+ "hm": "https://www2.hm.com/en_us/search-results.html?q={query}",
78
+ "asos": "https://www.asos.com/us/{dept}/search/?q={query}",
79
+ "mango": "https://shop.mango.com/us/en/search?kw={query}",
80
+ "shein": "https://us.shein.com/pdsearch/{query}/",
81
+ }
82
+
83
+
84
+ # =============================================================================
85
+ # Color utilities — resolve a descriptive color phrase to a hex swatch
86
+ # =============================================================================
87
+
88
+ NAMED_COLOR_HEX = {
89
+ # multi-word fashion phrases (checked first, longest match wins)
90
+ "ice white": "#F5F5F0", "warm white": "#FAF3E8", "deep jewel": "#5B2C6F",
91
+ "bold blue": "#1E5AA8", "royal blue": "#4169E1", "cobalt blue": "#0047AB",
92
+ "powder blue": "#B0E0E6", "rich gold": "#C9A227", "warm beige": "#E8D9B5",
93
+ "warm brown": "#8B5A2B", "cool red": "#C8102E", "bright red": "#EE2C2C",
94
+ "brick red": "#9B3A2C", "deep teal": "#114B5F", "deep olive": "#4B5320",
95
+ "deep purple": "#4B1A6B", "forest green": "#1B4332", "bright orange": "#FF6B1A",
96
+ "bright pink": "#FF2D87", "dusty rose": "#C68893", "blush pink": "#F4C2C2",
97
+ "grass green": "#3DA35D", "slate gray": "#6E7B8B", "burnt orange": "#CB6015",
98
+ "golden yellow": "#F5C518", "true red": "#C8102E", "bright warm red": "#E0382D",
99
+ "very deep": "#3A2A22", "cool blue": "#3B6FCC",
100
+ # single words (fallback)
101
+ "white": "#FFFFFF", "black": "#1A1A1A", "ivory": "#FFFFF0", "silver": "#C0C0C0",
102
+ "gold": "#D4AF37", "amber": "#FFBF00", "tangerine": "#F28500", "coral": "#FF7F50",
103
+ "peach": "#FFCBA4", "terracotta": "#C0654D", "olive": "#708238", "beige": "#E8D9B5",
104
+ "tan": "#C19A6B", "khaki": "#C3B091", "emerald": "#2E8B57", "teal": "#218380",
105
+ "turquoise": "#30D5C8", "mint": "#98D8C8", "navy": "#1B1F3B", "blue": "#3B6FCC",
106
+ "lavender": "#B497D6", "plum": "#8E4585", "magenta": "#C2185B", "fuchsia": "#D6336C",
107
+ "purple": "#7B4397", "maroon": "#7A2E3B", "burgundy": "#6E0D25", "crimson": "#A11D33",
108
+ "red": "#D63447", "pink": "#E07A9E", "rose": "#D17B96", "yellow": "#F4C542",
109
+ "mustard": "#C9A227", "orange": "#E8722C", "brown": "#7B4B2A", "chocolate": "#5A3A22",
110
+ "copper": "#B6622A", "rust": "#9E4624", "green": "#3F8D5C", "grey": "#9A9A9A",
111
+ "gray": "#9A9A9A", "charcoal": "#3B3B3B", "cream": "#F1E8DA",
112
+ }
113
+
114
+
115
+ def resolve_color_hex(text):
116
+ """Return a hex color if any known color phrase appears in `text`, else None."""
117
+ if not text:
118
+ return None
119
+ t = str(text).lower()
120
+ for phrase in sorted(NAMED_COLOR_HEX, key=len, reverse=True):
121
+ if " " in phrase and phrase in t:
122
+ return NAMED_COLOR_HEX[phrase]
123
+ for word in t.replace(",", " ").split():
124
+ if word in NAMED_COLOR_HEX:
125
+ return NAMED_COLOR_HEX[word]
126
+ return None
127
+
128
+
129
+ def text_to_pastel_hex(text):
130
+ """Deterministic fallback color: hash the text into a pleasant, distinct hue."""
131
+ digest = hashlib.md5(str(text).encode("utf-8")).hexdigest()
132
+ hue = int(digest[:4], 16) / 65535.0
133
+ r, g, b = colorsys.hls_to_rgb(hue, 0.55, 0.55)
134
+ return "#{:02X}{:02X}{:02X}".format(int(r * 255), int(g * 255), int(b * 255))
135
+
136
+
137
+ def swatch_color_for(*texts):
138
+ """Pick the first resolvable color among the given text fields, or fall back
139
+ to a stable hashed pastel so the same item always gets the same swatch."""
140
+ for t in texts:
141
+ hexcode = resolve_color_hex(t)
142
+ if hexcode:
143
+ return hexcode
144
+ joined = " ".join(str(t) for t in texts if t)
145
+ return text_to_pastel_hex(joined or "style")
146
+
147
+
148
+ # =============================================================================
149
+ # Seasonal color profile (display-only "stylist" layer on top of the catalog)
150
+ # =============================================================================
151
+
152
+ SEASON_INFO = {
153
+ "Spring": {
154
+ "blurb": "Warm and light — your glow loves clear, fresh colors with a golden undertone.",
155
+ "palette": [("Coral", "#FF7F50"), ("Peach", "#FFCBA4"), ("Golden Yellow", "#F5C518"),
156
+ ("Grass Green", "#3DA35D"), ("Turquoise", "#30D5C8"), ("Ivory", "#FFFFF0")],
157
+ },
158
+ "Autumn": {
159
+ "blurb": "Warm and rich — earthy, spiced tones make your natural warmth shine.",
160
+ "palette": [("Rust", "#9E4624"), ("Olive", "#708238"), ("Mustard", "#C9A227"),
161
+ ("Chocolate Brown", "#5A3A22"), ("Burnt Orange", "#CB6015"), ("Forest Green", "#1B4332")],
162
+ },
163
+ "Summer": {
164
+ "blurb": "Cool and soft — muted, misty colors flatter your cool undertone beautifully.",
165
+ "palette": [("Powder Blue", "#B0E0E6"), ("Lavender", "#B497D6"), ("Rose Pink", "#D17B96"),
166
+ ("Soft Teal", "#5F9EA0"), ("Dusty Mauve", "#A97C8A"), ("Slate Gray", "#6E7B8B")],
167
+ },
168
+ "Winter": {
169
+ "blurb": "Cool and deep — bold, high-contrast colors match your striking cool undertone.",
170
+ "palette": [("True Red", "#C8102E"), ("Royal Blue", "#4169E1"), ("Emerald", "#2E8B57"),
171
+ ("Black", "#1A1A1A"), ("White", "#FFFFFF"), ("Magenta", "#C2185B")],
172
+ },
173
+ "Soft Spring": {
174
+ "blurb": "A gentle warm-neutral mix — soft, peachy tones suit you better than stark contrast.",
175
+ "palette": [("Soft Peach", "#F2C6A0"), ("Honey", "#E2B765"), ("Sage Green", "#9CAF88"),
176
+ ("Camel", "#C19A6B"), ("Warm Ivory", "#F5EFE0"), ("Apricot", "#FBCEB1")],
177
+ },
178
+ "Soft Autumn": {
179
+ "blurb": "A muted warm-neutral mix — soft earth tones bring out your warmth without overpowering it.",
180
+ "palette": [("Terracotta", "#C0654D"), ("Sage", "#8A9A5B"), ("Caramel", "#AF6E4D"),
181
+ ("Warm Taupe", "#9C8170"), ("Moss", "#6B7A4F"), ("Dusty Gold", "#B79766")],
182
+ },
183
+ "Soft Summer": {
184
+ "blurb": "A gentle cool-neutral mix — soft, dusty colors are more flattering than bright ones.",
185
+ "palette": [("Dusty Rose", "#C68893"), ("Soft Lilac", "#C6B4D6"), ("Sage Gray", "#A6AD9E"),
186
+ ("Mauve", "#9C7A8A"), ("Soft Denim", "#7C93A8"), ("Pearl Gray", "#C9C5C0")],
187
+ },
188
+ "Soft Winter": {
189
+ "blurb": "A muted cool-neutral mix — clear but gentle colors balance your cool undertone.",
190
+ "palette": [("Plum", "#8E4585"), ("Slate Blue", "#5B6C8F"), ("Charcoal", "#3B3B3B"),
191
+ ("Berry", "#7A2E4D"), ("Icy Pink", "#E7C6CE"), ("Steel Gray", "#71797E")],
192
+ },
193
+ }
194
+
195
+
196
+ def skin_depth_flag(skin_tone):
197
+ """Bucket a skin-tone label into 'light' or 'deep' using a best-effort ordering."""
198
+ s = str(skin_tone).lower().strip()
199
+ if s in SKIN_DEPTH_ORDER:
200
+ idx = SKIN_DEPTH_ORDER.index(s)
201
+ midpoint = len(SKIN_DEPTH_ORDER) / 2
202
+ else:
203
+ idx, midpoint = 1, 2 # unseen label -> assume mid-range
204
+ return "deep" if idx >= midpoint else "light"
205
+
206
+
207
+ def derive_color_profile(skin_tone, undertone, eye_color=None):
208
+ """Map (skin tone, undertone, eye color) onto a simplified 4/8-season
209
+ color-analysis bucket. This is a styling heuristic, not a clinical tool —
210
+ it gives the user a friendly, explainable color story to go with their
211
+ matched looks."""
212
+ undertone = str(undertone).lower().strip()
213
+ depth = skin_depth_flag(skin_tone)
214
+ eye = str(eye_color).lower().strip() if eye_color else ""
215
+
216
+ if undertone == "warm":
217
+ season = "Spring" if depth == "light" else "Autumn"
218
+ elif undertone == "cool":
219
+ season = "Summer" if depth == "light" else "Winter"
220
+ else:
221
+ leans_warm = eye in WARM_EYES
222
+ leans_cool = eye in COOL_EYES
223
+ if depth == "light":
224
+ season = "Soft Summer" if leans_cool and not leans_warm else "Soft Spring"
225
+ else:
226
+ season = "Soft Winter" if leans_cool and not leans_warm else "Soft Autumn"
227
+
228
+ info = SEASON_INFO.get(season, SEASON_INFO["Spring"])
229
+ return season, info["blurb"], info["palette"]
230
+
231
+
232
+ # =============================================================================
233
+ # Skin tone / undertone estimation from an uploaded photo (Mode A)
234
+ # =============================================================================
235
+
236
+ def estimate_skin_tone_undertone(pil_image, available_skin_tones):
237
+ """A lightweight, transparent heuristic — NOT a clinical skin-tone
238
+ classifier. It samples a centered, face-shaped region of the photo, keeps
239
+ only pixels that look skin-like in YCbCr space, and buckets the resulting
240
+ average color by brightness (-> skin tone bucket) and red/blue balance
241
+ (-> warm / cool / neutral undertone)."""
242
+ img = pil_image.convert("RGB").resize((160, 160))
243
+ arr = np.asarray(img).astype(np.float32)
244
+
245
+ h, w, _ = arr.shape
246
+ y0, y1 = int(h * 0.15), int(h * 0.75)
247
+ x0, x1 = int(w * 0.30), int(w * 0.70)
248
+ crop = arr[y0:y1, x0:x1, :]
249
+
250
+ r, g, b = crop[..., 0], crop[..., 1], crop[..., 2]
251
+ y_ = 0.299 * r + 0.587 * g + 0.114 * b
252
+ cb = 128 - 0.168736 * r - 0.331264 * g + 0.5 * b
253
+ cr = 128 + 0.5 * r - 0.418688 * g - 0.081312 * b
254
+ skin_mask = (y_ > 60) & (cb > 85) & (cb < 135) & (cr > 135) & (cr < 180)
255
+
256
+ pixels = crop.reshape(-1, 3) if skin_mask.sum() < 50 else crop[skin_mask]
257
+ mean_rgb = pixels.mean(axis=0)
258
+ brightness = float(0.299 * mean_rgb[0] + 0.587 * mean_rgb[1] + 0.114 * mean_rgb[2])
259
+
260
+ available = {str(s).lower() for s in available_skin_tones}
261
+ ordered = [s for s in SKIN_DEPTH_ORDER if s in available] or list(available_skin_tones)
262
+ n = len(ordered)
263
+ frac = 1.0 - min(max(brightness / 255.0, 0.0), 1.0) # darker photo -> higher frac
264
+ bucket_idx = min(int(frac * n), n - 1) if n else 0
265
+ skin_tone_guess = ordered[bucket_idx] if n else "medium"
266
+
267
+ diff = float(mean_rgb[0] - mean_rgb[2]) # R - B
268
+ if diff > 8:
269
+ undertone_guess = "warm"
270
+ elif diff < -8:
271
+ undertone_guess = "cool"
272
+ else:
273
+ undertone_guess = "neutral"
274
+
275
+ swatch_hex = "#{:02X}{:02X}{:02X}".format(
276
+ *[int(min(max(c, 0), 255)) for c in mean_rgb]
277
+ )
278
+ return skin_tone_guess, undertone_guess, swatch_hex
279
+
280
+
281
+ # =============================================================================
282
+ # Query building & embedding (mirrors the validated Part 3 pipeline exactly)
283
+ # =============================================================================
284
+
285
+ def build_feature_sentence(skin_tone, undertone, style_preference, gender=None,
286
+ age_group=None, eye_color=None):
287
+ """Path B: turn typed-in features into a sentence in the same vector
288
+ space CLIP's text encoder was trained on, mirroring Part 3's
289
+ build_feature_sentence()."""
290
+ descriptor = " ".join(p for p in [age_group, gender] if p) or "person"
291
+ sentence = (
292
+ f"a {descriptor} with {skin_tone} skin tone and {undertone} undertone, "
293
+ f"wearing a {style_preference} style outfit"
294
+ )
295
+ if eye_color:
296
+ sentence += f", {str(eye_color).lower()} eyes"
297
+ return sentence
298
+
299
+
300
+ @torch.no_grad()
301
+ def embed_query_image(pil_image, model, processor, device):
302
+ """Path A: turn a user-uploaded photo into a CLIP image embedding."""
303
+ inputs = processor(images=pil_image.convert("RGB"), return_tensors="pt").to(device)
304
+ outputs = model.get_image_features(**inputs)
305
+ feats = outputs.pooler_output if hasattr(outputs, "pooler_output") else outputs
306
+ return feats.cpu().numpy().astype("float32")
307
+
308
+
309
+ @torch.no_grad()
310
+ def embed_query_text(sentence, model, processor, device):
311
+ """Path B: turn a feature sentence into a CLIP text embedding."""
312
+ inputs = processor(text=[sentence], return_tensors="pt", padding=True,
313
+ truncation=True).to(device)
314
+ outputs = model.get_text_features(**inputs)
315
+ feats = outputs.pooler_output if hasattr(outputs, "pooler_output") else outputs
316
+ return feats.cpu().numpy().astype("float32")
317
+
318
+
319
+ def faiss_filtered_search(query_emb, faiss_index, df_pool, top_k=3, exclude_idx=None,
320
+ gender=None, age_group=None):
321
+ """Self-exclusion + graded demographic filtering fallback, identical in
322
+ spirit to Part 3's faiss_filtered_search: Tier 1 (gender + age) -> Tier 2
323
+ (gender only) -> Tier 3 (fully open). Returns (indices, rows, distances, tier)."""
324
+ query_emb = np.array(query_emb, dtype="float32").reshape(1, -1).copy()
325
+ faiss.normalize_L2(query_emb)
326
+ k = min(len(df_pool), faiss_index.ntotal)
327
+ distances, indices = faiss_index.search(query_emb, k)
328
+ distances, indices = distances[0], indices[0]
329
+
330
+ def collect(filter_fn):
331
+ kept_i, kept_d = [], []
332
+ for idx, dist in zip(indices, distances):
333
+ if idx == -1 or (exclude_idx is not None and idx == exclude_idx):
334
+ continue
335
+ row = df_pool.iloc[idx]
336
+ if not filter_fn(row):
337
+ continue
338
+ kept_i.append(int(idx))
339
+ kept_d.append(float(dist))
340
+ if len(kept_i) == top_k:
341
+ break
342
+ return kept_i, kept_d
343
+
344
+ def gender_match(row):
345
+ return gender is None or str(row["gender"]).lower() == str(gender).lower()
346
+
347
+ def age_match(row):
348
+ return age_group is None or str(row["age_group"]).lower() == str(age_group).lower()
349
+
350
+ kept_i, kept_d = collect(lambda row: gender_match(row) and age_match(row))
351
+ tier = 1
352
+ if len(kept_i) < top_k:
353
+ kept_i, kept_d = collect(gender_match)
354
+ tier = 2
355
+ if len(kept_i) < top_k:
356
+ kept_i, kept_d = collect(lambda row: True)
357
+ tier = 3
358
+
359
+ idx_arr = np.array(kept_i)
360
+ rows = df_pool.iloc[idx_arr] if len(idx_arr) else df_pool.iloc[0:0]
361
+ return idx_arr, rows, np.array(kept_d), tier
362
+
363
+
364
+ # =============================================================================
365
+ # Real, working shop links
366
+ # =============================================================================
367
+
368
+ def normalize_gender(gender):
369
+ g = str(gender).strip().lower() if gender is not None else ""
370
+ if g in ("male", "man", "men", "m"):
371
+ return "men"
372
+ if g in ("female", "woman", "women", "f"):
373
+ return "women"
374
+ return "women"
375
+
376
+
377
+ def component_shop_link(retailer, item_text, gender=None):
378
+ """Build a real, live retailer search URL for one outfit component."""
379
+ dept = normalize_gender(gender)
380
+ text = str(item_text)
381
+ if retailer == "zara":
382
+ section = "MAN" if dept == "men" else "WOMAN"
383
+ query = urllib.parse.quote(text)
384
+ return RETAILER_SEARCH_URLS["zara"].format(query=query, section=section)
385
+ if retailer == "asos":
386
+ query = urllib.parse.quote(text)
387
+ return RETAILER_SEARCH_URLS["asos"].format(query=query, dept=dept)
388
+ gender_word = "men's" if dept == "men" else "women's"
389
+ query = urllib.parse.quote(f"{gender_word} {text}")
390
+ return RETAILER_SEARCH_URLS[retailer].format(query=query)
391
+
392
+
393
+ # =============================================================================
394
+ # HTML rendering — matches the required "YOUR MATCHED LOOKS" layout exactly
395
+ # =============================================================================
396
+
397
+ def render_profile_card_html(season, blurb, palette):
398
+ chips = "".join(
399
+ f'<div class="fs-chip"><span class="fs-chip-dot" style="background:{hexcode}"></span>{html.escape(name)}</div>'
400
+ for name, hexcode in palette
401
+ )
402
+ return f"""
403
+ <div class="fs-profile-card">
404
+ <div class="fs-profile-eyebrow">YOUR COLOR PROFILE</div>
405
+ <div class="fs-profile-season">{html.escape(season)}</div>
406
+ <div class="fs-profile-blurb">{html.escape(blurb)}</div>
407
+ <div class="fs-chip-row">{chips}</div>
408
+ </div>
409
+ """
410
+
411
+
412
+ def render_caption_html(caption):
413
+ return (
414
+ '<div class="fs-caption">🪄 <span class="fs-caption-label">AI Stylist note:</span> '
415
+ f'“{html.escape(caption)}”</div>'
416
+ )
417
+
418
+
419
+ def _component_html(label, text, retailer, gender):
420
+ hexcode = swatch_color_for(text)
421
+ link = component_shop_link(retailer, text, gender)
422
+ return f"""
423
+ <div class="fs-component">
424
+ <div class="fs-component-swatch"><span class="fs-swatch-dot" style="background:{hexcode}"></span></div>
425
+ <div class="fs-component-body">
426
+ <div class="fs-component-label">{label}</div>
427
+ <div class="fs-component-name">{html.escape(str(text))}</div>
428
+ <a class="fs-shop-btn" href="{link}" target="_blank" rel="noopener noreferrer">Shop ↗</a>
429
+ </div>
430
+ </div>
431
+ """
432
+
433
+
434
+ def render_look_card_html(look_number, row, score_pct):
435
+ avatar_hex = swatch_color_for(row.get("primary_color"), row.get("secondary_color"))
436
+ gender = row.get("gender")
437
+
438
+ components = [
439
+ ("TOP", row.get("outfit_top", ""), COMPONENT_RETAILERS["TOP"]),
440
+ ("BOTTOM", row.get("outfit_bottom", ""), COMPONENT_RETAILERS["BOTTOM"]),
441
+ ("SHOES", row.get("outfit_shoes", ""), COMPONENT_RETAILERS["SHOES"]),
442
+ ("ACCESSORY", row.get("outfit_accessory", ""), COMPONENT_RETAILERS["ACCESSORY"]),
443
+ ]
444
+ comp_html = "".join(_component_html(label, text, retailer, gender)
445
+ for label, text, retailer in components)
446
+
447
+ style_pref = html.escape(str(row.get("style_preference", "")))
448
+ skin_tone = html.escape(str(row.get("skin_tone", "")))
449
+ colors_line = html.escape(str(row.get("recommended_colors", "")))
450
+
451
+ return f"""
452
+ <div class="fs-look-card">
453
+ <div class="fs-look-head">
454
+ <span class="fs-look-avatar" style="background:{avatar_hex}"></span>
455
+ <div>
456
+ <div class="fs-look-title">Look #{look_number}</div>
457
+ <div class="fs-look-sub">{style_pref} · {skin_tone} skin · {score_pct}% match</div>
458
+ </div>
459
+ </div>
460
+ {comp_html}
461
+ <div class="fs-colors-footer">Recommended colors: {colors_line}</div>
462
+ </div>
463
+ """
464
+
465
+
466
+ def render_results_html(profile_html, look_cards_html_list, note=None):
467
+ cards = "".join(look_cards_html_list)
468
+ note_html = f'<div class="fs-note">{html.escape(note)}</div>' if note else ""
469
+ return f"""
470
+ <div class="fs-root">
471
+ {profile_html}
472
+ <div class="fs-header">
473
+ <div class="fs-header-decoration"></div>
474
+ <div class="fs-header-eyebrow">YOUR MATCHED LOOKS</div>
475
+ <div class="fs-header-sub">Top 3 outfits from your personal style dataset</div>
476
+ </div>
477
+ {note_html}
478
+ <div class="fs-grid">{cards}</div>
479
+ </div>
480
+ """
481
+
482
+
483
+ def render_error_html(message):
484
+ return f"""
485
+ <div class="fs-root">
486
+ <div class="fs-error">
487
+ <div class="fs-error-title">Something went wrong</div>
488
+ <div class="fs-error-msg">{html.escape(str(message))}</div>
489
+ </div>
490
+ </div>
491
+ """
492
+
493
+
494
+ def render_placeholder_html():
495
+ return """
496
+ <div class="fs-root">
497
+ <div class="fs-placeholder">
498
+ Upload a photo or pick your features, then press
499
+ <strong>“Find My Looks”</strong> to see your personal color profile
500
+ and your top 3 matched outfits.
501
+ </div>
502
+ </div>
503
+ """
504
+
505
+
506
+ # =============================================================================
507
+ # Dataset & model loading
508
+ # =============================================================================
509
+
510
+ def order_choices(values, preferred_order):
511
+ """Order dropdown choices using a sensible preferred order, falling back
512
+ to alphabetical for anything not in that list."""
513
+ vals = {str(v) for v in values}
514
+ ordered = [p for p in preferred_order if p in vals]
515
+ remaining = sorted(v for v in vals if v not in ordered)
516
+ return ordered + remaining
517
+
518
+
519
+ def load_catalog():
520
+ print(f"Loading dataset '{DATASET_REPO}' from the Hugging Face Hub...")
521
+ ds = load_dataset(DATASET_REPO)
522
+ train = ds["train"]
523
+ df = train.to_pandas()
524
+ images = [train[i]["image_improved"] for i in range(len(train))]
525
+ print(f"Catalog loaded: {len(df)} items, {len(df.columns)} columns.")
526
+ return df, images
527
+
528
+
529
+ def load_embedding_model():
530
+ print(f"Loading embedding model '{EMBED_MODEL_REPO}' from the Hugging Face Hub...")
531
+ device = "cuda" if torch.cuda.is_available() else "cpu"
532
+ processor = AutoProcessor.from_pretrained(EMBED_MODEL_REPO)
533
+ model = AutoModel.from_pretrained(EMBED_MODEL_REPO).to(device).eval()
534
+ print(f"Embedding model ready on device='{device}'.")
535
+ return model, processor, device
536
+
537
+
538
+ @torch.no_grad()
539
+ def embed_catalog_images(model, processor, images, device, batch_size=32):
540
+ embs = []
541
+ for i in range(0, len(images), batch_size):
542
+ batch = [im.convert("RGB") for im in images[i:i + batch_size]]
543
+ inputs = processor(images=batch, return_tensors="pt").to(device)
544
+ outputs = model.get_image_features(**inputs)
545
+ feats = outputs.pooler_output if hasattr(outputs, "pooler_output") else outputs
546
+ feats = feats / feats.norm(dim=-1, keepdim=True)
547
+ embs.append(feats.cpu().numpy())
548
+ print(f" embedded {min(i + batch_size, len(images))}/{len(images)} catalog images")
549
+ return np.vstack(embs).astype("float32")
550
+
551
+
552
+ def build_faiss_index(df, images, model, processor, device):
553
+ image_embeddings = None
554
+ if os.path.exists(EMBED_CACHE_PATH):
555
+ try:
556
+ cached = np.load(EMBED_CACHE_PATH)
557
+ if cached.shape[0] == len(df):
558
+ print("Loaded cached catalog embeddings from disk.")
559
+ image_embeddings = cached
560
+ except Exception as e:
561
+ print(f"Ignoring unreadable embedding cache: {e}")
562
+
563
+ if image_embeddings is None:
564
+ image_embeddings = embed_catalog_images(model, processor, images, device)
565
+ try:
566
+ np.save(EMBED_CACHE_PATH, image_embeddings)
567
+ except Exception as e:
568
+ print(f"Could not cache embeddings to disk (non-fatal): {e}")
569
+
570
+ dim = image_embeddings.shape[1]
571
+ index = faiss.IndexFlatL2(dim)
572
+ normalized = image_embeddings.copy()
573
+ faiss.normalize_L2(normalized)
574
+ index.add(normalized)
575
+ print(f"FAISS index ready with {index.ntotal} items (dim={dim}).")
576
+ return index
577
+
578
+
579
+ def pick_quickstarts(df, n=3):
580
+ """Pick `n` real catalog rows covering distinct styles, to use as
581
+ 1-click 'Quick Starter' examples with guaranteed-valid feature values."""
582
+ eye_cycle = ["Brown", "Hazel", "Blue"]
583
+ seen_styles, starters = set(), []
584
+ for _, row in df.iterrows():
585
+ style = row["style_preference"]
586
+ if style in seen_styles:
587
+ continue
588
+ seen_styles.add(style)
589
+ starters.append({
590
+ "skin_tone": row["skin_tone"],
591
+ "undertone": row["undertone"],
592
+ "style": style,
593
+ "gender": row["gender"],
594
+ "age_group": row["age_group"],
595
+ "eye_color": eye_cycle[len(starters) % len(eye_cycle)],
596
+ })
597
+ if len(starters) == n:
598
+ break
599
+ return starters
600
+
601
+
602
+ # =============================================================================
603
+ # Gradio app
604
+ # =============================================================================
605
+
606
+ CUSTOM_CSS = """
607
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@600;700;800&family=Inter:wght@400;500;600&display=swap');
608
+
609
+ .fs-root { max-width: 1100px; margin: 0 auto; font-family: 'Inter', system-ui, sans-serif; }
610
+ .fs-app-title { font-family:'Outfit', system-ui, sans-serif; font-weight:800; font-size:28px; color:#1A1410; margin-bottom:2px; }
611
+ .fs-app-tag { color:#8C8388; font-size:14px; margin-bottom:6px; }
612
+
613
+ .fs-placeholder { color:#9c9396; font-size:14px; padding: 30px 6px; text-align:center; border:1px dashed #ECE3E2; border-radius:16px; }
614
+
615
+ .fs-profile-card { background: linear-gradient(135deg,#1c1512 0%,#2b1c22 100%); border-radius: 18px; padding: 26px 28px; margin-bottom: 22px; color:#fff; position: relative; overflow:hidden; }
616
+ .fs-profile-eyebrow { color:#e8638c; font-weight:700; font-size:12px; letter-spacing:.1em; text-transform:uppercase; margin-bottom:6px; }
617
+ .fs-profile-season { font-family:'Outfit', sans-serif; font-weight:800; font-size:26px; margin-bottom:6px; }
618
+ .fs-profile-blurb { color:#cfc6c6; font-size:14px; max-width:600px; margin-bottom:16px; line-height:1.4; }
619
+ .fs-chip-row { display:flex; gap:10px; flex-wrap:wrap; }
620
+ .fs-chip { display:flex; align-items:center; gap:6px; background:rgba(255,255,255,0.08); border-radius:999px; padding:6px 12px 6px 6px; font-size:12px; color:#f1ecec; }
621
+ .fs-chip-dot { width:16px; height:16px; border-radius:50%; display:inline-block; border:2px solid rgba(255,255,255,.5); }
622
+
623
+ .fs-header { position:relative; background:#16110F; border-radius:18px; padding:22px 26px; margin-bottom:22px; overflow:hidden; }
624
+ .fs-header-decoration { position:absolute; top:-40px; right:-30px; width:130px; height:130px; border-radius:50%; background:#3d1f29; }
625
+ .fs-header-eyebrow { color:#e0507a; font-weight:800; font-size:13px; letter-spacing:.12em; text-transform:uppercase; margin-bottom:6px; position:relative; z-index:1; }
626
+ .fs-header-sub { color:#b9b0b2; font-size:15px; position:relative; z-index:1; }
627
+
628
+ .fs-note { background:#FBEFE9; color:#8a5a3a; border-radius:12px; padding:10px 16px; font-size:13px; margin-bottom:16px; }
629
+ .fs-caption { background:#FFF6E9; border:1px solid #F0DCB8; border-radius:14px; padding:14px 18px; margin-bottom:18px; font-size:14px; color:#5a4a30; }
630
+ .fs-caption-label { font-weight:700; }
631
+
632
+ .fs-grid { display:grid; grid-template-columns: repeat(3, 1fr); gap:18px; }
633
+ @media (max-width: 900px) { .fs-grid { grid-template-columns: 1fr; } }
634
+
635
+ .fs-look-card { background:#fff; border:1px solid #ECE3E2; border-radius:18px; padding:18px; box-shadow:0 2px 10px rgba(0,0,0,0.03); }
636
+ .fs-look-head { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
637
+ .fs-look-avatar { width:48px; height:48px; border-radius:50%; flex-shrink:0; border:3px solid #e8638c; display:inline-block; }
638
+ .fs-look-title { font-weight:800; font-size:17px; color:#1A1410; font-family:'Outfit',sans-serif; }
639
+ .fs-look-sub { color:#9c9396; font-size:13px; margin-top:2px; }
640
+
641
+ .fs-component { border:1px solid #ECE3E2; border-radius:14px; overflow:hidden; margin-bottom:14px; }
642
+ .fs-component:last-child { margin-bottom:0; }
643
+ .fs-component-swatch { background:#F1E8DA; height:64px; display:flex; align-items:center; justify-content:center; }
644
+ .fs-swatch-dot { width:34px; height:34px; border-radius:50%; display:inline-block; box-shadow:0 0 0 1px rgba(0,0,0,0.05) inset; }
645
+ .fs-component-body { padding:12px 16px; background:#fff; }
646
+ .fs-component-label { color:#d6336c; font-weight:800; font-size:11px; letter-spacing:.08em; text-transform:uppercase; margin-bottom:3px; }
647
+ .fs-component-name { font-weight:700; color:#1A1410; font-size:14.5px; margin-bottom:10px; line-height:1.3; }
648
+ .fs-shop-btn { display:inline-flex; align-items:center; gap:6px; background:#14110F; color:#fff !important; padding:9px 18px; border-radius:999px; font-size:12.5px; font-weight:700; text-decoration:none !important; transition:transform .15s ease, background .15s ease; }
649
+ .fs-shop-btn:hover { background:#2a2422; transform:translateY(-1px); }
650
+
651
+ .fs-colors-footer { color:#9c9396; font-size:12px; margin-top:6px; padding-top:10px; border-top:1px dashed #ECE3E2; }
652
+
653
+ .fs-error { background:#FBE8E8; border:1px solid #F0BABA; border-radius:14px; padding:18px 20px; }
654
+ .fs-error-title { color:#A11D33; font-weight:800; margin-bottom:4px; }
655
+ .fs-error-msg { color:#7a3a3a; font-size:14px; }
656
+ """
657
+
658
+
659
+ def build_demo(df, model, processor, faiss_index, device):
660
+ skin_tone_choices = order_choices(df["skin_tone"].unique(), SKIN_DEPTH_ORDER)
661
+ undertone_choices = order_choices(df["undertone"].unique(), UNDERTONE_ORDER)
662
+ style_choices = sorted(df["style_preference"].unique().tolist())
663
+ gender_choices = sorted(df["gender"].unique().tolist())
664
+ age_choices = order_choices(df["age_group"].unique(), AGE_ORDER)
665
+ quickstarts = pick_quickstarts(df)
666
+
667
+ caption_pipe_holder = {"pipe": None, "failed": False}
668
+
669
+ def get_caption_pipe():
670
+ if caption_pipe_holder["pipe"] is None and not caption_pipe_holder["failed"]:
671
+ try:
672
+ from transformers import pipeline as hf_pipeline
673
+ print(f"Loading GenAI caption model '{CAPTION_MODEL_REPO}' (first use only)...")
674
+ caption_pipe_holder["pipe"] = hf_pipeline(
675
+ "text-generation",
676
+ model=CAPTION_MODEL_REPO,
677
+ device=0 if device == "cuda" else -1,
678
+ )
679
+ except Exception as e:
680
+ print(f"Caption model unavailable, skipping AI note: {e}")
681
+ caption_pipe_holder["failed"] = True
682
+ return caption_pipe_holder["pipe"]
683
+
684
+ def generate_caption(row):
685
+ pipe = get_caption_pipe()
686
+ if pipe is None:
687
+ return None
688
+ try:
689
+ user_prompt = (
690
+ "Write one short, warm sentence (max 25 words) from a fashion stylist, "
691
+ f"recommending this look: a {row['style_preference']} style outfit in "
692
+ f"{row['primary_color']} and {row['secondary_color']}, best colors: "
693
+ f"{row['recommended_colors']}. Be specific and stylish, no hashtags."
694
+ )
695
+ messages = [{"role": "user", "content": user_prompt}]
696
+ out = pipe(messages, max_new_tokens=40, do_sample=True, temperature=0.7)
697
+ return out[0]["generated_text"][-1]["content"].strip()
698
+ except Exception as e:
699
+ print(f"[caption generation skipped] {e}")
700
+ return None
701
+
702
+ def predict(mode, photo, photo_skin_override, photo_undertone_override,
703
+ manual_skin, manual_undertone, style, gender, age_group,
704
+ eye_color, want_caption):
705
+ try:
706
+ is_photo_mode = str(mode).startswith("📷")
707
+
708
+ if is_photo_mode:
709
+ if photo is None:
710
+ return render_error_html(
711
+ "Please upload a photo, or switch to “Manual Selection”."
712
+ )
713
+ detected_skin, detected_undertone, _ = estimate_skin_tone_undertone(
714
+ photo, skin_tone_choices
715
+ )
716
+ skin_tone = (
717
+ detected_skin if photo_skin_override in (None, "", AUTO_DETECT_LABEL)
718
+ else photo_skin_override
719
+ )
720
+ undertone = (
721
+ detected_undertone if photo_undertone_override in (None, "", AUTO_DETECT_LABEL)
722
+ else photo_undertone_override
723
+ )
724
+ query_emb = embed_query_image(photo, model, processor, device)
725
+ if style:
726
+ # Even in photo mode, let the chosen style nudge the search:
727
+ # blend in a small text-embedding bias toward that style,
728
+ # in the same CLIP vector space as the photo embedding.
729
+ style_emb = embed_query_text(
730
+ f"wearing a {style} style outfit", model, processor, device
731
+ )
732
+ query_emb = 0.75 * query_emb + 0.25 * style_emb
733
+ else:
734
+ skin_tone = manual_skin
735
+ undertone = manual_undertone
736
+ sentence = build_feature_sentence(
737
+ skin_tone, undertone, style, gender, age_group, eye_color
738
+ )
739
+ query_emb = embed_query_text(sentence, model, processor, device)
740
+
741
+ indices, rows, distances, tier = faiss_filtered_search(
742
+ query_emb, faiss_index, df, top_k=3, exclude_idx=None,
743
+ gender=gender or None, age_group=age_group or None,
744
+ )
745
+ if len(indices) == 0:
746
+ return render_error_html(
747
+ "No matching looks were found in the catalog for these filters — "
748
+ "try a different style, gender, or age group."
749
+ )
750
+
751
+ season, blurb, palette = derive_color_profile(skin_tone, undertone, eye_color)
752
+ profile_html = render_profile_card_html(season, blurb, palette)
753
+
754
+ scores = [max(0.0, min(1.0, 1.0 - d / 2.0)) for d in distances]
755
+ look_cards = [
756
+ render_look_card_html(i + 1, rows.iloc[i], round(scores[i] * 100))
757
+ for i in range(len(rows))
758
+ ]
759
+
760
+ extra_html = ""
761
+ if want_caption:
762
+ caption = generate_caption(rows.iloc[0])
763
+ if caption:
764
+ extra_html = render_caption_html(caption)
765
+
766
+ note = None
767
+ if tier == 2:
768
+ note = "We broadened the search beyond the exact age group to find your best matches."
769
+ elif tier == 3:
770
+ note = "We expanded the search beyond your filters so you still get great matches."
771
+
772
+ return extra_html + render_results_html(profile_html, look_cards, note=note)
773
+
774
+ except Exception as e:
775
+ traceback.print_exc()
776
+ return render_error_html(f"{type(e).__name__}: {e}")
777
+
778
+ def toggle_mode(mode):
779
+ is_photo = str(mode).startswith("📷")
780
+ return gr.update(visible=is_photo), gr.update(visible=not is_photo)
781
+
782
+ theme = gr.themes.Soft(
783
+ primary_hue=gr.themes.colors.pink,
784
+ secondary_hue=gr.themes.colors.rose,
785
+ neutral_hue=gr.themes.colors.gray,
786
+ ).set(
787
+ button_primary_background_fill="#14110F",
788
+ button_primary_background_fill_hover="#2a2422",
789
+ button_primary_text_color="#FFFFFF",
790
+ )
791
+
792
+ with gr.Blocks(css=CUSTOM_CSS, theme=theme, title="Personal Style Matcher") as demo:
793
+ gr.HTML(
794
+ '<div class="fs-app-title">✨ Personal Style Matcher</div>'
795
+ '<div class="fs-app-tag">Find your color profile and your top 3 matched '
796
+ 'looks from our personal style dataset.</div>'
797
+ )
798
+
799
+ with gr.Row():
800
+ with gr.Column(scale=1):
801
+ mode = gr.Radio(
802
+ ["📷 Upload My Photo", "🎛️ Manual Selection"],
803
+ value="🎛️ Manual Selection",
804
+ label="How would you like to start?",
805
+ )
806
+
807
+ with gr.Group(visible=False) as photo_group:
808
+ photo = gr.Image(label="Upload a clear, front-facing photo", type="pil")
809
+ photo_skin_override = gr.Dropdown(
810
+ [AUTO_DETECT_LABEL] + skin_tone_choices,
811
+ value=AUTO_DETECT_LABEL,
812
+ label="Skin tone (auto-detected — override if needed)",
813
+ )
814
+ photo_undertone_override = gr.Dropdown(
815
+ [AUTO_DETECT_LABEL] + undertone_choices,
816
+ value=AUTO_DETECT_LABEL,
817
+ label="Undertone (auto-detected — override if needed)",
818
+ )
819
+
820
+ with gr.Group(visible=True) as manual_group:
821
+ manual_skin = gr.Dropdown(
822
+ skin_tone_choices, value=skin_tone_choices[0], label="Skin Tone"
823
+ )
824
+ manual_undertone = gr.Dropdown(
825
+ undertone_choices, value=undertone_choices[0], label="Undertone"
826
+ )
827
+
828
+ style = gr.Dropdown(
829
+ style_choices, value=style_choices[0], label="Clothing Style"
830
+ )
831
+ with gr.Row():
832
+ gender = gr.Dropdown(
833
+ gender_choices, value=gender_choices[0], label="Gender"
834
+ )
835
+ age_group = gr.Dropdown(
836
+ age_choices, value=age_choices[0], label="Age Group"
837
+ )
838
+ eye_color = gr.Dropdown(
839
+ EYE_COLOR_CHOICES, value=EYE_COLOR_CHOICES[0], label="Eye Color"
840
+ )
841
+ want_caption = gr.Checkbox(
842
+ label="✨ Add an AI stylist note (small GenAI text model)", value=False
843
+ )
844
+ submit_btn = gr.Button("Find My Looks", variant="primary", size="lg")
845
+
846
+ with gr.Column(scale=2):
847
+ output_html = gr.HTML(value=render_placeholder_html())
848
+
849
+ mode.change(toggle_mode, inputs=mode, outputs=[photo_group, manual_group])
850
+
851
+ predict_inputs = [
852
+ mode, photo, photo_skin_override, photo_undertone_override,
853
+ manual_skin, manual_undertone, style, gender, age_group,
854
+ eye_color, want_caption,
855
+ ]
856
+ submit_btn.click(predict, inputs=predict_inputs, outputs=output_html)
857
+
858
+ if quickstarts:
859
+ example_rows = [
860
+ [
861
+ "🎛️ Manual Selection", None, AUTO_DETECT_LABEL, AUTO_DETECT_LABEL,
862
+ qs["skin_tone"], qs["undertone"], qs["style"], qs["gender"],
863
+ qs["age_group"], qs["eye_color"], False,
864
+ ]
865
+ for qs in quickstarts
866
+ ]
867
+ gr.Examples(
868
+ examples=example_rows,
869
+ inputs=predict_inputs,
870
+ outputs=output_html,
871
+ fn=predict,
872
+ run_on_click=True,
873
+ cache_examples=False,
874
+ label="✨ Quick Starters — click one to see it in action",
875
+ )
876
+
877
+ return demo
878
+
879
+
880
+ # =============================================================================
881
+ # Entry point
882
+ # =============================================================================
883
+
884
+ if __name__ == "__main__":
885
+ df, images = load_catalog()
886
+ clip_model, clip_processor, device = load_embedding_model()
887
+ faiss_index = build_faiss_index(df, images, clip_model, clip_processor, device)
888
+ demo = build_demo(df, clip_model, clip_processor, faiss_index, device)
889
+ demo.queue().launch()