recommender / recommender.py
zhenyuxyz's picture
Upload 7 files
54b590a verified
# 檔名: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
"""