# 檔名:recommender.py # 功能: # - 負責「推薦模型 / 規則」 # - 對外只提供一個函式:run_recommend_model(report, weather_dict) # # 目前流程: # 1. 從 report 讀取:身形、臉型、性別、膚色資訊。 # 2. 依身形 / 臉型 / 性別 / 氣溫,從 outfits.json 篩出候選款式。 # 3. 依 skin_analysis.skin_tone_type / skin_tone_name 找到色盤(colors.json -> palettes), # 展開出顏色搭配 (top_color_name, bottom_color_name)。 # 4. 對每組顏色搭配計算: # - 相似色分數 S_sim # - 互補色分數 S_comp # - 對比色分數 S_cont # - 與膚色的相容度 S_skin # 再用:S_color = max(S_sim, S_comp, S_cont) + α · S_skin。 # 5. 對每個 outfit,從高分顏色搭配中,挑選一組「顏色 tags 符合 # top_color_tags / bottom_color_tags」的組合,生成英文 prompt。 # # ✅ 新版 prompt 結構(給 MGD / VTON): # outfits.json 每個 outfit 用 prompt_items_en,像: # "prompt_items_en": { # "top": ["{top_color_en} plain tshirt", ...], # "bottom": ["{bottom_color_en} straight jeans", ...], # "outer": ["{top_color_en} bomber jacket", ...] # } # # 回傳 clothe_json(ClotheJSON): # { # "M_RECT_01_SUITPANTS_01": { # "top": [... 3 prompts ...], # "bottom": [... 3 prompts ...], # "outer": [... 3 prompts ...] # }, # ... # } from __future__ import annotations from typing import Dict, List, Any, Tuple, Optional from pathlib import Path import json import math import colorsys import random # ============================================================ # 型別:回傳給 API 的 clothe_json # ============================================================ # 每套 outfit 會回傳多個「部位」的 prompt(每部位通常 3 句) # 例如:{"top":[...], "bottom":[...], "outer":[...]} PromptItemsEN = Dict[str, List[str]] # clothe_id -> PromptItemsEN ClotheJSON = Dict[str, PromptItemsEN] # ============================================================ # 讀取 outfits.json # ============================================================ BASE_DIR = Path(__file__).resolve().parent OUTFITS_PATH = BASE_DIR / "outfits.json" try: with OUTFITS_PATH.open("r", encoding="utf-8") as f: _outfits_raw = json.load(f) OUTFIT_LIBRARY: List[dict] = _outfits_raw.get("outfits", []) print(f"[Recommender] 載入 outfits.json,共 {len(OUTFIT_LIBRARY)} 套穿搭。") except FileNotFoundError: print("[Recommender] 找不到 outfits.json,OUTFIT_LIBRARY 為空,請確認檔案放在與 recommender.py 同一層。") OUTFIT_LIBRARY = [] except Exception as e: print(f"[Recommender] 讀取 outfits.json 發生錯誤:{e}") OUTFIT_LIBRARY = [] # ============================================================ # 讀取 colors.json # ============================================================ COLORS_PATH = BASE_DIR / "colors.json" try: with COLORS_PATH.open("r", encoding="utf-8") as f: _colors_raw = json.load(f) _RAW_COLORS: Dict[str, Dict[str, Any]] = _colors_raw.get("colors", {}) SKIN_TONE_TO_PALETTE: Dict[str, str] = _colors_raw.get("skin_tone_to_palette", {}) PALETTES: Dict[str, Any] = _colors_raw.get("palettes", {}) print(f"[Recommender] 載入 colors.json,顏色數量={len(_RAW_COLORS)},色盤數量={len(PALETTES)}。") except FileNotFoundError: print("[Recommender] 找不到 colors.json,請確認檔案放在與 recommender.py 同一層。") _RAW_COLORS = {} SKIN_TONE_TO_PALETTE = {} PALETTES = {} except Exception as e: print(f"[Recommender] 讀取 colors.json 發生錯誤:{e}") _RAW_COLORS = {} SKIN_TONE_TO_PALETTE = {} PALETTES = {} # ============================================================ # 色碼轉換:RGB / HEX -> HSL # ============================================================ def rgb_to_hsl(r: float, g: float, b: float) -> Tuple[float, float, float]: """RGB(0–255) -> HSL (H:0–360, S/L:0–1)""" r_n, g_n, b_n = r / 255.0, g / 255.0, b / 255.0 # colorsys 回傳的是 HLS(注意順序) h, l, s = colorsys.rgb_to_hls(r_n, g_n, b_n) return h * 360.0, s, l def hex_to_hsl(hex_str: str) -> Tuple[float, float, float]: """將十六進位色碼 (#RRGGBB) 轉成 HSL。""" hex_str = hex_str.strip().lstrip("#") if len(hex_str) == 3: # 例如 #abc -> #aabbcc hex_str = "".join([c * 2 for c in hex_str]) if len(hex_str) != 6: # 給一個中性灰的預設 return rgb_to_hsl(128, 128, 128) try: r = int(hex_str[0:2], 16) g = int(hex_str[2:4], 16) b = int(hex_str[4:6], 16) except ValueError: # hex 解析失敗也用預設 return rgb_to_hsl(128, 128, 128) return rgb_to_hsl(r, g, b) # 建立顏色資料庫:中文名稱 -> {en, hsl, tags} COLOR_DB: Dict[str, Dict[str, Any]] = {} for name, info in _RAW_COLORS.items(): hex_code = info.get("hex", "#888888") hsl = hex_to_hsl(hex_code) COLOR_DB[name] = { "en": info.get("en", name), "hsl": hsl, "tags": info.get("tags", []), } # ============================================================ # 超參數(之後都可以自己調) # ============================================================ ALPHA_SKIN = 0.55 # 原 0.8:降低膚色權重,避免壓過色彩和諧分 TOP_K_COLOR_COMBOS = 60 # 原 20:增加候選顏色組合,降低配不到的機率 MAX_OUTFITS = 3 # 維持輸出 3 套 PER_OUTFIT_COLOR_OPTIONS = 20 # 每個款式從前幾個可用顏色中抽一個,避免結果過度固定 FINAL_OUTFIT_SELECTION_POOL = 10 # 最終挑套裝時,不只硬取前 3 套,改從前幾名中抽樣 # skin 相容度相關參數:控制「色盤寬度」與「理想亮度/飽和度差」 SIGMA_H_SKIN = 55.0 # 原 40:放寬色相差容許 SIGMA_L_SKIN = 0.28 # 原 0.20:放寬亮度差容許 SIGMA_S_SKIN = 0.38 # 原 0.30:放寬飽和度差容許 MU_L_SKIN = 0.12 # 原 0.15:偏向較小亮度差,較自然 MU_S_SKIN = 0.08 # 原 0.10:稍降飽和度偏好,減少過度鮮豔 # ============================================================ # 色碼常數 # ============================================================ # 中性色設定(黑/白/灰) NEUTRAL_COLOR_NAMES = { "黑", "黑色", "白", "白色", "灰", "灰色", "black", "white", "gray", "grey", } NEUTRAL_COLOR_BONUS = 6.0 DOUBLE_NEUTRAL_EXTRA_BONUS = 2.0 def _is_neutral_color(color_name: str) -> bool: """判斷是否為中性色(黑/白/灰)。""" if not isinstance(color_name, str): return False key = color_name.strip().lower() if key in NEUTRAL_COLOR_NAMES: return True info = COLOR_DB.get(color_name) or {} en_name = str(info.get("en", "")).strip().lower() return en_name in NEUTRAL_COLOR_NAMES # ============================================================ # 色彩評分相關工具 # ============================================================ def hue_distance(h1: float, h2: float) -> float: """色相環上的距離 (0–180)。""" dh = abs(h1 - h2) % 360.0 if dh > 180.0: dh = 360.0 - dh return dh def gaussian(x: float, mu: float, sigma: float) -> float: """一維高斯函數,輸出約 0–1。""" if sigma <= 0: return 0.0 return math.exp(-((x - mu) / sigma) ** 2) def skin_compatibility_for_color( skin_hsl: Tuple[float, float, float], cloth_hsl: Tuple[float, float, float], ) -> float: """ 計算單一衣服顏色與膚色的相容度 C_skin(c_k),輸出 0–100。 - 色相差越小越好; - 與膚色亮度差約 MU_L_SKIN 最佳; - 飽和度比膚色稍高(約 MU_S_SKIN)最佳。 """ Hs, Ss, Ls = skin_hsl Hc, Sc, Lc = cloth_hsl delta_H = hue_distance(Hc, Hs) # 色相差 (degree) delta_L = abs(Lc - Ls) # 亮度差 delta_S = Sc - Ss # 飽和度差(衣服 - 膚色) term_h = gaussian(delta_H, 0.0, SIGMA_H_SKIN) term_l = gaussian(delta_L, MU_L_SKIN, SIGMA_L_SKIN) term_s = gaussian(delta_S, MU_S_SKIN, SIGMA_S_SKIN) return 100.0 * term_h * term_l * term_s def skin_compatibility_for_outfit( skin_hsl: Tuple[float, float, float], outfit_colors_hsl: List[Tuple[float, float, float]], weights: Optional[List[float]] = None, ) -> float: """ S_skin(o) = sum_k w_k · C_skin(c_k) / sum_k w_k 如果沒有給 weights,預設每個部位權重都一樣。 """ n = len(outfit_colors_hsl) if n == 0: return 0.0 if weights is None: weights = [1.0] * n total_w = sum(weights) if total_w <= 0: return 0.0 acc = 0.0 for w, color_hsl in zip(weights, outfit_colors_hsl): acc += w * skin_compatibility_for_color(skin_hsl, color_hsl) return acc / total_w def sim_score(hsl1: Tuple[float, float, float], hsl2: Tuple[float, float, float]) -> float: """ 相似色分數: - 色相差角度小; - 明度 / 飽和度差距小(但不要完全 0)。 """ H1, S1, L1 = hsl1 H2, S2, L2 = hsl2 dh = hue_distance(H1, H2) ds = abs(S1 - S2) dl = abs(L1 - L2) score_h = gaussian(dh, 0.0, 25.0) score_s = gaussian(ds, 0.15, 0.20) score_l = gaussian(dl, 0.10, 0.20) return 100.0 * score_h * score_s * score_l def comp_score(hsl1: Tuple[float, float, float], hsl2: Tuple[float, float, float]) -> float: """ 互補色分數: - 色相差約 180 度; - 飽和度差中等; - 明度差不大。 """ H1, S1, L1 = hsl1 H2, S2, L2 = hsl2 dh = hue_distance(H1, H2) ds = abs(S1 - S2) dl = abs(L1 - L2) score_h = gaussian(dh, 180.0, 25.0) score_s = gaussian(ds, 0.25, 0.20) score_l = gaussian(dl, 0.10, 0.20) return 100.0 * score_h * score_s * score_l def cont_score(hsl1: Tuple[float, float, float], hsl2: Tuple[float, float, float]) -> float: """ 對比色分數: - 色相差約 90 度; - 明度 / 飽和度差都「不小也不大」。 """ H1, S1, L1 = hsl1 H2, S2, L2 = hsl2 dh = hue_distance(H1, H2) ds = abs(S1 - S2) dl = abs(L1 - L2) score_h = gaussian(dh, 90.0, 25.0) score_s = gaussian(ds, 0.30, 0.20) score_l = gaussian(dl, 0.25, 0.20) return 100.0 * score_h * score_s * score_l def color_harmony_scores_for_combo( colors_hsl: List[Tuple[float, float, float]] ) -> Tuple[float, float, float]: """ 給一組顏色(目前假設 2 個顏色),計算: S_sim(o), S_comp(o), S_cont(o) """ if len(colors_hsl) < 2: return 0.0, 0.0, 0.0 hsl1, hsl2 = colors_hsl[0], colors_hsl[1] s_sim = sim_score(hsl1, hsl2) s_comp = comp_score(hsl1, hsl2) s_cont = cont_score(hsl1, hsl2) return s_sim, s_comp, s_cont # ============================================================ # 從 report 抽資訊、篩選款式、決定顏色候選 # ============================================================ def _normalize_gender(g: Optional[str]) -> Optional[str]: """把各種寫法的性別字串統一成 'male' / 'female'。""" if g is None: return None if not isinstance(g, str): return None g = g.strip().lower() if g in ("male", "m", "boy", "man", "男", "男性"): return "male" if g in ("female", "f", "girl", "woman", "女", "女性"): return "female" return None def extract_skin_rgb(skin_info: Dict[str, Any]) -> Tuple[float, float, float]: """ 盡量從 skin_analysis 裡萃取一組 RGB: - {"color_rgb": [r,g,b]} ← 你現在 JSON 的主要來源 - {"skin_rgb": {"r":..., "g":..., "b":...}} - 其它 fallback。 找不到時回傳一個中性的膚色。 """ v = skin_info.get("color_rgb") if isinstance(v, (list, tuple)) and len(v) >= 3: try: r, g, b = v[:3] return float(r), float(g), float(b) except Exception: pass for key in ("skin_rgb", "skin_color_rgb", "avg_rgb", "rgb"): v = skin_info.get(key) if isinstance(v, dict) and all(ch in v for ch in ("r", "g", "b")): try: return float(v["r"]), float(v["g"]), float(v["b"]) except Exception: pass if isinstance(v, (list, tuple)) and len(v) >= 3: try: r, g, b = v[:3] return float(r), float(g), float(b) except Exception: pass for v in skin_info.values(): if isinstance(v, dict) and all(ch in v for ch in ("r", "g", "b")): try: return float(v["r"]), float(v["g"]), float(v["b"]) except Exception: continue if isinstance(v, (list, tuple)) and len(v) >= 3: try: r, g, b = v[:3] return float(r), float(g), float(b) except Exception: continue return 190.0, 164.0, 133.0 def filter_outfits(body_type: Optional[str], face_shape: Optional[str], gender: Optional[str], weather: Dict[str, Any]) -> List[dict]: """ 依體型 / 臉型 / 性別 / 氣溫,從 OUTFIT_LIBRARY 裡挑出候選。 """ temperature = weather.get("temperature") norm_gender = _normalize_gender(gender) candidates: List[dict] = [] for outfit in OUTFIT_LIBRARY: outfit_gender = _normalize_gender(outfit.get("gender")) if norm_gender and outfit_gender and outfit_gender != norm_gender: continue outfit_body_types = outfit.get("body_types") or [] if body_type and outfit_body_types and body_type not in outfit_body_types: continue outfit_face_shapes = outfit.get("face_shapes") or [] if face_shape and outfit_face_shapes and face_shape not in outfit_face_shapes: continue if isinstance(temperature, (int, float)): min_temp = outfit.get("min_temp") max_temp = outfit.get("max_temp") if isinstance(min_temp, (int, float)) and temperature < float(min_temp): continue if isinstance(max_temp, (int, float)) and temperature > float(max_temp): continue candidates.append(outfit) if not candidates: candidates = list(OUTFIT_LIBRARY) return candidates def get_color_combos_for_user(report: dict, gender: Optional[str] = None) -> List[Tuple[str, str]]: """ 根據 skin_analysis 中的 skin_tone_type / skin_tone_name, 從 colors.json 的 palettes 裡挑出這個人的顏色搭配候選。 支援性別區分:會優先查找 `{gender}_{type}` 的 key。 """ skin = report.get("skin_analysis", {}) or {} tone_type = skin.get("skin_tone_type") tone_name = skin.get("skin_tone_name") norm_gender = _normalize_gender(gender) palette_key: Optional[str] = None if norm_gender and isinstance(tone_type, str): key_with_gender = f"{norm_gender}_{tone_type}" if key_with_gender in SKIN_TONE_TO_PALETTE: palette_key = SKIN_TONE_TO_PALETTE[key_with_gender] if not palette_key and isinstance(tone_type, str) and tone_type in SKIN_TONE_TO_PALETTE: palette_key = SKIN_TONE_TO_PALETTE[tone_type] if not palette_key and norm_gender and isinstance(tone_name, str): key_with_gender = f"{norm_gender}_{tone_name}" if key_with_gender in SKIN_TONE_TO_PALETTE: palette_key = SKIN_TONE_TO_PALETTE[key_with_gender] if not palette_key and isinstance(tone_name, str) and tone_name in SKIN_TONE_TO_PALETTE: palette_key = SKIN_TONE_TO_PALETTE[tone_name] print(f"[Recommender] Skin Type: {tone_type}, Gender: {norm_gender} -> Palette Key: {palette_key}") combos: List[Tuple[str, str]] = [] if palette_key and palette_key in PALETTES: palette = PALETTES[palette_key] for entry in palette.get("combos", []): top = entry.get("top") bottoms = entry.get("bottoms", []) if not top or not bottoms: continue for b in bottoms: if top in COLOR_DB and b in COLOR_DB: combos.append((top, b)) if not combos and COLOR_DB: print("[Recommender] 找不到對應色盤或 combos 為空,使用全顏色 fallback。") names = list(COLOR_DB.keys()) for i, c1 in enumerate(names): for c2 in names[i + 1:]: combos.append((c1, c2)) return combos[:TOP_K_COLOR_COMBOS] # ============================================================ # prompt helper(新版:prompt_items_en) # ============================================================ def _safe_format_prompt(s: str, top_color_en: str, bottom_color_en: str) -> str: """ 安全 format:允許字串沒有 placeholder,也允許只用其中一種。 """ top_color_en = top_color_en.replace(" ", "-") bottom_color_en = bottom_color_en.replace(" ", "-") try: return s.format(top_color_en=top_color_en, bottom_color_en=bottom_color_en) except KeyError as e: # 如果 outfits.json 不小心寫了別的 placeholder 名稱,避免整套爆掉 print(f"[Recommender] prompt format 缺少 placeholder:{e},原字串:{s}") return s except Exception as e: print(f"[Recommender] prompt format 失敗:{e},原字串:{s}") return s def _build_prompt_items_en(outfit: dict, top_color_en: str, bottom_color_en: str) -> PromptItemsEN: """ 優先使用 outfits.json 的 prompt_items_en(你新定義的結構)。 若沒有,才回退到 prompt_template_en,包成 {"full": [...]}。 """ prompt_items = outfit.get("prompt_items_en") # ✅ 新格式:dict[str, list[str]] if isinstance(prompt_items, dict): out: PromptItemsEN = {} for part, arr in prompt_items.items(): if not isinstance(part, str): continue if isinstance(arr, list): formatted = [] for x in arr: if not isinstance(x, str): continue formatted.append(_safe_format_prompt(x, top_color_en, bottom_color_en).strip()) if formatted: out[part] = formatted if out: return out # 🔁 舊格式 fallback:prompt_template_en(整句) prompts_en: List[str] = [] for tmpl in outfit.get("prompt_template_en", []): if not isinstance(tmpl, str): continue prompts_en.append(_safe_format_prompt(tmpl, top_color_en, bottom_color_en).strip()) if not prompts_en: prompts_en = [ f"{top_color_en} top with {bottom_color_en} bottom", f"outfit with {top_color_en} upper garment and {bottom_color_en} lower garment", f"{top_color_en} and {bottom_color_en} color-coordinated outfit", ] seen = set() prompts_en = [x for x in prompts_en if not (x in seen or seen.add(x))] return {"full": prompts_en[:3]} def _outfit_uses_dress_color(outfit: dict) -> bool: """判斷此 outfit 是否為 dress 單件式輸出。""" allowed_dress_colors = outfit.get("allowed_dress_colors") or [] if allowed_dress_colors: return True prompt_items = outfit.get("prompt_items_en") if isinstance(prompt_items, dict): dress_items = prompt_items.get("dress") if isinstance(dress_items, list) and dress_items: return True return False def _color_allowed_by_rules( color_name: str, allowed_colors: List[str], allowed_tags: List[str], ) -> bool: """ 顏色檢查規則: 1. 若 outfit 有明確 allowed_*_colors,優先用顯式顏色名單。 2. 否則回退到 *_color_tags。 3. 若兩者都沒填,視為可用。 """ if allowed_colors: return color_name in allowed_colors if not allowed_tags: return True if _is_neutral_color(color_name): return True color_info = COLOR_DB.get(color_name) if not color_info: return False color_tags = color_info.get("tags") or [] return any(tag in allowed_tags for tag in color_tags) def _weighted_pick_by_score(candidates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: """ 從少量高分候選中做加權隨機: - 分數越高越容易被選到 - 但不會永遠只選第一名 使用 sqrt 壓縮分數差,避免第一名權重過大。 """ if not candidates: return None if len(candidates) == 1: return candidates[0] weights: List[float] = [] for item in candidates: score = max(float(item.get("score", 0.0)), 0.0) weights.append(math.sqrt(score) + 1e-6) return random.choices(candidates, weights=weights, k=1)[0] # ============================================================ # 主函式:run_recommend_model # ============================================================ def run_recommend_model(report: dict, weather: Dict[str, Any]) -> ClotheJSON: """ 推薦邏輯(回傳新版 ClotheJSON): - 每個 clothe_id 對應一套 outfit - 每套 outfit 內是「部位 -> prompts(list[str])」 """ # -------- 1) 從 report 抽資訊 -------- body_type = ( report.get("body_type") or report.get("body_shape_analysis", {}).get("body_shape_type") ) face_shape = report.get("face_analysis", {}).get("face_shape") gender = ( report.get("body_gender") or report.get("gender") or report.get("sex") ) skin_info = report.get("skin_analysis", {}) or {} if not isinstance(skin_info, dict): skin_info = {} r, g, b = extract_skin_rgb(skin_info) skin_hsl = rgb_to_hsl(r, g, b) # -------- 2) 篩選候選款式 -------- outfit_candidates = filter_outfits(body_type, face_shape, gender, weather) if not outfit_candidates: print("[Recommender] OUTFIT_LIBRARY 為空或篩選後沒有任何款式。") return {} # -------- 3) 取得這個人的顏色搭配候選 -------- color_combos = get_color_combos_for_user(report, gender) if not color_combos: print("[Recommender] 沒有任何顏色搭配候選,請檢查 colors.json。") return {} # -------- 4) 對每一組顏色搭配計算分數 -------- combo_scores: List[Tuple[float, Tuple[str, str]]] = [] for name1, name2 in color_combos: color1 = COLOR_DB.get(name1) color2 = COLOR_DB.get(name2) if not color1 or not color2: continue hsl1 = color1["hsl"] hsl2 = color2["hsl"] s_sim, s_comp, s_cont = color_harmony_scores_for_combo([hsl1, hsl2]) s_skin = skin_compatibility_for_outfit(skin_hsl, [hsl1, hsl2]) neutral_bonus = 0.0 n1 = _is_neutral_color(name1) n2 = _is_neutral_color(name2) if n1 or n2: neutral_bonus += NEUTRAL_COLOR_BONUS if n1 and n2: neutral_bonus += DOUBLE_NEUTRAL_EXTRA_BONUS s_color = max(s_sim, s_comp, s_cont) + ALPHA_SKIN * s_skin + neutral_bonus combo_scores.append((s_color, (name1, name2))) if not combo_scores: print("[Recommender] 所有顏色搭配都無法計算分數,可能是 COLOR_DB 空的。") return {} combo_scores.sort(key=lambda x: x[0], reverse=True) dress_color_score_map: Dict[str, float] = {} for score, (name1, name2) in combo_scores: dress_color_score_map[name1] = max(dress_color_score_map.get(name1, float("-inf")), score) dress_color_score_map[name2] = max(dress_color_score_map.get(name2, float("-inf")), score) dress_color_scores: List[Tuple[float, str]] = sorted( ((score, color_name) for color_name, score in dress_color_score_map.items()), key=lambda x: x[0], reverse=True, ) # -------- 5) 先找出每個 outfit 可用的最佳顏色,再挑整體最高分 -------- results: ClotheJSON = {} outfit_matches: List[Dict[str, Any]] = [] for outfit in outfit_candidates: allowed_top_tags = outfit.get("top_color_tags") or [] allowed_bottom_tags = outfit.get("bottom_color_tags") or [] allowed_top_colors = outfit.get("allowed_top_colors") or [] allowed_bottom_colors = outfit.get("allowed_bottom_colors") or [] allowed_dress_colors = outfit.get("allowed_dress_colors") or [] if _outfit_uses_dress_color(outfit): dress_candidates: List[Dict[str, Any]] = [] for score, color_name in dress_color_scores: if not _color_allowed_by_rules(color_name, allowed_dress_colors, allowed_top_tags): continue dress_candidates.append({ "color_name": color_name, "score": score, }) if len(dress_candidates) >= PER_OUTFIT_COLOR_OPTIONS: break chosen_dress = _weighted_pick_by_score(dress_candidates) if not chosen_dress: continue chosen_color = str(chosen_dress["color_name"]) chosen_score = float(chosen_dress["score"]) color_info = COLOR_DB.get(chosen_color) if not color_info: continue outfit_matches.append({ "outfit": outfit, "score": chosen_score, "mode": "dress", "top_color_zh": chosen_color, "bottom_color_zh": chosen_color, "top_color_en": color_info["en"], "bottom_color_en": color_info["en"], "signature": ("dress", chosen_color), }) continue combo_candidates_for_outfit: List[Dict[str, Any]] = [] for score, (name1, name2) in combo_scores: if not _color_allowed_by_rules(name1, allowed_top_colors, allowed_top_tags): continue if not _color_allowed_by_rules(name2, allowed_bottom_colors, allowed_bottom_tags): continue combo_candidates_for_outfit.append({ "combo": (name1, name2), "score": score, }) if len(combo_candidates_for_outfit) >= PER_OUTFIT_COLOR_OPTIONS: break chosen_combo_entry = _weighted_pick_by_score(combo_candidates_for_outfit) if not chosen_combo_entry: continue chosen_combo = tuple(chosen_combo_entry["combo"]) chosen_score = float(chosen_combo_entry["score"]) color1 = COLOR_DB.get(chosen_combo[0]) color2 = COLOR_DB.get(chosen_combo[1]) if not color1 or not color2: continue outfit_matches.append({ "outfit": outfit, "score": chosen_score, "mode": "combo", "top_color_zh": chosen_combo[0], "bottom_color_zh": chosen_combo[1], "top_color_en": color1["en"], "bottom_color_en": color2["en"], "signature": ("combo", chosen_combo[0], chosen_combo[1]), }) if not outfit_matches: print("[Recommender] 沒有任何 outfit 成功配到顏色組合(可能是 allowed colors / color_tags 規則太嚴)。") return {} outfit_matches.sort(key=lambda item: item["score"], reverse=True) print(f"[Recommender] 已為 {len(outfit_matches)} 個候選款式找到可用顏色,開始挑選前 {MAX_OUTFITS} 套。") selected_matches: List[Dict[str, Any]] = [] used_signatures = set() used_primary_colors = set() remaining_matches = list(outfit_matches) while remaining_matches and len(selected_matches) < MAX_OUTFITS: eligible_matches = [ match for match in remaining_matches if match["signature"] not in used_signatures and match["top_color_zh"] not in used_primary_colors ] if not eligible_matches: eligible_matches = [ match for match in remaining_matches if match["signature"] not in used_signatures ] if not eligible_matches: break selection_pool = eligible_matches[:min(FINAL_OUTFIT_SELECTION_POOL, len(eligible_matches))] chosen_match = _weighted_pick_by_score(selection_pool) if not chosen_match: break selected_matches.append(chosen_match) used_signatures.add(chosen_match["signature"]) used_primary_colors.add(chosen_match["top_color_zh"]) remaining_matches = [match for match in remaining_matches if match is not chosen_match] if len(selected_matches) < MAX_OUTFITS: print( f"[Recommender] 放寬主色不可重複後,仍只選到 {len(selected_matches)} 套;" "若要更多變化,可再放寬 allowed colors。" ) for outfit_assigned, match in enumerate(selected_matches, start=1): outfit = match["outfit"] top_color_zh = match["top_color_zh"] bottom_color_zh = match["bottom_color_zh"] top_color_en = match["top_color_en"] bottom_color_en = match["bottom_color_en"] chosen_score = float(match["score"]) desc_zh = outfit.get("desc_zh", "") if match["mode"] == "dress": zh_desc = f"{top_color_zh}色{desc_zh}".strip() elif "+" in desc_zh: left, right = [part.strip() for part in desc_zh.split("+", 1)] zh_desc = f"{top_color_zh}色{left} + {bottom_color_zh}色{right}" else: zh_desc = f"{top_color_zh}色 / {bottom_color_zh}色 {desc_zh}".strip() prompt_items_en = _build_prompt_items_en(outfit, top_color_en, bottom_color_en) outfit_id = str(outfit.get("id", "O")) clothe_id = f"{outfit_id}_{outfit_assigned:02d}" results[clothe_id] = prompt_items_en if match["mode"] == "dress": print( f"[Recommender] 推薦 {clothe_id}: {zh_desc} | " f"dress_color={top_color_zh}, score={chosen_score:.2f}" ) else: print( f"[Recommender] 推薦 {clothe_id}: {zh_desc} | " f"colors=({top_color_zh}, {bottom_color_zh}), score={chosen_score:.2f}" ) if not results: print("[Recommender] 沒有任何 outfit 成功產生 prompt。") return results """ # === 目前先回傳你提供的 6 句描述(兩件洋裝) === dress_1_prompts = [ "long red sleeveless dress", "red floor-length dress", "solid red long dress", ] dress_2_prompts = [ "cream dress", "natural sleeveless v-neck dress", "sleevless beige dress", # 保留你原本的拼字 ] clothe_json: ClotheJSON = { "000001": dress_1_prompts, "000002": dress_2_prompts, } return clothe_json """