fengchen31's picture
Sync from GitHub refs/heads/main @ d60d159
5d2a335 verified
Raw
History Blame Contribute Delete
26.6 kB
"""虛擬試穿服務 — 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")