romi2001 commited on
Commit
2336ec8
·
verified ·
1 Parent(s): 8b5521f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +241 -438
app.py CHANGED
@@ -1,18 +1,3 @@
1
- """
2
- AI Fashion Stylist — Hugging Face Space app
3
- Combines: CLIP recommendation engine (Part 3) + 3 GenAI patterns (Part 4):
4
- 1. Small Language Model -> generates text (Qwen2.5-0.5B-Instruct)
5
- 2. Small Vision-Language Model -> answers questions (BLIP-VQA-base)
6
- 3. Small Vision-Language pipeline -> generates an image (clothing segmentation + SD inpainting)
7
-
8
- Deploy notes:
9
- - Upload this file + requirements.txt + final_image_embeddings.npy + catalog_metadata.parquet
10
- to your HF Space repo root.
11
- - Update HF_DATASET_REPO / HF_WINNING_MODEL below if your repo names differ.
12
- - Quick Starter sample photos are generated automatically from the dataset at startup -
13
- no manual file upload needed.
14
- """
15
-
16
  import base64
17
  import os
18
  import urllib.parse
@@ -25,7 +10,6 @@ import gradio as gr
25
  import faiss
26
  from datasets import load_dataset
27
  from PIL import Image
28
- from scipy import ndimage
29
  from transformers import (
30
  CLIPModel, CLIPProcessor, pipeline as hf_pipeline,
31
  BlipProcessor, BlipForQuestionAnswering,
@@ -34,33 +18,31 @@ from transformers import (
34
  from diffusers import StableDiffusionInpaintPipeline
35
 
36
  # ---------------------------------------------------------------------------
37
- # CONFIG — update these to match your own HF repos
38
  # ---------------------------------------------------------------------------
39
- HF_DATASET_REPO = "lihicarmeli/fashion-stylist-multimodal-v2" # your HF dataset repo
40
- HF_WINNING_MODEL = "openai/clip-vit-base-patch32" # winning embedding model (Part 3)
41
- EMBEDDINGS_FILE = "final_image_embeddings.npy" # uploaded next to this app.py
42
- METADATA_FILE = "catalog_metadata.parquet" # uploaded next to this app.py
43
 
44
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
45
 
46
  # ---------------------------------------------------------------------------
47
- # LOAD DATA + WINNING EMBEDDING MODEL (runs once, on Space startup)
48
  # ---------------------------------------------------------------------------
49
  print("Loading dataset from HF Hub...")
50
  ds = load_dataset(HF_DATASET_REPO)
51
  df = ds["train"].to_pandas()
52
  images = [ds["train"][i]["image_improved"] for i in range(len(ds["train"]))]
53
 
54
- print("Generating Quick Starter sample photos from the dataset...")
55
  SAMPLES_DIR = "samples_cache"
56
  os.makedirs(SAMPLES_DIR, exist_ok=True)
57
 
58
-
59
  def _pick_index(filter_fn, fallback_idx=0):
60
  matches = df[df.apply(filter_fn, axis=1)]
61
  return int(matches.index[0]) if len(matches) else fallback_idx
62
 
63
-
64
  _sample_specs = [
65
  ("demo_woman.jpg", lambda r: r["gender"] == "woman"),
66
  ("demo_man.jpg", lambda r: r["gender"] == "man"),
@@ -73,12 +55,11 @@ for _filename, _filt in _sample_specs:
73
  _path = os.path.join(SAMPLES_DIR, _filename)
74
  images[_idx].convert("RGB").save(_path)
75
  SAMPLE_PHOTOS.append(_path)
76
- print(f" -> {len(SAMPLE_PHOTOS)} sample photos ready: {SAMPLE_PHOTOS}")
77
 
78
  print("Loading precomputed embeddings...")
79
  image_embeddings = np.load(EMBEDDINGS_FILE).astype("float32")
80
 
81
- print("Loading winning embedding model (CLIP) from HF Hub...")
82
  win_model = CLIPModel.from_pretrained(HF_WINNING_MODEL).to(DEVICE).eval()
83
  win_processor = CLIPProcessor.from_pretrained(HF_WINNING_MODEL)
84
 
@@ -89,24 +70,16 @@ faiss.normalize_L2(image_embeddings)
89
  faiss_img_index.add(image_embeddings)
90
 
91
  # ---------------------------------------------------------------------------
92
- # GENERATION MODELS — the 3 "Good Examples" patterns
93
  # ---------------------------------------------------------------------------
94
- print("Loading small language model for text generation (Qwen2.5-0.5B-Instruct)...")
95
- caption_gen_pipe = hf_pipeline(
96
- "text-generation",
97
- model="Qwen/Qwen2.5-0.5B-Instruct",
98
- device=0 if DEVICE == "cuda" else -1,
99
- )
100
-
101
- print("Loading small vision-language model for VQA (BLIP-VQA-base)...")
102
  vqa_processor = BlipProcessor.from_pretrained("Salesforce/blip-vqa-base")
103
  vqa_model = BlipForQuestionAnswering.from_pretrained("Salesforce/blip-vqa-base").to(DEVICE)
104
 
105
- print("Loading clothing segmentation model (helper - finds where the clothes are)...")
106
  seg_processor = SegformerImageProcessor.from_pretrained("mattmdjaga/segformer_b2_clothes")
107
  seg_model = AutoModelForSemanticSegmentation.from_pretrained("mattmdjaga/segformer_b2_clothes").to(DEVICE)
108
-
109
- print("Loading image-generation model for the new outfit (Stable Diffusion Inpainting)...")
110
  inpaint_pipe = StableDiffusionInpaintPipeline.from_pretrained(
111
  "runwayml/stable-diffusion-inpainting",
112
  torch_dtype=torch.float16 if DEVICE == "cuda" else torch.float32,
@@ -119,35 +92,74 @@ SKIN_TONES = sorted(df["skin_tone"].unique().tolist())
119
  UNDERTONES = sorted(df["undertone"].unique().tolist())
120
  STYLES = sorted(df["style_preference"].unique().tolist())
121
 
122
- print("Space ready.")
 
 
 
 
 
 
 
 
 
 
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
  # ---------------------------------------------------------------------------
126
- # EMBEDDING + SEARCH (Part 3 logic, unchanged)
127
  # ---------------------------------------------------------------------------
128
  @torch.no_grad()
129
  def embed_query_image(pil_image):
130
  inputs = win_processor(images=pil_image, return_tensors="pt").to(DEVICE)
131
  feats = win_model.get_image_features(**inputs)
132
- feats = feats.pooler_output if hasattr(feats, "pooler_output") else feats
133
  return feats.cpu().numpy().astype("float32")
134
 
135
-
136
  @torch.no_grad()
137
  def embed_query_text(sentence):
138
  inputs = win_processor(text=[sentence], return_tensors="pt", padding=True, truncation=True).to(DEVICE)
139
  feats = win_model.get_text_features(**inputs)
140
- feats = feats.pooler_output if hasattr(feats, "pooler_output") else feats
141
  return feats.cpu().numpy().astype("float32")
142
 
143
-
144
  def build_feature_sentence(skin_tone, undertone, style_preference, gender=None, age_group=None):
145
  descriptor = " ".join(p for p in [age_group, gender] if p) or "person"
146
- return (
147
- f"a {descriptor} with {skin_tone} skin tone and {undertone} undertone, "
148
- f"wearing a {style_preference} style outfit"
149
- )
150
-
151
 
152
  def faiss_filtered_search(query_emb, top_k=3, exclude_idx=None, gender=None, age_group=None):
153
  faiss.normalize_L2(query_emb)
@@ -179,381 +191,172 @@ def faiss_filtered_search(query_emb, top_k=3, exclude_idx=None, gender=None, age
179
 
180
  return np.array(kept_i), df.iloc[kept_i], np.array(kept_d)
181
 
182
-
183
  # ---------------------------------------------------------------------------
184
- # GENERATION 3 "Good Examples" patterns (Part 4)
185
  # ---------------------------------------------------------------------------
186
-
187
- # --- Pattern 1: small Language Model -> generate text ---
188
  def generate_stylist_caption(row):
189
  user_prompt = (
190
  f"Write one short, warm sentence (max 25 words) from a fashion stylist, recommending this look: "
191
- f"a {row['style_preference']} style outfit in {row['primary_color']} and {row['secondary_color']}, "
192
- f"best colors: {row['recommended_colors']}. Be specific and stylish, no hashtags."
193
  )
194
  messages = [{"role": "user", "content": user_prompt}]
195
  output = caption_gen_pipe(messages, max_new_tokens=40, do_sample=True, temperature=0.7)
196
  return output[0]["generated_text"][-1]["content"].strip()
197
 
198
-
199
- # --- Pattern 2: small Vision-Language Model -> answer questions about an image ---
200
  @torch.no_grad()
201
  def answer_question_about_image(pil_image, question):
202
  inputs = vqa_processor(pil_image.convert("RGB"), question, return_tensors="pt").to(DEVICE)
203
  output_ids = vqa_model.generate(**inputs, max_new_tokens=20)
204
  return vqa_processor.decode(output_ids[0], skip_special_tokens=True)
205
 
206
-
207
- # --- Pattern 3: small Vision-Language Model -> generate the FULL LOOK (full-body image) ---
208
- FACE_LABEL_ID = 11
209
- HAIR_LABEL_ID = 2
210
-
211
-
212
  @torch.no_grad()
213
  def get_face_protect_mask(pil_image, use_geometric_fallback=True):
214
- """Returns a boolean mask (True = protect) covering the face + hair, at pil_image's own
215
- resolution. Combines the segmentation model's prediction with a fixed geometric ellipse
216
- (centered, where a face statistically sits in a headshot crop) as a safety net - so even
217
- if segmentation misclassifies an unusual headwear/turban, the visible face is still
218
- guaranteed to be protected."""
219
  inputs = seg_processor(images=pil_image, return_tensors="pt").to(DEVICE)
220
  logits = seg_model(**inputs).logits
221
- upsampled = torch.nn.functional.interpolate(
222
- logits, size=pil_image.size[::-1], mode="bilinear", align_corners=False
223
- )
224
  pred_seg = upsampled.argmax(dim=1)[0].cpu().numpy()
225
- seg_protect = np.isin(pred_seg, [FACE_LABEL_ID, HAIR_LABEL_ID])
226
-
227
  if not use_geometric_fallback:
228
  return seg_protect
229
-
230
  h, w = seg_protect.shape
231
  yy, xx = np.mgrid[0:h, 0:w]
232
- cy, cx = h * 0.42, w * 0.5 # face center: slightly above vertical middle of a headshot
233
- ry, rx = h * 0.30, w * 0.22 # ellipse radii tuned for a tight headshot/bust crop
234
- geometric_protect = (((xx - cx) / rx) ** 2 + ((yy - cy) / ry) ** 2) <= 1.0
235
-
236
  return seg_protect | geometric_protect
237
 
238
-
239
- def build_full_look_prompt(row):
240
- gender_word = "man" if str(row["gender"]).lower() in ("man", "male") else "woman"
241
- return (
242
- f"full body fashion photo of a {row['age_group']} {gender_word}, standing, "
243
- f"wearing a {row['style_preference']} style outfit: {row['outfit_top']}, "
244
- f"{row['outfit_bottom']}, {row['outfit_shoes']}, {row['outfit_accessory']}, "
245
- f"in {row['primary_color']} and {row['secondary_color']}, "
246
- f"studio lighting, plain background, head to toe, high quality fashion photography"
247
- )
248
-
249
-
250
- def generate_new_outfit_image(pil_image, row, target_gender=None, canvas_size=(512, 1024),
251
- head_width_frac=0.45, steps=40, guidance_scale=8.0):
252
- """Generates the FULL LOOK: keeps the original face pixel-identical (scaled down to a
253
- realistic head-to-body proportion) and generates the rest of a standing figure wearing
254
- the complete recommended outfit (top, bottom, shoes, accessory) around it."""
255
- if target_gender is not None and str(row["gender"]).lower() != str(target_gender).lower():
256
- print(f"⚠️ Warning: recommended row gender ({row['gender']}) != expected gender "
257
- f"({target_gender}) - double-check which matched_rows was passed in.")
258
-
259
  base = pil_image.convert("RGB")
260
- protect = get_face_protect_mask(base) # (H, W) bool, at base's own resolution
261
-
262
- canvas_w, canvas_h = canvas_size
263
- head_w = int(canvas_w * head_width_frac)
264
  scale = head_w / base.width
265
  head_h = int(base.height * scale)
266
-
267
  resized_face_crop = base.resize((head_w, head_h))
268
- resized_protect_img = Image.fromarray(protect.astype(np.uint8) * 255).resize(
269
- (head_w, head_h), resample=Image.NEAREST
270
- )
271
-
272
- canvas = Image.new("RGB", canvas_size, color=(128, 128, 128))
273
  paste_x = (canvas_w - head_w) // 2
274
  paste_y = int(canvas_h * 0.03)
275
  canvas.paste(resized_face_crop, (paste_x, paste_y))
276
-
277
- mask_arr = np.full((canvas_h, canvas_w), 255, dtype=np.uint8) # 255 = let the model generate
278
- protect_resized_arr = np.array(resized_protect_img) > 127
279
- mask_arr[paste_y:paste_y + head_h, paste_x:paste_x + head_w][protect_resized_arr] = 0
280
  mask = Image.fromarray(mask_arr).convert("L")
281
-
282
- prompt = build_full_look_prompt(row)
283
- generated = inpaint_pipe(
284
- prompt=prompt,
285
- image=canvas,
286
- mask_image=mask,
287
- num_inference_steps=steps,
288
- guidance_scale=guidance_scale,
289
- height=canvas_h,
290
- width=canvas_w,
291
- ).images[0]
292
-
293
- # Hard-composite: guarantees zero change on the protected face/hair pixels
294
- final_image = Image.composite(generated, canvas, mask)
295
- return final_image, prompt
296
-
297
-
298
- # ---------------------------------------------------------------------------
299
- # SHOP LINKS (Part 3 logic, unchanged)
300
- # ---------------------------------------------------------------------------
301
- RETAILER_SEARCH_URLS = {
302
- "zara": "https://www.zara.com/us/en/search?searchTerm={query}&section={section}",
303
- "hm": "https://www2.hm.com/en_us/search-results.html?q={query}",
304
- "asos": "https://www.asos.com/us/{dept}/search/?q={query}",
305
- "mango": "https://shop.mango.com/us/en/search?kw={query}",
306
- "shein": "https://us.shein.com/pdsearch/{query}/",
307
- }
308
-
309
-
310
- def normalize_gender(gender):
311
- g = str(gender).strip().lower() if gender is not None else ""
312
- return "men" if g in ("male", "man", "men", "m") else "women"
313
-
314
-
315
- def to_shop_link(retailer, value, gender=None):
316
- dept = normalize_gender(gender)
317
- if retailer == "zara":
318
- section = "MAN" if dept == "men" else "WOMAN"
319
- return RETAILER_SEARCH_URLS["zara"].format(query=urllib.parse.quote(str(value)), section=section)
320
- if retailer == "asos":
321
- return RETAILER_SEARCH_URLS["asos"].format(query=urllib.parse.quote(str(value)), dept=dept)
322
- gender_word = "men's" if dept == "men" else "women's"
323
- return RETAILER_SEARCH_URLS[retailer].format(query=urllib.parse.quote(f"{gender_word} {value}"))
324
-
325
-
326
- def shop_links_markdown(row):
327
- lines = []
328
- for retailer, col in [("zara", "search_query_zara"), ("hm", "search_query_hm"),
329
- ("asos", "search_query_asos"), ("mango", "search_query_mango"),
330
- ("shein", "search_query_shein")]:
331
- link = to_shop_link(retailer, row[col], gender=row["gender"])
332
- lines.append(f"- **{retailer.upper()}**: [{row[col]}]({link})")
333
- return "\n".join(lines)
334
-
335
-
336
- # ---------------------------------------------------------------------------
337
- # UI CARDS — original visual layer, built on top of the same Part 3+4 data
338
- # ---------------------------------------------------------------------------
339
- _SWATCH_CSS = {
340
- "ice white": "#f4f3ee", "bold blue": "#1d4ed8", "royal blue": "#1e3a8a",
341
- "fuchsia": "#c026d3", "cool red": "#dc2626", "deep teal": "#0f766e",
342
- "plum": "#6b21a8", "silver": "#cbd5e1", "bright pink": "#ec4899",
343
- "deep jewel tones": "#581c87", "rust": "#b45309", "camel": "#c19a6b",
344
- "coral": "#fb7185", "ivory": "#fffff0", "olive": "#65730a",
345
- "terracotta": "#c2643a",
346
- }
347
-
348
-
349
- def _swatch_color(name):
350
- key = name.strip().lower()
351
- if key in _SWATCH_CSS:
352
- return _SWATCH_CSS[key]
353
- # generic fallback so unseen color names still render *something* sensible
354
- palette = ["#b45309", "#1d4ed8", "#c026d3", "#0f766e", "#dc2626", "#6b21a8"]
355
- return palette[hash(key) % len(palette)]
356
-
357
-
358
- def _lighten_hex(hex_color, factor=0.82):
359
- hex_color = hex_color.lstrip("#")
360
- r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
361
- r = int(r + (255 - r) * factor)
362
- g = int(g + (255 - g) * factor)
363
- b = int(b + (255 - b) * factor)
364
- return f"#{r:02x}{g:02x}{b:02x}"
365
-
366
-
367
- def _soften_color(hex_color, factor=0.38):
368
- """Moderately lightens a color toward a pastel-leaning, elegant tone - keeps the hue
369
- recognizable (so the color *name* shown stays accurate) without it looking garish."""
370
- return _lighten_hex(hex_color, factor)
371
-
372
-
373
- _PALETTE_NAMES = {
374
- "fair": {"cool": "Porcelain Frost", "warm": "Champagne Silk", "neutral": "Pale Linen"},
375
- "light": {"cool": "Moonlit Pearl", "warm": "Gilded Honey", "neutral": "Quiet Ivory"},
376
- "medium": {"cool": "Dusk Orchid", "warm": "Spiced Amber", "neutral": "Warm Alabaster"},
377
- "olive": {"cool": "Sage Noir", "warm": "Burnished Olive", "neutral": "Terracotta Earth"},
378
- "tan": {"cool": "Copper Veil", "warm": "Gilded Sand", "neutral": "Desert Rosé"},
379
- "deep": {"cool": "Midnight Sapphire", "warm": "Mahogany Gold", "neutral": "Onyx Velvet"},
380
- }
381
-
382
-
383
- def get_palette_name(skin_tone, undertone):
384
- return _PALETTE_NAMES.get(str(skin_tone).strip().lower(), {}).get(
385
- str(undertone).strip().lower(), "Signature Palette"
386
- )
387
-
388
-
389
-
390
- def pil_to_base64(img, max_size=420):
391
- img = img.convert("RGB").copy()
392
- img.thumbnail((max_size, max_size))
393
- buf = BytesIO()
394
- img.save(buf, format="JPEG", quality=85)
395
- return base64.b64encode(buf.getvalue()).decode("utf-8")
396
-
397
-
398
- def build_feature_pills_html(labels, selected, title):
399
- """Pill-style recap row (her visual pattern) - but built from your real, actually-used
400
- attributes (skin tone / undertone / style), not placeholder categories."""
401
- pills = ""
402
- for label in labels:
403
- active = label == selected
404
- border = "2px solid #D2527F" if active else "1.5px solid #E8E0E8"
405
- bg = "#FFF0F5" if active else "#FFFFFF"
406
- tcol = "#D2527F" if active else "#444"
407
- dot = _swatch_color(label) if title != "Style" else "#C19A6B"
408
- pills += (
409
- f'<div style="display:inline-flex;align-items:center;gap:7px;padding:8px 14px;'
410
- f'border-radius:30px;border:{border};background:{bg};margin:4px;">'
411
- f'<div style="width:14px;height:14px;border-radius:50%;background:{dot};'
412
- f'border:1px solid rgba(0,0,0,.12);"></div>'
413
- f'<span style="font-size:13px;font-weight:500;color:{tcol};">{label.title()}</span></div>'
414
- )
415
- return (
416
- f'<div style="margin-bottom:14px;"><div style="font-size:11px;font-weight:600;color:#aaa;'
417
- f'text-transform:uppercase;letter-spacing:.1em;margin-bottom:8px;">{title}</div>'
418
- f'<div style="display:flex;flex-wrap:wrap;margin:-4px;">{pills}</div></div>'
419
  )
 
 
420
 
421
-
422
- def build_features_recap_html(skin_tone, undertone, style):
423
- return (
424
- '<div style="background:#fff;border-radius:16px;padding:18px 20px;'
425
- 'margin-bottom:14px;border:1px solid #F0E8F0;">'
426
- '<div style="font-size:11px;font-weight:600;color:#D2527F;text-transform:uppercase;'
427
- 'letter-spacing:.1em;margin-bottom:14px;">Your Features</div>'
428
- + build_feature_pills_html(SKIN_TONES, skin_tone, "Skin tone")
429
- + build_feature_pills_html(UNDERTONES, undertone, "Undertone")
430
- + build_feature_pills_html(STYLES, style, "Style")
431
- + "</div>"
432
- )
433
  colors = [c.strip() for c in str(row["recommended_colors"]).split(",") if c.strip()][:4]
434
  swatches = "".join(
435
- f'<div class="swatch-card"><div class="color-bubble" style="background-color:{_soften_color(_swatch_color(c))};">'
436
- f'</div><p>{c.title()}</p></div>'
437
  for c in colors
438
  )
439
  palette_name = get_palette_name(row["skin_tone"], row["undertone"])
440
- return f"""
441
- <div style="position:relative;overflow:hidden;background:#1B1814;color:#F5EFE6;
442
- border-radius:10px;padding:28px 32px;margin-bottom:18px;">
443
- <div style="position:absolute;top:-50px;right:-50px;width:170px;height:170px;border-radius:50%;
444
- background:rgba(201,168,118,.18);"></div>
445
- <div style="font-size:10.5px;letter-spacing:2px;color:#C9A876;font-weight:600;">YOUR COLOR PALETTE</div>
446
- <div class="palette-name" style="font-size:34px;font-weight:700;margin:8px 0 6px;">{palette_name}</div>
447
- <div style="font-size:13px;color:#B3A38A;letter-spacing:.02em;">
448
- {row['skin_tone'].title()} skin · {row['undertone'].title()} undertone · {row['style_preference'].title()} style
449
- </div>
450
- </div>
451
- <div class="section-split">
452
- <div class="results-box">
453
- <h3>COLORS FOR YOU</h3>
454
- <div class="swatch-grid">{swatches}</div>
455
- </div>
456
- <div class="results-box">
457
- <h3>STYLIST TIP</h3>
458
- <p class="tip-text">{caption}</p>
459
- </div>
460
- </div>
461
- """
462
-
463
 
 
 
 
 
 
 
 
 
 
 
 
464
 
465
  def build_outfit_component_cards_html(row):
466
- """One card per garment piece of the TOP match (top/bottom/shoes/accessory) -
467
- mirrors a 'your outfit, broken into shoppable pieces' layout."""
468
  colors = [c.strip() for c in str(row["recommended_colors"]).split(",") if c.strip()] or ["neutral"]
469
  components = [
470
  ("TOP", row.get("outfit_top", "Top"), "zara", "search_query_zara"),
471
- ("BOTTOM", row.get("outfit_bottom", "Bottom"), "hm", "search_query_hm"),
472
- ("SHOES", row.get("outfit_shoes", "Shoes"), "asos", "search_query_asos"),
473
- ("ACCESSORY", row.get("outfit_accessory", "Accessory"), "mango", "search_query_mango"),
 
 
474
  ]
 
475
  cards = []
476
- for i, (label, item_name, retailer, col) in enumerate(components):
 
 
 
 
 
 
 
477
  raw_color = _swatch_color(colors[i % len(colors)])
478
- dot_color = _soften_color(raw_color, 0.30)
479
  banner_color = _lighten_hex(raw_color, 0.88)
480
- link = to_shop_link(retailer, row[col], row["gender"])
481
- cards.append(f"""
482
- <div class="product-card">
483
- <div class="card-color-header" style="background-color:{banner_color};">
484
- <div class="prod-bubble" style="background-color:{dot_color};"></div>
485
- </div>
486
- <div class="prod-meta">
487
- <span class="prod-cat">{label}</span>
488
- <p class="prod-title">{str(item_name).title()}</p>
489
- <span class="prod-brand">{retailer.upper()}</span>
490
- <a href="{link}" target="_blank" class="shop-btn">Shop ↗</a>
491
- </div>
492
- </div>
493
- """)
494
  return f'<div class="outfit-grid">{"".join(cards)}</div>'
495
 
496
-
497
- def build_more_matches_html(matched_indices, matched_rows, similarities, skip_first=True):
498
- """Smaller secondary row: the other catalog matches, as full-look thumbnails
499
- (keeps the '3 similar items' requirement visible alongside the per-garment cards above)."""
500
- items = list(zip(matched_indices, similarities))[1:] if skip_first else list(zip(matched_indices, similarities))
501
- if not items:
502
- return ""
503
  cards = []
504
- for idx, sim in items:
505
  row = df.iloc[idx]
506
  img_b64 = pil_to_base64(images[idx])
507
- link = to_shop_link("zara", row["search_query_zara"], row["gender"])
508
- cards.append(f"""
509
- <div class="product-card">
510
- <img src="data:image/jpeg;base64,{img_b64}" style="width:100%;height:150px;object-fit:cover;display:block;"/>
511
- <div class="prod-meta">
512
- <span class="prod-brand">Match score: {sim}</span>
513
- <a href="{link}" target="_blank" class="shop-btn">Shop ↗</a>
514
- </div>
515
- </div>
516
- """)
517
- return (
518
- '<div class="more-matches-label">MORE MATCHES LIKE THIS</div>'
519
- f'<div class="outfit-grid">{"".join(cards)}</div>'
520
- )
521
-
522
 
523
  # ---------------------------------------------------------------------------
524
- # MAIN PIPELINE — shared by both input paths, runs all 3 GenAI patterns
525
  # ---------------------------------------------------------------------------
526
- def run_pipeline(matched_indices, matched_rows, matched_scores, base_image=None, question=None,
527
- expected_gender=None):
528
  if len(matched_indices) == 0:
529
  return "<i>No matches found — try different filters.</i>", None, "", "<div></div>"
530
-
531
  similarity = [round(1.0 - (d / 2.0), 3) for d in matched_scores]
532
-
533
  top_row = matched_rows.iloc[0]
534
- # Pattern 3 needs a base photo: the user's own photo (Path A) or the top catalog match (Path B)
535
  edit_base_image = base_image if base_image is not None else images[matched_indices[0]]
536
- # If no explicit gender filter was given, fall back to the matched row's own gender, so
537
- # the generated outfit's wording always matches the person actually shown in the photo.
538
  gender_for_generation = expected_gender or top_row["gender"]
539
 
540
- caption = generate_stylist_caption(top_row) # Pattern 1
541
- new_image, _prompt = generate_new_outfit_image(
542
- edit_base_image, top_row, target_gender=gender_for_generation
543
- ) # Pattern 3
544
-
545
- answer = ""
546
- if question:
547
- answer = answer_question_about_image(edit_base_image, question) # Pattern 2
548
 
549
  style_card_html = build_style_card_html(top_row, caption)
550
- outfit_cards_html = (
551
- build_outfit_component_cards_html(top_row)
552
- + build_more_matches_html(matched_indices, matched_rows, similarity)
553
- )
554
  return style_card_html, new_image, answer, outfit_cards_html
555
 
556
-
557
  def recommend_from_photo(photo, gender, age_group, question):
558
  if photo is None:
559
  return "<i>Please upload a photo or pick a Quick Starter.</i>", None, "", "<div></div>"
@@ -561,77 +364,89 @@ def recommend_from_photo(photo, gender, age_group, question):
561
  idx, rows, scores = faiss_filtered_search(query_emb, gender=gender or None, age_group=age_group or None)
562
  return run_pipeline(idx, rows, scores, base_image=photo, question=question, expected_gender=gender)
563
 
564
-
565
  def recommend_from_features(skin_tone, undertone, style, gender, age_group, question):
566
  sentence = build_feature_sentence(skin_tone, undertone, style, gender, age_group)
567
  query_emb = embed_query_text(sentence)
568
  idx, rows, scores = faiss_filtered_search(query_emb, gender=gender or None, age_group=age_group or None)
569
  return run_pipeline(idx, rows, scores, base_image=None, question=question, expected_gender=gender)
570
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571
 
572
  # ---------------------------------------------------------------------------
573
- # GRADIO UI
574
  # ---------------------------------------------------------------------------
575
  CUSTOM_CSS = """
576
  @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Inter:wght@400;500;600;700&display=swap');
577
- body, .gradio-container {background-color: #FAF7F2 !important;
578
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;}
579
- .gradio-container {max-width: 860px !important; margin: 0 auto !important;}
580
- footer {display: none !important;}
581
-
582
- .tab-nav button {font-size: 14px !important; font-weight: 600 !important; padding: 14px 26px !important;
583
- letter-spacing: .03em !important;}
584
- .tab-nav button.selected {color: #9C7A4E !important; border-bottom: 2px solid #C9A876 !important;}
585
-
586
- #find-btn, .big-btn {background: #1B1814 !important; color: #F5EFE6 !important; border: none !important;
587
- border-radius: 4px !important; font-weight: 600 !important; font-size: 14px !important;
588
- padding: 15px !important; text-transform: uppercase; letter-spacing: .12em !important;
589
- transition: background .25s !important;}
590
- #find-btn:hover, .big-btn:hover {background: #3A332B !important;}
591
-
592
- .dark-panel {background: #1B1814 !important; border-radius: 10px !important; padding: 22px !important;}
593
- .dark-panel label, .dark-panel span {color: #E8DFD2 !important;}
594
-
595
- /* Results layout */
596
- .section-split {display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px; margin-bottom: 16px;}
597
- .results-box {background: #FFFFFF; border-radius: 6px; padding: 26px;
598
- border: 1px solid #ECE4D6; box-shadow: 0 8px 28px rgba(60,50,30,.04);}
599
- .results-box h3 {font-size: 10.5px; font-weight: 600; color: #B3A38A; letter-spacing: 1.6px;
600
- text-transform: uppercase; margin: 0 0 16px;}
601
- .swatch-grid {display: flex; gap: 20px; flex-wrap: wrap;}
602
- .swatch-card {text-align: center; font-size: 11px; color: #8A7F6E; width: 58px;}
603
- .color-bubble {width: 42px; height: 42px; border-radius: 50%; margin: 0 auto 8px;
604
- border: 1px solid rgba(0,0,0,.05); box-shadow: 0 2px 6px rgba(0,0,0,.05);}
605
- .tip-text {font-size: 14px; color: #3A332B; line-height: 1.65; margin: 0; font-style: italic;}
606
-
607
- /* Outfit / product cards */
608
- .outfit-grid {display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px;}
609
- @media (max-width: 700px) {.outfit-grid {grid-template-columns: repeat(2, 1fr);}}
610
- .product-card {background: #fff; border: 1px solid #ECE4D6; border-radius: 6px; overflow: hidden;
611
- box-shadow: 0 8px 28px rgba(60,50,30,.04);}
612
- .card-color-header {width: 100%; height: 84px; display: flex; align-items: center; justify-content: center;}
613
- .prod-bubble {width: 38px; height: 38px; border-radius: 50%; box-shadow: 0 2px 8px rgba(0,0,0,.06);}
614
- .prod-meta {padding: 18px;}
615
- .prod-cat {font-size: 10px; font-weight: 600; color: #9C7A4E; letter-spacing: 1.4px; text-transform: uppercase;}
616
- .prod-title {font-size: 14px; font-weight: 600; color: #1B1814; margin: 5px 0 3px; line-height: 1.3;}
617
- .prod-brand {font-size: 11px; color: #B3A38A; display: block; margin-bottom: 12px; letter-spacing: .04em;}
618
- .shop-btn {display: block; text-align: center; padding: 10px 0; background: #1B1814; color: #F5EFE6 !important;
619
- text-decoration: none !important; border-radius: 4px; font-size: 11px; font-weight: 600;
620
- letter-spacing: .1em; text-transform: uppercase;}
621
- .shop-btn:hover {background: #C9A876; color: #1B1814 !important;}
622
- .more-matches-label {font-size: 10.5px; font-weight: 600; color: #B3A38A; letter-spacing: 1.6px;
623
- text-transform: uppercase; margin: 20px 0 10px;}
624
- .palette-name {font-family: 'Playfair Display', Georgia, serif !important;}
625
  """
626
 
 
 
 
627
  with gr.Blocks(title="Personal Color Styling", css=CUSTOM_CSS, theme=gr.themes.Soft(primary_hue="amber")) as demo:
628
  gr.HTML("""
629
- <div style="text-align:center;padding:30px 20px 18px;">
630
- <div style="font-size:11px;font-weight:600;color:#C9A876;letter-spacing:.18em;
631
- text-transform:uppercase;margin-bottom:10px;">Personal Color Styling</div>
632
- <div style="font-size:14px;color:#8A7F6E;max-width:480px;margin:0 auto;line-height:1.6;">
633
- Upload a photo or describe your features — receive a curated palette,
634
- a personal stylist note, and a brand-new look generated just for you.
635
  </div>
636
  </div>
637
  """)
@@ -642,13 +457,14 @@ with gr.Blocks(title="Personal Color Styling", css=CUSTOM_CSS, theme=gr.themes.S
642
  with gr.Column():
643
  gender_a = gr.Dropdown(GENDERS, label="Gender (optional)")
644
  age_a = gr.Dropdown(AGE_GROUPS, label="Age group (optional)")
645
- question_a = gr.Textbox(label="Ask the stylist a question about your photo (optional)",
646
- placeholder="e.g. What style would suit me best?")
647
- btn_a = gr.Button("Reveal My Palette", elem_id="find-btn", variant="primary")
648
- style_card_a = gr.HTML(label="Style Profile")
649
  new_img_a = gr.Image(label="✨ Your New AI-Generated Look")
650
  answer_a = gr.Textbox(label="Answer to your question")
651
- outfit_cards_a = gr.HTML(label="Outfit Picks")
 
652
  btn_a.click(
653
  recommend_from_photo,
654
  [photo_in, gender_a, age_a, question_a],
@@ -660,7 +476,7 @@ with gr.Blocks(title="Personal Color Styling", css=CUSTOM_CSS, theme=gr.themes.S
660
  inputs=[photo_in, gender_a, age_a, question_a],
661
  outputs=[style_card_a, new_img_a, answer_a, outfit_cards_a],
662
  fn=recommend_from_photo,
663
- cache_examples=True, # precomputed at Space startup -> truly "1-click and see a result"
664
  label="Quick Starters",
665
  )
666
 
@@ -674,36 +490,23 @@ with gr.Blocks(title="Personal Color Styling", css=CUSTOM_CSS, theme=gr.themes.S
674
  with gr.Row():
675
  gender_b = gr.Dropdown(GENDERS, label="Gender", value=GENDERS[0])
676
  age_b = gr.Dropdown(AGE_GROUPS, label="Age group", value=AGE_GROUPS[0])
677
- question_b = gr.Textbox(label="Ask the stylist a question about the top match (optional)",
678
- placeholder="e.g. Is this outfit formal or casual?")
679
- btn_b = gr.Button("Reveal My Palette", elem_id="find-btn", elem_classes="big-btn", variant="primary")
680
  features_recap_b = gr.HTML(build_features_recap_html(SKIN_TONES[0], UNDERTONES[0], STYLES[0]))
681
  for _dropdown in (skin_b, undertone_b, style_b):
682
- _dropdown.change(
683
- build_features_recap_html, [skin_b, undertone_b, style_b], features_recap_b
684
- )
685
- style_card_b = gr.HTML(label="Style Profile")
686
  new_img_b = gr.Image(label="✨ Your New AI-Generated Look")
687
  answer_b = gr.Textbox(label="Answer to your question")
688
- outfit_cards_b = gr.HTML(label="Outfit Picks")
 
689
  btn_b.click(
690
  recommend_from_features,
691
  [skin_b, undertone_b, style_b, gender_b, age_b, question_b],
692
  [style_card_b, new_img_b, answer_b, outfit_cards_b],
693
  )
694
 
695
- gr.Examples(
696
- examples=[
697
- ["deep", "cool", "boho", "woman", "young adult", "Is this outfit formal or casual?"],
698
- ["tan", "warm", "elegant", "man", "adult", "What occasion suits this outfit?"],
699
- ["fair", "neutral", "minimalist", "woman", "teen", "What season is this outfit best for?"],
700
- ],
701
- inputs=[skin_b, undertone_b, style_b, gender_b, age_b, question_b],
702
- outputs=[style_card_b, new_img_b, answer_b, outfit_cards_b],
703
- fn=recommend_from_features,
704
- cache_examples=True, # precomputed at Space startup -> truly "1-click and see a result"
705
- label="Quick Starters",
706
- )
707
-
708
  if __name__ == "__main__":
709
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import base64
2
  import os
3
  import urllib.parse
 
10
  import faiss
11
  from datasets import load_dataset
12
  from PIL import Image
 
13
  from transformers import (
14
  CLIPModel, CLIPProcessor, pipeline as hf_pipeline,
15
  BlipProcessor, BlipForQuestionAnswering,
 
18
  from diffusers import StableDiffusionInpaintPipeline
19
 
20
  # ---------------------------------------------------------------------------
21
+ # CONFIG
22
  # ---------------------------------------------------------------------------
23
+ HF_DATASET_REPO = "lihicarmeli/fashion-stylist-multimodal-v2"
24
+ HF_WINNING_MODEL = "openai/clip-vit-base-patch32"
25
+ EMBEDDINGS_FILE = "final_image_embeddings.npy"
26
+ METADATA_FILE = "catalog_metadata.parquet"
27
 
28
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
29
 
30
  # ---------------------------------------------------------------------------
31
+ # LOAD DATA + WINNING EMBEDDING MODEL
32
  # ---------------------------------------------------------------------------
33
  print("Loading dataset from HF Hub...")
34
  ds = load_dataset(HF_DATASET_REPO)
35
  df = ds["train"].to_pandas()
36
  images = [ds["train"][i]["image_improved"] for i in range(len(ds["train"]))]
37
 
38
+ print("Generating Quick Starter sample photos...")
39
  SAMPLES_DIR = "samples_cache"
40
  os.makedirs(SAMPLES_DIR, exist_ok=True)
41
 
 
42
  def _pick_index(filter_fn, fallback_idx=0):
43
  matches = df[df.apply(filter_fn, axis=1)]
44
  return int(matches.index[0]) if len(matches) else fallback_idx
45
 
 
46
  _sample_specs = [
47
  ("demo_woman.jpg", lambda r: r["gender"] == "woman"),
48
  ("demo_man.jpg", lambda r: r["gender"] == "man"),
 
55
  _path = os.path.join(SAMPLES_DIR, _filename)
56
  images[_idx].convert("RGB").save(_path)
57
  SAMPLE_PHOTOS.append(_path)
 
58
 
59
  print("Loading precomputed embeddings...")
60
  image_embeddings = np.load(EMBEDDINGS_FILE).astype("float32")
61
 
62
+ print("Loading winning embedding model (CLIP)...")
63
  win_model = CLIPModel.from_pretrained(HF_WINNING_MODEL).to(DEVICE).eval()
64
  win_processor = CLIPProcessor.from_pretrained(HF_WINNING_MODEL)
65
 
 
70
  faiss_img_index.add(image_embeddings)
71
 
72
  # ---------------------------------------------------------------------------
73
+ # GENERATION MODELS
74
  # ---------------------------------------------------------------------------
75
+ print("Loading text and VQA generation pipelines...")
76
+ caption_gen_pipe = hf_pipeline("text-generation", model="Qwen/Qwen2.5-0.5B-Instruct", device=0 if DEVICE == "cuda" else -1)
 
 
 
 
 
 
77
  vqa_processor = BlipProcessor.from_pretrained("Salesforce/blip-vqa-base")
78
  vqa_model = BlipForQuestionAnswering.from_pretrained("Salesforce/blip-vqa-base").to(DEVICE)
79
 
80
+ print("Loading segmentation and Stable Diffusion pipelines...")
81
  seg_processor = SegformerImageProcessor.from_pretrained("mattmdjaga/segformer_b2_clothes")
82
  seg_model = AutoModelForSemanticSegmentation.from_pretrained("mattmdjaga/segformer_b2_clothes").to(DEVICE)
 
 
83
  inpaint_pipe = StableDiffusionInpaintPipeline.from_pretrained(
84
  "runwayml/stable-diffusion-inpainting",
85
  torch_dtype=torch.float16 if DEVICE == "cuda" else torch.float32,
 
92
  UNDERTONES = sorted(df["undertone"].unique().tolist())
93
  STYLES = sorted(df["style_preference"].unique().tolist())
94
 
95
+ # ---------------------------------------------------------------------------
96
+ # UTILS & VISUAL CONFIG
97
+ # ---------------------------------------------------------------------------
98
+ _SWATCH_CSS = {
99
+ "ice white": "#f4f3ee", "bold blue": "#1d4ed8", "royal blue": "#1e3a8a",
100
+ "fuchsia": "#c026d3", "cool red": "#dc2626", "deep teal": "#0f766e",
101
+ "plum": "#6b21a8", "silver": "#cbd5e1", "bright pink": "#ec4899",
102
+ "deep jewel tones": "#581c87", "rust": "#b45309", "camel": "#c19a6b",
103
+ "coral": "#fb7185", "ivory": "#fffff0", "olive": "#65730a",
104
+ "terracotta": "#c2643a", "peach": "#FBC299", "warm camel": "#C69E6E"
105
+ }
106
 
107
+ def _swatch_color(name):
108
+ key = str(name).strip().lower()
109
+ if key in _SWATCH_CSS:
110
+ return _SWATCH_CSS[key]
111
+ palette = ["#b45309", "#1d4ed8", "#c026d3", "#0f766e", "#dc2626", "#6b21a8"]
112
+ return palette[hash(key) % len(palette)]
113
+
114
+ def _lighten_hex(hex_color, factor=0.85):
115
+ hex_color = hex_color.lstrip("#")
116
+ if len(hex_color) != 6:
117
+ return "#F5E6E1"
118
+ r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
119
+ r = int(r + (255 - r) * factor)
120
+ g = int(g + (255 - g) * factor)
121
+ b = int(b + (255 - b) * factor)
122
+ return f"#{r:02x}{g:02x}{b:02x}"
123
+
124
+ _PALETTE_NAMES = {
125
+ "fair": {"cool": "Porcelain Frost", "warm": "Champagne Silk", "neutral": "Pale Linen"},
126
+ "light": {"cool": "Moonlit Pearl", "warm": "Gilded Honey", "neutral": "Quiet Ivory"},
127
+ "medium": {"cool": "Dusk Orchid", "warm": "Spiced Amber", "neutral": "Warm Alabaster"},
128
+ "olive": {"cool": "Sage Noir", "warm": "Burnished Olive", "neutral": "Terracotta Earth"},
129
+ "tan": {"cool": "Copper Veil", "warm": "Gilded Sand", "neutral": "Desert Rosé"},
130
+ "deep": {"cool": "Midnight Sapphire", "warm": "Mahogany Gold", "neutral": "Onyx Velvet"},
131
+ }
132
+
133
+ def get_palette_name(skin_tone, undertone):
134
+ return _PALETTE_NAMES.get(str(skin_tone).strip().lower(), {}).get(
135
+ str(undertone).strip().lower(), "Signature Palette"
136
+ )
137
+
138
+ def pil_to_base64(img, max_size=280):
139
+ img = img.convert("RGB").copy()
140
+ img.thumbnail((max_size, max_size))
141
+ buf = BytesIO()
142
+ img.save(buf, format="JPEG", quality=85)
143
+ return base64.b64encode(buf.getvalue()).decode("utf-8")
144
 
145
  # ---------------------------------------------------------------------------
146
+ # EMBEDDING + FAISS SEARCH
147
  # ---------------------------------------------------------------------------
148
  @torch.no_grad()
149
  def embed_query_image(pil_image):
150
  inputs = win_processor(images=pil_image, return_tensors="pt").to(DEVICE)
151
  feats = win_model.get_image_features(**inputs)
 
152
  return feats.cpu().numpy().astype("float32")
153
 
 
154
  @torch.no_grad()
155
  def embed_query_text(sentence):
156
  inputs = win_processor(text=[sentence], return_tensors="pt", padding=True, truncation=True).to(DEVICE)
157
  feats = win_model.get_text_features(**inputs)
 
158
  return feats.cpu().numpy().astype("float32")
159
 
 
160
  def build_feature_sentence(skin_tone, undertone, style_preference, gender=None, age_group=None):
161
  descriptor = " ".join(p for p in [age_group, gender] if p) or "person"
162
+ return f"a {descriptor} with {skin_tone} skin tone and {undertone} undertone, wearing a {style_preference} style outfit"
 
 
 
 
163
 
164
  def faiss_filtered_search(query_emb, top_k=3, exclude_idx=None, gender=None, age_group=None):
165
  faiss.normalize_L2(query_emb)
 
191
 
192
  return np.array(kept_i), df.iloc[kept_i], np.array(kept_d)
193
 
 
194
  # ---------------------------------------------------------------------------
195
+ # PATTERN LOGIC & COMPONENT BUILDERS (FIXED SYNTAXERRORS & MISSING FUNCTIONS)
196
  # ---------------------------------------------------------------------------
 
 
197
  def generate_stylist_caption(row):
198
  user_prompt = (
199
  f"Write one short, warm sentence (max 25 words) from a fashion stylist, recommending this look: "
200
+ f"a {row['style_preference']} style outfit in {row['primary_color']} and {row['secondary_color']}. "
201
+ f"Be specific and stylish, no hashtags."
202
  )
203
  messages = [{"role": "user", "content": user_prompt}]
204
  output = caption_gen_pipe(messages, max_new_tokens=40, do_sample=True, temperature=0.7)
205
  return output[0]["generated_text"][-1]["content"].strip()
206
 
 
 
207
  @torch.no_grad()
208
  def answer_question_about_image(pil_image, question):
209
  inputs = vqa_processor(pil_image.convert("RGB"), question, return_tensors="pt").to(DEVICE)
210
  output_ids = vqa_model.generate(**inputs, max_new_tokens=20)
211
  return vqa_processor.decode(output_ids[0], skip_special_tokens=True)
212
 
 
 
 
 
 
 
213
  @torch.no_grad()
214
  def get_face_protect_mask(pil_image, use_geometric_fallback=True):
 
 
 
 
 
215
  inputs = seg_processor(images=pil_image, return_tensors="pt").to(DEVICE)
216
  logits = seg_model(**inputs).logits
217
+ upsampled = torch.nn.functional.interpolate(logits, size=pil_image.size[::-1], mode="bilinear", align_corners=False)
 
 
218
  pred_seg = upsampled.argmax(dim=1)[0].cpu().numpy()
219
+ seg_protect = np.isin(pred_seg, [11, 2])
 
220
  if not use_geometric_fallback:
221
  return seg_protect
 
222
  h, w = seg_protect.shape
223
  yy, xx = np.mgrid[0:h, 0:w]
224
+ geometric_protect = (((xx - w*0.5) / (w*0.22)) ** 2 + ((yy - h*0.42) / (h*0.30)) ** 2) <= 1.0
 
 
 
225
  return seg_protect | geometric_protect
226
 
227
+ def generate_new_outfit_image(pil_image, row, target_gender=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  base = pil_image.convert("RGB")
229
+ protect = get_face_protect_mask(base)
230
+ canvas_w, canvas_h = 512, 1024
231
+ head_w = int(canvas_w * 0.45)
 
232
  scale = head_w / base.width
233
  head_h = int(base.height * scale)
234
+
235
  resized_face_crop = base.resize((head_w, head_h))
236
+ resized_protect_img = Image.fromarray(protect.astype(np.uint8) * 255).resize((head_w, head_h), resample=Image.NEAREST)
237
+
238
+ canvas = Image.new("RGB", (canvas_w, canvas_h), color=(240, 238, 235))
 
 
239
  paste_x = (canvas_w - head_w) // 2
240
  paste_y = int(canvas_h * 0.03)
241
  canvas.paste(resized_face_crop, (paste_x, paste_y))
242
+
243
+ mask_arr = np.full((canvas_h, canvas_w), 255, dtype=np.uint8)
244
+ mask_arr[paste_y:paste_y + head_h, paste_x:paste_x + head_w][np.array(resized_protect_img) > 127] = 0
 
245
  mask = Image.fromarray(mask_arr).convert("L")
246
+
247
+ gender_word = "man" if str(row["gender"]).lower() in ("man", "male") else "woman"
248
+ prompt = (
249
+ f"full body fashion photo of a {row['age_group']} {gender_word}, standing, wearing a {row['style_preference']} style outfit: "
250
+ f"{row['outfit_top']}, {row['outfit_bottom']}, {row['outfit_shoes']}, in {row['primary_color']}, quality photography"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  )
252
+ generated = inpaint_pipe(prompt=prompt, image=canvas, mask_image=mask, num_inference_steps=25, height=canvas_h, width=canvas_w).images[0]
253
+ return Image.composite(generated, canvas, mask), prompt
254
 
255
+ # --- CRITICAL FIX: DEFINING THE MISSING HEADER AND PROFILE CARD BUILDER ---
256
+ def build_style_card_html(row, caption):
 
 
 
 
 
 
 
 
 
 
257
  colors = [c.strip() for c in str(row["recommended_colors"]).split(",") if c.strip()][:4]
258
  swatches = "".join(
259
+ f'<div class="swatch-card"><div class="color-bubble" style="background-color:{_swatch_color(c)};"></div><p>{c.title()}</p></div>'
 
260
  for c in colors
261
  )
262
  palette_name = get_palette_name(row["skin_tone"], row["undertone"])
263
+
264
+ html = f'<div class="palette-premium-banner">'
265
+ html += f'<div style="font-size:11px; letter-spacing:2px; color:#9C7A4E; font-weight:700; text-transform:uppercase;">YOUR COLOR PALETTE</div>'
266
+ html += f'<div class="palette-name" style="font-size:32px; font-weight:700; margin:6px 0; color:#1B1814;">{palette_name}</div>'
267
+ html += f'<div style="font-size:13px; color:#666;">{str(row["skin_tone"]).title()} Skin · {str(row["undertone"]).title()} Undertone · {str(row["style_preference"]).title()} Style</div>'
268
+ html += f'</div>'
269
+ html += f'<div class="section-split">'
270
+ html += f'<div class="results-box"><h3>COLORS FOR YOU</h3><div class="swatch-grid">{swatches}</div></div>'
271
+ html += f'<div class="results-box"><h3>STYLIST TIP</h3><p class="tip-text">{caption}</p></div>'
272
+ html += f'</div>'
273
+ return html
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
+ def to_shop_link(retailer, value, gender=None):
276
+ g = str(gender).strip().lower() if gender is not None else ""
277
+ dept = "men" if g in ("male", "man", "men", "m") else "women"
278
+ query_enc = urllib.parse.quote(str(value))
279
+ if retailer == "zara":
280
+ return f"https://www.zara.com/us/en/search?searchTerm={query_enc}"
281
+ if retailer == "asos":
282
+ return f"https://www.asos.com/us/search/?q={query_enc}"
283
+ if retailer == "hm":
284
+ return f"https://www2.hm.com/en_us/search-results.html?q={query_enc}"
285
+ return f"https://www.google.com/search?q={retailer}+{query_enc}"
286
 
287
  def build_outfit_component_cards_html(row):
288
+ """Generates the clean 6 core component cards layout mirroring LookMatch perfectly"""
 
289
  colors = [c.strip() for c in str(row["recommended_colors"]).split(",") if c.strip()] or ["neutral"]
290
  components = [
291
  ("TOP", row.get("outfit_top", "Top"), "zara", "search_query_zara"),
292
+ ("TROUSERS", row.get("outfit_bottom", "Trousers"), "asos", "search_query_asos"),
293
+ ("SHOES", row.get("outfit_shoes", "Shoes"), "zara", "search_query_zara"),
294
+ ("JACKET", "Tailored Jacket Profile", "hm", "search_query_hm"),
295
+ {"category": "BAG", "name": "Minimalist Tote Bag", "retailer": "asos", "query": "structured tan tote bag"},
296
+ {"category": "DRESS", "name": "Classic Slip Midi Dress", "retailer": "zara", "query": "terracotta slip midi dress"}
297
  ]
298
+
299
  cards = []
300
+ for i, comp in enumerate(components):
301
+ if isinstance(comp, dict):
302
+ label, item_name, retailer = comp["category"], comp["name"], comp["retailer"]
303
+ link = to_shop_link(retailer, comp["query"], row["gender"])
304
+ else:
305
+ label, item_name, retailer, col = comp
306
+ link = to_shop_link(retailer, row.get(col, item_name), row["gender"])
307
+
308
  raw_color = _swatch_color(colors[i % len(colors)])
 
309
  banner_color = _lighten_hex(raw_color, 0.88)
310
+
311
+ card_html = f'<div class="product-card">'
312
+ card_html += f'<div class="card-color-header" style="background-color:{banner_color};">'
313
+ card_html += f'<div class="prod-bubble" style="background-color:{raw_color};"></div>'
314
+ card_html += f'</div>'
315
+ card_html += f'<div class="prod-meta">'
316
+ card_html += f'<span class="prod-cat">{label}</span>'
317
+ card_html += f'<p class="prod-title">{str(item_name).title()}</p>'
318
+ card_html += f'<span class="prod-brand">{retailer.upper()}</span>'
319
+ card_html += f'<a href="{link}" target="_blank" rel="noopener noreferrer" class="shop-btn">Shop ↗</a>'
320
+ card_html += f'</div>'
321
+ card_html += f'</div>'
322
+ cards.append(card_html)
323
+
324
  return f'<div class="outfit-grid">{"".join(cards)}</div>'
325
 
326
+ def build_more_matches_html(matched_indices, similarities):
 
 
 
 
 
 
327
  cards = []
328
+ for idx, sim in list(zip(matched_indices, similarities))[1:4]:
329
  row = df.iloc[idx]
330
  img_b64 = pil_to_base64(images[idx])
331
+ link = to_shop_link("zara", row.get("search_query_zara", "clothing"), row["gender"])
332
+ card_html = f'<div class="product-card">'
333
+ card_html += f'<img src="data:image/jpeg;base64,{img_b64}" style="width:100%;height:150px;object-fit:cover;display:block;"/>'
334
+ card_html += f'<div class="prod-meta">'
335
+ card_html += f'<span class="prod-brand">Match score: {sim}</span>'
336
+ card_html += f'<a href="{link}" target="_blank" rel="noopener noreferrer" class="shop-btn">Shop </a>'
337
+ card_html += f'</div></div>'
338
+ cards.append(card_html)
339
+ return f'<div class="more-matches-label">MORE MATCHES LIKE THIS</div><div class="outfit-grid">{"".join(cards)}</div>'
 
 
 
 
 
 
340
 
341
  # ---------------------------------------------------------------------------
342
+ # MAIN PIPELINES
343
  # ---------------------------------------------------------------------------
344
+ def run_pipeline(matched_indices, matched_rows, matched_scores, base_image=None, question=None, expected_gender=None):
 
345
  if len(matched_indices) == 0:
346
  return "<i>No matches found — try different filters.</i>", None, "", "<div></div>"
 
347
  similarity = [round(1.0 - (d / 2.0), 3) for d in matched_scores]
 
348
  top_row = matched_rows.iloc[0]
 
349
  edit_base_image = base_image if base_image is not None else images[matched_indices[0]]
 
 
350
  gender_for_generation = expected_gender or top_row["gender"]
351
 
352
+ caption = generate_stylist_caption(top_row)
353
+ new_image, _ = generate_new_outfit_image(edit_base_image, top_row, target_gender=gender_for_generation)
354
+ answer = answer_question_about_image(edit_base_image, question) if question else ""
 
 
 
 
 
355
 
356
  style_card_html = build_style_card_html(top_row, caption)
357
+ outfit_cards_html = build_outfit_component_cards_html(top_row) + build_more_matches_html(matched_indices, similarity)
 
 
 
358
  return style_card_html, new_image, answer, outfit_cards_html
359
 
 
360
  def recommend_from_photo(photo, gender, age_group, question):
361
  if photo is None:
362
  return "<i>Please upload a photo or pick a Quick Starter.</i>", None, "", "<div></div>"
 
364
  idx, rows, scores = faiss_filtered_search(query_emb, gender=gender or None, age_group=age_group or None)
365
  return run_pipeline(idx, rows, scores, base_image=photo, question=question, expected_gender=gender)
366
 
 
367
  def recommend_from_features(skin_tone, undertone, style, gender, age_group, question):
368
  sentence = build_feature_sentence(skin_tone, undertone, style, gender, age_group)
369
  query_emb = embed_query_text(sentence)
370
  idx, rows, scores = faiss_filtered_search(query_emb, gender=gender or None, age_group=age_group or None)
371
  return run_pipeline(idx, rows, scores, base_image=None, question=question, expected_gender=gender)
372
 
373
+ def build_feature_pills_html(labels, selected, title):
374
+ pills = ""
375
+ for label in labels:
376
+ active = str(label).strip().lower() == str(selected).strip().lower()
377
+ border = "2px solid #D2527F" if active else "1.5px solid #ECE4D6"
378
+ bg = "#FFF0F5" if active else "#FFFFFF"
379
+ tcol = "#D2527F" if active else "#2C2A29"
380
+ dot = _swatch_color(label) if title != "Style" else "#C69E6E"
381
+ pills += f'<div style="display:inline-flex;align-items:center;gap:7px;padding:8px 14px;border-radius:30px;border:{border};background:{bg};margin:4px;"><div style="width:12px;height:12px;border-radius:50%;background:{dot};border:1px solid rgba(0,0,0,.1);"></div><span style="font-size:13px;font-weight:600;color:{tcol};">{str(label).title()}</span></div>'
382
+ return f'<div style="margin-bottom:14px;"><div style="font-size:11px;font-weight:700;color:#8A7F6E;text-transform:uppercase;letter-spacing:.1em;margin-bottom:8px;">{title}</div><div style="display:flex;flex-wrap:wrap;margin:-4px;">{pills}</div></div>'
383
+
384
+ def build_features_recap_html(skin_tone, undertone, style):
385
+ return f'<div style="background:#fff;border-radius:16px;padding:24px;margin-bottom:20px;border:1px solid #ECE4D6;"><div style="font-size:12px;font-weight:700;color:#D2527F;text-transform:uppercase;letter-spacing:.1em;margin-bottom:16px;">Your Selected Specifications</div>{build_feature_pills_html(SKIN_TONES, skin_tone, "Skin tone")}{build_feature_pills_html(UNDERTONES, undertone, "Undertone")}{build_feature_pills_html(STYLES, style, "Style")}</div>'
386
 
387
  # ---------------------------------------------------------------------------
388
+ # PREMIUM PASTEL LUXURY CSS THEME
389
  # ---------------------------------------------------------------------------
390
  CUSTOM_CSS = """
391
  @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Inter:wght@400;500;600;700&display=swap');
392
+ body, .gradio-container { background-color: #F8F5F5 !important; font-family: 'Inter', sans-serif !important; }
393
+ .gradio-container { max-width: 850px !important; margin: 0 auto !important; padding-top: 20px !important; }
394
+ footer { display: none !important; }
395
+
396
+ /* Global Font Contrast Controls - No Invisible Text */
397
+ h1, h2, h3, p, span, label, input, select, textarea, button { color: #2C2A29 !important; }
398
+
399
+ .tab-nav button { font-size: 14px !important; font-weight: 600 !important; padding: 14px 24px !important; color: #555 !important; }
400
+ .tab-nav button.selected { color: #D2527F !important; border-bottom: 2px solid #D2527F !important; }
401
+
402
+ #find-btn, .big-btn { background: #161617 !important; color: #FFFFFF !important; border: none !important; border-radius: 8px !important; font-weight: 700 !important; font-size: 14px !important; padding: 14px !important; text-transform: uppercase; letter-spacing: .08em !important; width: 100% !important; margin-top: 10px; }
403
+ #find-btn:hover, .big-btn:hover { background: #2D2D2F !important; }
404
+
405
+ /* Clean Pastel Premium Layout Blocks */
406
+ .dark-panel { background: #FFFFFF !important; border-radius: 16px !important; padding: 24px !important; border: 1px solid #ECE4D6 !important; margin-bottom: 20px; }
407
+ .dark-panel label, .dark-panel span { color: #2C2A29 !important; font-weight: 600; }
408
+
409
+ .palette-premium-banner { background: #FAF3ED; padding: 24px; border-radius: 12px; margin-bottom: 20px; border-left: 5px solid #C69E6E; }
410
+ .section-split { display: grid !important; grid-template-columns: repeat(2, 1fr) !important; gap: 20px !important; margin-top: 20px !important; width: 100% !important; }
411
+ @media (max-width: 768px) { .section-split { grid-template-columns: 1fr !important; } }
412
+
413
+ .results-box { background: #FFFFFF; border-radius: 16px; padding: 24px; border: 1px solid #EFECE8; }
414
+ .results-box h3 { font-size: 11px; font-weight: 700; color: #9C8E82 !important; letter-spacing: 1.5px; text-transform: uppercase; margin: 0 0 14px; }
415
+ .swatch-grid { display: flex; gap: 16px; flex-wrap: wrap; }
416
+ .swatch-card { text-align: center; font-size: 12px; color: #666666 !important; width: 60px; }
417
+ .color-bubble { width: 44px; height: 44px; border-radius: 50%; margin: 0 auto 6px; border: 1px solid rgba(0,0,0,.06); box-shadow: inset 0 0 0 2px #FFF; }
418
+ .tip-text { font-size: 15px; color: #222222 !important; line-height: 1.6; margin: 0; font-style: italic; }
419
+
420
+ /* Perfect 6-Card Row Flow without Hiding or Squishing */
421
+ .outfit-grid { display: grid !important; grid-template-columns: repeat(3, 1fr) !important; gap: 20px !important; margin-top: 20px !important; width: 100% !important; }
422
+ @media (max-width: 768px) { .outfit-grid { grid-template-columns: repeat(2, 1fr) !important; } }
423
+ @media (max-width: 480px) { .outfit-grid { grid-template-columns: 1fr !important; } }
424
+
425
+ .product-card { background: #FFFFFF; border: 1px solid #EFECE8; border-radius: 16px; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 4px 12px rgba(0,0,0,0.01); width: 100% !important; }
426
+ .card-color-header { width: 100%; height: 95px; display: flex; align-items: center; justify-content: center; }
427
+ .prod-bubble { width: 46px; height: 46px; border-radius: 50%; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
428
+ .prod-meta { padding: 20px; display: flex; flex-direction: column; align-items: flex-start; text-align: left; width: 100%; }
429
+ .prod-cat { font-size: 11px; font-weight: 700; color: #D2527F !important; letter-spacing: 0.5px; text-transform: uppercase; margin-bottom: 4px; }
430
+ .prod-title { font-size: 15px; font-weight: 700; color: #111111 !important; margin: 0 0 4px 0; line-height: 1.3; min-height: 40px; display: flex; align-items: center; }
431
+ .prod-brand { font-size: 13px; color: #999999 !important; margin-bottom: 14px; display: block; }
432
+
433
+ .shop-btn { display: block; width: 100%; background: #161617; color: #FFFFFF !important; text-align: center; padding: 11px 0; border-radius: 8px; font-size: 13px; font-weight: 700; text-decoration: none !important; letter-spacing: 0.5px; }
434
+ .shop-btn:hover { background: #2D2D2F; color: #FFFFFF !important; }
435
+ .more-matches-label { font-size: 11px; font-weight: 700; color: #9C8E82 !important; letter-spacing: 1.6px; text-transform: uppercase; margin: 24px 0 12px; }
436
+ .palette-name { font-family: 'Playfair Display', serif !important; }
437
+ .footer-note { font-size: 13px; color: #A0A0A0 !important; text-align: center; margin-top: 16px; }
 
 
438
  """
439
 
440
+ # ---------------------------------------------------------------------------
441
+ # INTERFACE BUILD
442
+ # ---------------------------------------------------------------------------
443
  with gr.Blocks(title="Personal Color Styling", css=CUSTOM_CSS, theme=gr.themes.Soft(primary_hue="amber")) as demo:
444
  gr.HTML("""
445
+ <div style="text-align:center; padding:24px 20px 10px;">
446
+ <div style="font-size:11px; font-weight:700; color:#D2527F; letter-spacing:.18em; text-transform:uppercase; margin-bottom:8px;">Personal Color Styling</div>
447
+ <div class="palette-name" style="font-size:38px; font-weight:700; color:#111; margin-bottom:6px;">LookMatch</div>
448
+ <div style="font-size:14px; color:#666; max-width:480px; margin:0 auto; line-height:1.6;">
449
+ Upload a photo or describe your features — receive a curated palette, a personal stylist note, and a brand-new look generated just for you.
 
450
  </div>
451
  </div>
452
  """)
 
457
  with gr.Column():
458
  gender_a = gr.Dropdown(GENDERS, label="Gender (optional)")
459
  age_a = gr.Dropdown(AGE_GROUPS, label="Age group (optional)")
460
+ question_a = gr.Textbox(label="Ask the stylist a question about your photo (optional)", placeholder="e.g. What style would suit me best?")
461
+ btn_a = gr.Button("Get my look ✨", elem_id="find-btn", variant="primary")
462
+
463
+ style_card_a = gr.HTML()
464
  new_img_a = gr.Image(label="✨ Your New AI-Generated Look")
465
  answer_a = gr.Textbox(label="Answer to your question")
466
+ outfit_cards_a = gr.HTML()
467
+
468
  btn_a.click(
469
  recommend_from_photo,
470
  [photo_in, gender_a, age_a, question_a],
 
476
  inputs=[photo_in, gender_a, age_a, question_a],
477
  outputs=[style_card_a, new_img_a, answer_a, outfit_cards_a],
478
  fn=recommend_from_photo,
479
+ cache_examples=True,
480
  label="Quick Starters",
481
  )
482
 
 
490
  with gr.Row():
491
  gender_b = gr.Dropdown(GENDERS, label="Gender", value=GENDERS[0])
492
  age_b = gr.Dropdown(AGE_GROUPS, label="Age group", value=AGE_GROUPS[0])
493
+ question_b = gr.Textbox(label="Ask the stylist a question about the top match (optional)", placeholder="e.g. Is this outfit formal or casual?")
494
+ btn_b = gr.Button("Get my look ✨", elem_id="find-btn", variant="primary")
495
+
496
  features_recap_b = gr.HTML(build_features_recap_html(SKIN_TONES[0], UNDERTONES[0], STYLES[0]))
497
  for _dropdown in (skin_b, undertone_b, style_b):
498
+ _dropdown.change(build_features_recap_html, [skin_b, undertone_b, style_b], features_recap_b)
499
+
500
+ style_card_b = gr.HTML()
 
501
  new_img_b = gr.Image(label="✨ Your New AI-Generated Look")
502
  answer_b = gr.Textbox(label="Answer to your question")
503
+ outfit_cards_b = gr.HTML()
504
+
505
  btn_b.click(
506
  recommend_from_features,
507
  [skin_b, undertone_b, style_b, gender_b, age_b, question_b],
508
  [style_card_b, new_img_b, answer_b, outfit_cards_b],
509
  )
510
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  if __name__ == "__main__":
512
+ demo.launch()