|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| from __future__ import annotations
|
|
|
| from typing import Dict, List, Any, Tuple, Optional
|
| from pathlib import Path
|
| import json
|
| import math
|
| import colorsys
|
| import random
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| PromptItemsEN = Dict[str, List[str]]
|
|
|
|
|
| ClotheJSON = Dict[str, PromptItemsEN]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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_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 = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
| 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:
|
|
|
| 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:
|
|
|
| return rgb_to_hsl(128, 128, 128)
|
| return rgb_to_hsl(r, g, b)
|
|
|
|
|
|
|
| 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
|
| TOP_K_COLOR_COMBOS = 60
|
| MAX_OUTFITS = 3
|
| PER_OUTFIT_COLOR_OPTIONS = 20
|
| FINAL_OUTFIT_SELECTION_POOL = 10
|
|
|
|
|
| SIGMA_H_SKIN = 55.0
|
| SIGMA_L_SKIN = 0.28
|
| SIGMA_S_SKIN = 0.38
|
| MU_L_SKIN = 0.12
|
| MU_S_SKIN = 0.08
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
| 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")
|
|
|
|
|
| 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
|
|
|
|
|
| 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]
|
|
|
|
|
|
|
|
|
|
|
|
|
| def run_recommend_model(report: dict, weather: Dict[str, Any]) -> ClotheJSON:
|
| """
|
| 推薦邏輯(回傳新版 ClotheJSON):
|
| - 每個 clothe_id 對應一套 outfit
|
| - 每套 outfit 內是「部位 -> prompts(list[str])」
|
| """
|
|
|
|
|
| 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)
|
|
|
|
|
| outfit_candidates = filter_outfits(body_type, face_shape, gender, weather)
|
| if not outfit_candidates:
|
| print("[Recommender] OUTFIT_LIBRARY 為空或篩選後沒有任何款式。")
|
| return {}
|
|
|
|
|
| color_combos = get_color_combos_for_user(report, gender)
|
| if not color_combos:
|
| print("[Recommender] 沒有任何顏色搭配候選,請檢查 colors.json。")
|
| return {}
|
|
|
|
|
| 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,
|
| )
|
|
|
|
|
| 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
|
| """ |