File size: 32,323 Bytes
54b590a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 | # 檔名: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
""" |