"""虛擬試穿服務 — Multi-provider 架構(透過 ModelRegistry) 【這個模組在做什麼?】 接收使用者照片 + 衣服照片,產出試穿合成圖。 Provider 設定從 ModelRegistry 的 models.yaml 讀取,由 TRYON_PROVIDER 環境變數切換。 【Provider 說明】 leffa(預設): Meta Leffa via HF Space franciszzj/Leffa,MIT 商用安全 huggingface: HuggingFace Inference API 跑 IDM-VTON(免費層,受 CC BY-NC-SA 約束) replicate: Replicate IDM-VTON API,$0.023/次 fashn: Fashn.ai API,商用安全(無 CC BY-NC-SA 限制) local: 本地 diffusers pipeline(需 GPU,學習/研究用) mock: 開發用假 URL 【授權警告】 IDM-VTON 是 CC BY-NC-SA 4.0(非商用)—— huggingface / replicate / local 三條路都受約束。 Leffa 是 MIT(商用安全),預設選用。 Fashn.ai 另有商用授權,作為商業級替代。 【ModelRegistry 整合】 tryon capability → idm-vton model(provider: external)—— model 名稱是歷史命名, 實際上 variants 裡混合多個 VTON 模型(IDM-VTON / Leffa / Fashn)。 active_variant 由 TRYON_PROVIDER 環境變數決定(預設 leffa)。 """ from __future__ import annotations import asyncio import logging import os import httpx from pydantic import BaseModel logger = logging.getLogger(__name__) class TryOnResult(BaseModel): result_image_url: str provider: str = "replicate_idm_vton" # ============================================================================ # ModelRegistry 整合:讀取 active_variant 和 variant config # ============================================================================ async def _get_tryon_config() -> tuple[str, dict]: """ 從 ModelRegistry 取得試穿設定。 【回傳值】 (active_variant, variant_config) - active_variant:當前選用的 provider 名稱(huggingface/replicate/fashn/local/mock) - variant_config:該 provider 的設定 dict(來自 models.yaml idm-vton.variants) 【Fallback 邏輯】 若 ModelRegistry 未初始化(例如單元測試),直接讀取環境變數。 """ try: from src.registry import get_registry reg = get_registry() # 讀取 capability 設定 cap_config = reg.get_capability_config("tryon") # active_variant 由 cap_config["active_variant"] 取得, # 這個值在 models.yaml 中定義為 "${TRYON_PROVIDER:-leffa}"。 # Fallback 一律用 leffa(商用安全)——IDM-VTON 的 huggingface 變體是 CC BY-NC-SA, # 任何「schema drift 或 registry race 時偷偷走回舊路徑」的縫隙都違反 F1 意圖。 active_variant = cap_config.get("active_variant", "leffa") # 讀取 idm-vton model 設定(含 variants) model_config = reg.get_model_config(cap_config["model"]) variant_config = model_config.get("variants", {}).get(active_variant, {}) return active_variant, variant_config except Exception: # Fallback:registry 未初始化或 raise 時——仍預設 leffa,避免跳到 huggingface # (= IDM-VTON CC BY-NC-SA)違反 F1 的「commercial-safe by default」意圖。 provider = os.environ.get("TRYON_PROVIDER", "leffa") return provider, {} async def run_tryon(user_photo_url: str, garment_image_url: str) -> TryOnResult: """ 虛擬試穿入口。根據 ModelRegistry 設定的 active_variant 分發到對應 provider。 【Provider 選擇邏輯】 leffa → HF Space franciszzj/Leffa(MIT,預設,商用安全) huggingface → HuggingFace Inference API(需 HF_TOKEN,免費) replicate → Replicate API(需 REPLICATE_API_TOKEN,付費) fashn → Fashn.ai API(需 FASHN_API_KEY,付費,商用安全) local → 本地 diffusers(需 GPU + 模型下載) mock → 開發用假 URL 其他/無 key → mock """ # 從 registry 讀取 active_variant(而非直接讀環境變數) active_variant, variant_config = await _get_tryon_config() if active_variant == "leffa": return await _leffa_tryon(user_photo_url, garment_image_url, variant_config) elif active_variant == "huggingface": return await _huggingface_tryon( user_photo_url, garment_image_url, variant_config ) elif active_variant == "fashn": return await _fashn_tryon(user_photo_url, garment_image_url, variant_config) elif active_variant == "local": return await _local_tryon(user_photo_url, garment_image_url) elif active_variant == "mock": return _mock_tryon(user_photo_url, garment_image_url) else: # replicate (fallback) return await _replicate_tryon(user_photo_url, garment_image_url, variant_config) # ============================================================================ # Leffa Provider(HF Space franciszzj/Leffa — MIT 商用安全,預設) # ============================================================================ async def _leffa_tryon( user_photo_url: str, garment_image_url: str, variant_config: dict, ) -> TryOnResult: """ Leffa — 透過 HuggingFace Space `franciszzj/Leffa` 跑 SOTA 虛擬試穿。 【為什麼用 Leffa?】 Meta 2024 年的模型(CVPR 2025,arXiv:2412.08486),MIT 授權——解開 IDM-VTON CC BY-NC-SA 商用禁令。VITON-HD 成績贏過 IDM-VTON / OOTDiffusion, 基於 SD-1.5 UNet 較輕(相對於 IDM-VTON 雙 pass),A100 約 6s/image; 走 HF Space 共享 GPU 時 ~20-40s,冷啟動可能要多等 30-60s。 【執行位置】 呼叫 Space 託管的 Gradio endpoint—— 完全不用本地 GPU。Space 閒置會睡, 第一次呼叫會等 ZeroGPU 喚醒。 【variant_config(來自 models.yaml idm-vton.variants.leffa)】 space_id: "franciszzj/Leffa" api_name: "/leffa_predict_vt"(另一個 api_name 是 /leffa_predict_pt 做姿態轉換) model_type: "viton_hd" | "dress_code" garment_type: "upper_body" | "lower_body" | "dresses" step: 30(30-100,越高越細節但越慢) scale: 2.5(CFG,0.1-5.0) seed: 固定值可重現,或 -1 隨機 【輸出】 Space 回傳 tuple (generated_image, mask, densepose),我們只取第一張。 gradio_client 把檔案下載到本地 temp 路徑,需讀成 base64 data URI 回前端。 【async 規範】 gradio_client 內部是 blocking HTTP + SSE polling,用 asyncio.to_thread 包起來 避免凍結 event loop(遵守 ml-service async discipline rule)。 """ # SSRF guard: `handle_file(url)` 會在 gradio_client 內部對 URL 發 HTTP GET # 把檔案拉下來存成 local temp path,這筆下載發生在 ml-service 進程內。所以 # 任何未經驗證的 userPhotoUrl / garmentImageUrl 都是一條 SSRF vector,可達 # 169.254.169.254 / 127.0.0.1 / Redis 等。hub-service 的 /tryon route # 目前只跑 z.string().url()(不是 isSafeContentUrl),因此這道檢查必須 # 在呼叫 handle_file 之前做。任一 URL 被擋 = RuntimeError → worker 寫 # tryonJobs.status="failed" + 通知使用者。 from src.ssrf import validate_image_url try: # offload: validate_image_url runs blocking socket.getaddrinfo (ML-03); # called from async _leffa_tryon, must to_thread or event loop stalls. await asyncio.to_thread(validate_image_url, user_photo_url) await asyncio.to_thread(validate_image_url, garment_image_url) except ValueError as e: raise RuntimeError(f"Leffa refused unsafe image URL: {e}") from e # 放 closure 內延遲 import—— 避免 ml-service 啟動時因 gradio_client 初始化成本 # 影響 /health 回應(ml-service async discipline rule 的 C-extension pattern) from gradio_client import Client, handle_file space_id = variant_config.get("space_id", "franciszzj/Leffa") api_name = variant_config.get("api_name", "/leffa_predict_vt") model_type = variant_config.get("model_type", "viton_hd") garment_type = variant_config.get("garment_type", "upper_body") step = int(variant_config.get("step", 30)) scale = float(variant_config.get("scale", 2.5)) seed = int(variant_config.get("seed", 42)) # HF token:Space 公開可匿名呼叫,但帶 token 可用 Pro 額度避免排隊 hf_token = os.environ.get("HUGGINGFACE_TOKEN", "") or os.environ.get("HF_TOKEN", "") def _blocking() -> str: """Gradio client 同步呼叫——包進 to_thread 跑。""" client_kwargs: dict = {} if hf_token: client_kwargs["hf_token"] = hf_token client = Client(space_id, **client_kwargs) # handle_file(url) 把 URL 下載到 temp 檔,符合 Gradio file component 輸入格式。 # 注意:參數名是 `src_image_path` / `ref_image_path`(Space 的實際 kwarg,不是 `src_image`)。 # ref_acceleration / vt_repaint 是 Radio[True, False],view_api 顯示的 Literal['True','False'] # 是 **label** 文字,實際 choices 收的是 Python bool。 result = client.predict( src_image_path=handle_file(user_photo_url), ref_image_path=handle_file(garment_image_url), ref_acceleration=False, step=step, scale=scale, seed=seed, vt_model_type=model_type, vt_garment_type=garment_type, vt_repaint=False, api_name=api_name, ) # Leffa 回 tuple (generated_image, mask, densepose),取第一張 first = result[0] if isinstance(result, (list, tuple)) else result # gradio_client 可能回 local temp path string 或 {"path": ..., "url": ...} dict if isinstance(first, dict): return str(first.get("url") or first.get("path") or "") return str(first) logger.info( "Leffa via HF Space %s (model=%s, garment=%s, step=%d, scale=%.1f)", space_id, model_type, garment_type, step, scale, ) try: result_path = await asyncio.to_thread(_blocking) except Exception as e: logger.error("Leffa HF Space call failed: %s", e) raise RuntimeError( f"Leffa HF Space request failed ({type(e).__name__}): {e}. " "Check space availability at https://huggingface.co/spaces/franciszzj/Leffa " "or switch TRYON_PROVIDER to fashn/mock." ) from e if not result_path: raise RuntimeError("Leffa returned empty result path") # 遠端 URL 直接回傳;local temp file 轉 data URI 讓前端直接顯示 if result_path.startswith(("http://", "https://")): result_url = result_path else: import base64 import mimetypes with open(result_path, "rb") as f: img_bytes = f.read() # Gradio 會存成 .webp / .png 等,依副檔名決定 mime;未知則回 webp(Leffa 現況) mime, _ = mimetypes.guess_type(result_path) mime = mime or "image/webp" b64 = base64.b64encode(img_bytes).decode("utf-8") result_url = f"data:{mime};base64,{b64}" return TryOnResult(result_image_url=result_url, provider="leffa_hf_space") # ============================================================================ # HuggingFace Inference API Provider(免費,CC BY-NC-SA 非商用) # ============================================================================ async def _huggingface_tryon( user_photo_url: str, garment_image_url: str, variant_config: dict, ) -> TryOnResult: """ HuggingFace Inference API — 免費層虛擬試穿。 【學習重點:HuggingFace Inference API 的運作方式】 HuggingFace 提供免費的 Inference API,讓你不用自己架 GPU 就能呼叫模型。 原理是 HuggingFace 在雲端幫你跑模型推論(inference),你只要 HTTP POST 就好。 【API 流程】 1. 取得 HF_TOKEN(在 https://huggingface.co/settings/tokens 建立,免費) 2. POST JSON 到 https://api-inference.huggingface.co/models/{model_id} 3. Header 帶 Authorization: Bearer {token} 4. Body 放模型需要的輸入參數(每個模型不同) 【免費層限制】 - 模型可能需要「冷啟動」(cold start):第一次呼叫要等模型載入(~30-60s) - API 回 503 + "estimated_time" 表示模型正在載入,需要重試 - 免費層有 rate limit(約 30 req/min),超過會被暫時限流 - 大模型(如 IDM-VTON)可能需要 Pro 帳號才能穩定使用 【授權注意】 IDM-VTON 是 CC BY-NC-SA 4.0,透過 HF Inference API 呼叫同樣受此約束。 商用請改用 fashn provider。 【ModelRegistry 參數】 variant_config 來自 models.yaml idm-vton.variants.huggingface: - model_id: "yisol/IDM-VTON" """ # 嘗試讀取 HuggingFace token(支援兩種環境變數名稱) # HUGGINGFACE_TOKEN 是完整名稱,HF_TOKEN 是 HuggingFace CLI 的慣例 token = os.environ.get("HUGGINGFACE_TOKEN", "") or os.environ.get("HF_TOKEN", "") if not token: # 沒有 token 就 fallback 到 mock(不要讓服務掛掉) logger.warning( "HF_TOKEN / HUGGINGFACE_TOKEN not set, falling back to mock. " "Get a free token at https://huggingface.co/settings/tokens" ) return _mock_tryon(user_photo_url, garment_image_url) # 從 variant_config 取得 model_id,fallback 到預設值 model_id = variant_config.get("model_id", "yisol/IDM-VTON") # HuggingFace Inference API 的端點格式:固定前綴 + 模型 ID api_url = f"https://api-inference.huggingface.co/models/{model_id}" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } # 【學習重點:模型輸入格式】 # 不同模型接受的 JSON 結構不同,要看模型的 API 文件。 # IDM-VTON 需要人物照片(human_img)和衣服照片(garm_img)。 payload = { "inputs": { "human_img": user_photo_url, "garm_img": garment_image_url, }, } # 最多重試 3 次(處理模型冷啟動的 503 回應) max_retries = 3 async with httpx.AsyncClient(timeout=180.0) as client: for attempt in range(max_retries): logger.info( "HuggingFace Inference API attempt %d/%d for model %s", attempt + 1, max_retries, model_id, ) resp = await client.post(api_url, headers=headers, json=payload) # 【學習重點:503 = 模型正在載入(冷啟動)】 # HF 免費層的模型不會一直跑在 GPU 上,閒置一段時間後會被卸載。 # 第一次呼叫時 HF 要重新載入模型到 GPU,這段時間 API 回 503。 # 回應的 JSON 裡有 "estimated_time" 告訴你大概要等多久。 if resp.status_code == 503: body = resp.json() wait_time = body.get("estimated_time", 30.0) # 上限 60 秒,避免等太久 wait_time = min(float(wait_time), 60.0) logger.info( "Model is loading (cold start), waiting %.1fs before retry...", wait_time, ) await asyncio.sleep(wait_time) continue # 【學習重點:429 = 超過 rate limit】 # 免費層有請求頻率限制,超過時 API 回 429。 if resp.status_code == 429: logger.warning( "HuggingFace rate limit hit, waiting 10s before retry..." ) await asyncio.sleep(10) continue # 其他錯誤直接拋出 resp.raise_for_status() # 【學習重點:回應格式】 # HF Inference API 對圖片模型可能回傳: # (a) JSON:包含 base64 編碼的圖片或 URL # (b) 二進位:直接回傳圖片 bytes(Content-Type: image/...) # 我們需要根據 Content-Type 判斷回應類型。 content_type = resp.headers.get("content-type", "") if "application/json" in content_type: # JSON 回應:嘗試取出圖片 URL 或 base64 data = resp.json() if isinstance(data, list) and len(data) > 0: # 有些模型回傳陣列格式 result_url = str(data[0]) elif isinstance(data, dict): # 嘗試常見的回應欄位名稱 result_url = data.get( "output", data.get("image", data.get("url", "")) ) if not result_url: # 如果有 base64 資料,轉成 data URI b64 = data.get("generated_image", data.get("image_base64", "")) if b64: result_url = f"data:image/png;base64,{b64}" else: raise RuntimeError( f"HuggingFace returned unexpected JSON structure: {list(data.keys())}" ) else: result_url = str(data) return TryOnResult( result_image_url=result_url, provider="huggingface_idm_vton", ) elif content_type.startswith("image/"): # 二進位圖片回應:轉成 base64 data URI # 【學習重點:Data URI】 # 格式是 data:{mime_type};base64,{base64_data} # 瀏覽器可以直接顯示,不需要額外的圖片 URL import base64 img_b64 = base64.b64encode(resp.content).decode("utf-8") data_uri = f"data:{content_type};base64,{img_b64}" return TryOnResult( result_image_url=data_uri, provider="huggingface_idm_vton", ) else: raise RuntimeError( f"HuggingFace returned unexpected content-type: {content_type}" ) # 所有重試都失敗(通常是模型一直載入不完) raise RuntimeError( "HuggingFace Inference API failed after all retries. " "Model may be too large for free tier — consider upgrading to HF Pro " "or switching to TRYON_PROVIDER=replicate." ) # ============================================================================ # Replicate Provider # ============================================================================ async def _replicate_tryon( user_photo_url: str, garment_image_url: str, variant_config: dict, ) -> TryOnResult: """Replicate IDM-VTON — 直接 HTTP 呼叫(不用 replicate package)。 【學習重點:非同步 API 呼叫模式】 1. POST 建立 prediction(拿到 prediction ID) 2. 輪詢(polling)GET prediction 狀態,等 succeeded/failed 3. 用 asyncio.sleep 不阻塞 event loop(不用 time.sleep!) 4. httpx.AsyncClient 是 Python 的非同步 HTTP client(類似 Node 的 fetch) 【ModelRegistry 參數】 variant_config 來自 models.yaml idm-vton.variants.replicate: - model_id: "cuuupid/idm-vton" """ api_token = os.environ.get("REPLICATE_API_TOKEN", "") is_real_key = len(api_token) > 20 and not api_token.startswith("your") if not is_real_key: return _mock_tryon(user_photo_url, garment_image_url) # 註:replicate 用 version hash 鎖模型版本(payload["version"]),model_id # 欄位純 documentation 用途,因此 variant_config["model_id"] 不在此讀取。 headers = { "Authorization": f"Bearer {api_token}", "Content-Type": "application/json", } payload = { "version": "0513734a452173b8173e907e3a59d19a36266e55b48528559432bd21c7d7e985", "input": { "human_img": user_photo_url, "garm_img": garment_image_url, }, } async with httpx.AsyncClient(timeout=120.0) as client: resp = await client.post( "https://api.replicate.com/v1/predictions", headers=headers, json=payload, ) if resp.status_code == 402: raise RuntimeError( "Replicate billing required: add a payment method at https://replicate.com/account/billing" ) resp.raise_for_status() prediction = resp.json() poll_url = prediction["urls"]["get"] for _ in range(120): poll_resp = await client.get(poll_url, headers=headers) poll_resp.raise_for_status() data = poll_resp.json() status = data["status"] if status == "succeeded": output = data["output"] result_url = str(output[0]) if isinstance(output, list) else str(output) return TryOnResult( result_image_url=result_url, provider="replicate_idm_vton" ) elif status in ("failed", "canceled"): error_msg = data.get("error", "Unknown error") raise RuntimeError(f"Replicate prediction {status}: {error_msg}") await asyncio.sleep(1) raise RuntimeError("Replicate prediction timed out after 120s") # ============================================================================ # Fashn.ai Provider(商用安全替代) # ============================================================================ async def _fashn_tryon( user_photo_url: str, garment_image_url: str, variant_config: dict, ) -> TryOnResult: """ Fashn.ai — 商用安全的虛擬試穿 API。 【為什麼用 Fashn.ai?】 IDM-VTON 是 CC BY-NC-SA 4.0(非商用),商業化前必須換。 Fashn.ai 提供商用授權的試穿 API,品質接近 IDM-VTON。 API 文件:https://docs.fashn.ai 【ModelRegistry 參數】 variant_config 來自 models.yaml idm-vton.variants.fashn: - api_url: "https://api.fashn.ai/v1" """ api_key = os.environ.get("FASHN_API_KEY", "") if not api_key: logger.warning("FASHN_API_KEY not set, falling back to mock") return _mock_tryon(user_photo_url, garment_image_url) # 從 variant_config 讀取 API URL,fallback 到預設 api_url = variant_config.get("api_url", "https://api.fashn.ai/v1") async with httpx.AsyncClient(timeout=120.0) as client: # Fashn.ai run endpoint resp = await client.post( f"{api_url}/run", headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, json={ "model_image": user_photo_url, "garment_image": garment_image_url, "category": "auto", }, ) resp.raise_for_status() data = resp.json() # Poll for result pred_id = data.get("id", "") for _ in range(120): status_resp = await client.get( f"{api_url}/status/{pred_id}", headers={"Authorization": f"Bearer {api_key}"}, ) status_resp.raise_for_status() status_data = status_resp.json() if status_data.get("status") == "completed": return TryOnResult( result_image_url=status_data["output"], provider="fashn_ai", ) elif status_data.get("status") in ("failed", "canceled"): raise RuntimeError( f"Fashn.ai prediction failed: {status_data.get('error')}" ) await asyncio.sleep(1) raise RuntimeError("Fashn.ai prediction timed out") # ============================================================================ # Local Provider(學習/研究用,需 GPU) # ============================================================================ async def _local_tryon(user_photo_url: str, garment_image_url: str) -> TryOnResult: """ 本地 IDM-VTON — diffusers pipeline(需 NVIDIA GPU 16GB+)。 【學習重點:HuggingFace diffusers 的用法】 真正實作時的程式碼大致是: ```python from diffusers import AutoPipelineForImage2Image pipe = AutoPipelineForImage2Image.from_pretrained("yisol/IDM-VTON") pipe = pipe.to("cuda") # 需要 NVIDIA GPU result = pipe(person_image, garment_image) result.images[0].save("output.png") ``` 【注意】 - CC BY-NC-SA 4.0:僅限學習/研究,禁止商用 - 需要預先下載模型(~10GB) - 第一次推論較慢(模型載入 ~30s) 【誠實不可用 — feedback_no_fabricated_fallbacks】 本地 diffusers pipeline 從未實作(需租 GPU 實測後才填入真正的程式碼)。 在那之前,這個 provider 不可用——以前回傳假 mock URL 是捏造成功,會讓上層 以為試穿真的生成了。改成 raise RuntimeError(與 leffa/replicate/fashn 失敗時 一致的誠實路徑):上層 worker 收到後寫 tryonJobs.status="failed" + 通知使用者, HTTP 路徑回 500/503,前端走既有的 honest-unavailable 顯示而非假成功。 商用請用 leffa(預設)或 fashn provider。 """ raise RuntimeError( "Local IDM-VTON provider is not implemented (requires a GPU). " "Use the leffa (default) or fashn provider instead." ) # ============================================================================ # Mock(開發用) # ============================================================================ def _mock_tryon(user_photo_url: str, garment_image_url: str) -> TryOnResult: """開發用 mock:回傳假 URL。""" mock_id = abs(hash(user_photo_url + garment_image_url)) % 100000 mock_url = f"https://mock-tryon.wardrobe-os.dev/results/{mock_id}.jpg" return TryOnResult(result_image_url=mock_url, provider="mock")