| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | from __future__ import annotations
|
| |
|
| | import argparse
|
| | import json
|
| | import sys
|
| | from pathlib import Path
|
| | from typing import Any, Dict, Optional
|
| |
|
| |
|
| | from recommender import (
|
| | ALPHA_SKIN,
|
| | COLOR_DB,
|
| | DOUBLE_NEUTRAL_EXTRA_BONUS,
|
| | NEUTRAL_COLOR_BONUS,
|
| | _is_neutral_color,
|
| | color_harmony_scores_for_combo,
|
| | extract_skin_rgb,
|
| | get_color_combos_for_user,
|
| | rgb_to_hsl,
|
| | run_recommend_model,
|
| | skin_compatibility_for_outfit,
|
| | )
|
| | from body_type_classifier import classify_male_body_type, classify_female_body_type
|
| |
|
| |
|
| | DEFAULT_GENDER = "male"
|
| |
|
| |
|
| | def extract_gender(report: Dict[str, Any]) -> Optional[str]:
|
| | """
|
| | 從 report 抓性別。找不到就回 DEFAULT_GENDER。
|
| | """
|
| | gender = (
|
| | report.get("gender")
|
| | or report.get("sex")
|
| | or report.get("Gender")
|
| | or report.get("user_gender")
|
| | or report.get("body_gender")
|
| | )
|
| |
|
| | if isinstance(gender, str):
|
| | g = gender.strip().lower()
|
| | if g in ("male", "m", "boy", "man", "男", "男性"):
|
| | return "male"
|
| | if g in ("female", "f", "girl", "woman", "女", "女性"):
|
| | return "female"
|
| |
|
| |
|
| | if DEFAULT_GENDER is not None:
|
| | print(f"[BodyType] report 中沒有可用的 gender 欄位,暫時使用 DEFAULT_GENDER={DEFAULT_GENDER}")
|
| | return DEFAULT_GENDER
|
| |
|
| | return None
|
| |
|
| |
|
| | def extract_body_measurements(report: Dict[str, Any]) -> Optional[Dict[str, float]]:
|
| | """
|
| | 從 report 取得 body_measurements dict。
|
| | 你提供的 request JSON 結構是 report["body_measurements"] = {...}
|
| | """
|
| | bm = report.get("body_measurements")
|
| | if isinstance(bm, dict) and bm:
|
| | return bm
|
| | return None
|
| |
|
| |
|
| | def attach_body_type(report: Dict[str, Any]) -> None:
|
| | """
|
| | 先依 body_measurements + gender 判斷身形,寫回 report:
|
| | report["body_type"]
|
| | report["body_gender"]
|
| | """
|
| | body_measurements = extract_body_measurements(report)
|
| | gender = extract_gender(report)
|
| |
|
| | if not body_measurements or not gender:
|
| | print("[BodyType] 無法判斷:缺少 body_measurements 或 gender(且 DEFAULT_GENDER=None)")
|
| | return
|
| |
|
| | try:
|
| | if gender == "male":
|
| | body_type = classify_male_body_type(body_measurements)
|
| | else:
|
| | body_type = classify_female_body_type(body_measurements)
|
| |
|
| | print(f"[BodyType] gender={gender} → body_type={body_type}")
|
| |
|
| | report["body_type"] = body_type
|
| | report["body_gender"] = gender
|
| |
|
| | except Exception as e:
|
| | print(f"[BodyType] 判斷身形時發生錯誤: {e}")
|
| |
|
| |
|
| | def pick_default_request_file(base_dir: Path) -> Path:
|
| | """
|
| | 不帶參數時:自動挑 saved_requests/ 裡最新修改的 .json
|
| | """
|
| | req_dir = base_dir / "saved_requests"
|
| | if not req_dir.exists():
|
| | raise FileNotFoundError(f"找不到資料夾:{req_dir}")
|
| |
|
| | candidates = sorted(req_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
| | if not candidates:
|
| | raise FileNotFoundError(f"{req_dir} 底下沒有任何 .json 檔可以測試")
|
| | return candidates[0]
|
| |
|
| |
|
| | def load_request_json(path: Path) -> Dict[str, Any]:
|
| | with path.open("r", encoding="utf-8") as f:
|
| | data = json.load(f)
|
| | if not isinstance(data, dict):
|
| | raise ValueError("request JSON 的最外層必須是 dict")
|
| | return data
|
| |
|
| |
|
| | def print_top10_color_combos(report: Dict[str, Any]) -> None:
|
| | """
|
| | 印出該使用者顏色組合前 10 名(含分數)。
|
| | 分數公式與 recommender 內一致:
|
| | S_color = max(S_sim, S_comp, S_cont) + ALPHA_SKIN * S_skin + neutral_bonus
|
| | """
|
| | 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)
|
| |
|
| | color_combos = get_color_combos_for_user(report, gender)
|
| | if not color_combos:
|
| | print("[ColorDebug] 沒有可用的顏色組合。")
|
| | return
|
| |
|
| | scored = []
|
| | for top_zh, bottom_zh in color_combos:
|
| | c1 = COLOR_DB.get(top_zh)
|
| | c2 = COLOR_DB.get(bottom_zh)
|
| | if not c1 or not c2:
|
| | continue
|
| |
|
| | hsl1 = c1["hsl"]
|
| | hsl2 = c2["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
|
| | if _is_neutral_color(top_zh) or _is_neutral_color(bottom_zh):
|
| | neutral_bonus += NEUTRAL_COLOR_BONUS
|
| | if _is_neutral_color(top_zh) and _is_neutral_color(bottom_zh):
|
| | neutral_bonus += DOUBLE_NEUTRAL_EXTRA_BONUS
|
| |
|
| | s_color = max(s_sim, s_comp, s_cont) + ALPHA_SKIN * s_skin + neutral_bonus
|
| | scored.append((s_color, top_zh, bottom_zh, c1["en"], c2["en"]))
|
| |
|
| | if not scored:
|
| | print("[ColorDebug] 顏色組合分數計算失敗。")
|
| | return
|
| |
|
| | scored.sort(key=lambda x: x[0], reverse=True)
|
| | print("[ColorDebug] Top 10 顏色組合(含分數):")
|
| | for rank, (score, top_zh, bottom_zh, top_en, bottom_en) in enumerate(scored[:20], start=1):
|
| | print(
|
| | f" {rank:02d}. ({top_zh}/{top_en}) + ({bottom_zh}/{bottom_en}) "
|
| | f"=> score={score:.2f}"
|
| | )
|
| |
|
| |
|
| | def main() -> int:
|
| | base_dir = Path(__file__).resolve().parent
|
| |
|
| | parser = argparse.ArgumentParser(description="純 Python 測試:先判斷身形,再跑推薦,輸出到 output/")
|
| | parser.add_argument(
|
| | "input",
|
| | nargs="?",
|
| | help="request JSON 路徑(例如 saved_requests/request_xxx.json)。不填則自動抓 saved_requests/ 最新檔。",
|
| | )
|
| | args = parser.parse_args()
|
| |
|
| |
|
| | if args.input:
|
| | input_path = (base_dir / args.input).resolve() if not Path(args.input).is_absolute() else Path(args.input)
|
| | else:
|
| | input_path = pick_default_request_file(base_dir)
|
| |
|
| | if not input_path.exists():
|
| | print(f"[Error] 找不到輸入檔:{input_path}")
|
| | return 2
|
| |
|
| | print(f"[Test] 使用輸入檔:{input_path}")
|
| |
|
| |
|
| | data = load_request_json(input_path)
|
| |
|
| |
|
| | if "report" in data and "weather" in data:
|
| | report = data["report"]
|
| | weather = data["weather"]
|
| | else:
|
| | raise ValueError("request JSON 必須包含 'report' 與 'weather' 兩個 key(最外層)")
|
| |
|
| | if not isinstance(report, dict):
|
| | raise ValueError("'report' 必須是 dict")
|
| | if not isinstance(weather, dict):
|
| | raise ValueError("'weather' 必須是 dict")
|
| |
|
| |
|
| | attach_body_type(report)
|
| |
|
| |
|
| | print_top10_color_combos(report)
|
| |
|
| |
|
| | result = run_recommend_model(report, weather)
|
| |
|
| |
|
| | out_dir = base_dir / "output"
|
| | out_dir.mkdir(exist_ok=True)
|
| |
|
| |
|
| | stem = input_path.stem
|
| | out_path = out_dir / f"{stem}_output.json"
|
| |
|
| | with out_path.open("w", encoding="utf-8") as f:
|
| | json.dump(result, f, ensure_ascii=False, indent=2)
|
| |
|
| | print(f"[Test] 推薦結果已輸出:{out_path}")
|
| | print("[Test] 預覽(前幾項):")
|
| |
|
| | preview_items = list(result.items())[:3]
|
| | print(json.dumps(dict(preview_items), ensure_ascii=False, indent=2))
|
| |
|
| | return 0
|
| |
|
| |
|
| | if __name__ == "__main__":
|
| | try:
|
| | raise SystemExit(main())
|
| | except Exception as e:
|
| | print(f"[Fatal] 測試失敗:{e}")
|
| | raise
|
| |
|