save code
This commit is contained in:
@@ -0,0 +1,440 @@
|
|||||||
|
{
|
||||||
|
"1": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": [
|
||||||
|
"22",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"13",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "VAE解码"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"inputs": {
|
||||||
|
"to_ref": true,
|
||||||
|
"ref_main_image": false,
|
||||||
|
"ref_longest_edge": [
|
||||||
|
"20",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"ref_crop": "center",
|
||||||
|
"ref_upscale": "lanczos",
|
||||||
|
"to_vl": true,
|
||||||
|
"vl_resize": true,
|
||||||
|
"vl_target_size": 384,
|
||||||
|
"vl_crop": "center",
|
||||||
|
"vl_upscale": "bicubic",
|
||||||
|
"image": [
|
||||||
|
"10",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"configs": [
|
||||||
|
"8",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "QwenEditConfigPreparer",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Qwen Edit Config Preparer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"conditioning": [
|
||||||
|
"15",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "ConditioningZeroOut",
|
||||||
|
"_meta": {
|
||||||
|
"title": "条件零化"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"inputs": {
|
||||||
|
"custom_output": [
|
||||||
|
"15",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "QwenEditOutputExtractor",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Qwen Edit Output Extractor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"inputs": {
|
||||||
|
"to_ref": true,
|
||||||
|
"ref_main_image": true,
|
||||||
|
"ref_longest_edge": [
|
||||||
|
"9",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"ref_crop": "pad",
|
||||||
|
"ref_upscale": "lanczos",
|
||||||
|
"to_vl": true,
|
||||||
|
"vl_resize": true,
|
||||||
|
"vl_target_size": 384,
|
||||||
|
"vl_crop": "center",
|
||||||
|
"vl_upscale": "bicubic",
|
||||||
|
"image": [
|
||||||
|
"11",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "QwenEditConfigPreparer",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Qwen Edit Config Preparer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"max_size": [
|
||||||
|
"25",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"image": [
|
||||||
|
"11",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "QwenEditAdaptiveLongestEdge",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Qwen Edit Adaptive Longest Edge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"inputs": {
|
||||||
|
"image": "5d22eef9-c44c-437c-ab8b-a19c9137a5a1.webp"
|
||||||
|
},
|
||||||
|
"class_type": "LoadImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "加载图像"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"11": {
|
||||||
|
"inputs": {
|
||||||
|
"image": "cq5dam.web.hE7E3DA.1800.1800 - 2024-04-29T161212.795.jpg"
|
||||||
|
},
|
||||||
|
"class_type": "LoadImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "加载图像"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"13": {
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "qwen_image_vae.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"_meta": {
|
||||||
|
"title": "加载VAE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"14": {
|
||||||
|
"inputs": {
|
||||||
|
"prompt": "",
|
||||||
|
"return_full_refs_cond": true,
|
||||||
|
"instruction": "Describe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.",
|
||||||
|
"clip": [
|
||||||
|
"18",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"13",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"configs": [
|
||||||
|
"2",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "TextEncodeQwenImageEditPlusCustom_lrzjason",
|
||||||
|
"_meta": {
|
||||||
|
"title": "TextEncodeQwenImageEditPlusCustom lrzjason"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"15": {
|
||||||
|
"inputs": {
|
||||||
|
"prompt": [
|
||||||
|
"31",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"return_full_refs_cond": true,
|
||||||
|
"instruction": "Describe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.",
|
||||||
|
"clip": [
|
||||||
|
"18",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"13",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"configs": [
|
||||||
|
"2",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "TextEncodeQwenImageEditPlusCustom_lrzjason",
|
||||||
|
"_meta": {
|
||||||
|
"title": "TextEncodeQwenImageEditPlusCustom lrzjason"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"16": {
|
||||||
|
"inputs": {
|
||||||
|
"gguf_name": "FireRed-Image-Edit-1.1-transformer-q4_k_m.gguf"
|
||||||
|
},
|
||||||
|
"class_type": "LoaderGGUF",
|
||||||
|
"_meta": {
|
||||||
|
"title": "GGUF Loader"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"17": {
|
||||||
|
"inputs": {
|
||||||
|
"lora_name": "FireRed-Image-Edit-1.0-Lightning-8steps-v1.0.safetensors",
|
||||||
|
"strength_model": 1,
|
||||||
|
"model": [
|
||||||
|
"16",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "LoraLoaderModelOnly",
|
||||||
|
"_meta": {
|
||||||
|
"title": "LoRA加载器(仅模型)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"18": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "ClipLoaderGGUF",
|
||||||
|
"_meta": {
|
||||||
|
"title": "GGUF CLIP Loader"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"19": {
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"1",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"pad_info": [
|
||||||
|
"7",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "CropWithPadInfo",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Crop With Pad Info"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"20": {
|
||||||
|
"inputs": {
|
||||||
|
"max_size": [
|
||||||
|
"25",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"image": [
|
||||||
|
"10",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "QwenEditAdaptiveLongestEdge",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Qwen Edit Adaptive Longest Edge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"21": {
|
||||||
|
"inputs": {
|
||||||
|
"samples1": [
|
||||||
|
"15",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"samples2": [
|
||||||
|
"14",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "LatentAdd",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Latent相加"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"22": {
|
||||||
|
"inputs": {
|
||||||
|
"seed": 11489974198335,
|
||||||
|
"steps": 8,
|
||||||
|
"cfg": 1,
|
||||||
|
"sampler_name": "euler",
|
||||||
|
"scheduler": "beta",
|
||||||
|
"denoise": 1,
|
||||||
|
"model": [
|
||||||
|
"17",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"positive": [
|
||||||
|
"15",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"negative": [
|
||||||
|
"14",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"latent_image": [
|
||||||
|
"28",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"_meta": {
|
||||||
|
"title": "K采样器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"24": {
|
||||||
|
"inputs": {
|
||||||
|
"upscale_method": "lanczos",
|
||||||
|
"scale_by": [
|
||||||
|
"19",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"image": [
|
||||||
|
"19",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "ImageScaleBy",
|
||||||
|
"_meta": {
|
||||||
|
"title": "缩放图像(比例)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"25": {
|
||||||
|
"inputs": {
|
||||||
|
"Number": "1536"
|
||||||
|
},
|
||||||
|
"class_type": "Int",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Int"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"28": {
|
||||||
|
"inputs": {
|
||||||
|
"upscale_method": "nearest-exact",
|
||||||
|
"width": [
|
||||||
|
"29",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"height": [
|
||||||
|
"30",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"crop": "disabled",
|
||||||
|
"samples": [
|
||||||
|
"21",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "LatentUpscale",
|
||||||
|
"_meta": {
|
||||||
|
"title": "缩放Latent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"29": {
|
||||||
|
"inputs": {
|
||||||
|
"Number": "768"
|
||||||
|
},
|
||||||
|
"class_type": "Int",
|
||||||
|
"_meta": {
|
||||||
|
"title": "宽度"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"30": {
|
||||||
|
"inputs": {
|
||||||
|
"Number": "1024"
|
||||||
|
},
|
||||||
|
"class_type": "Int",
|
||||||
|
"_meta": {
|
||||||
|
"title": "高度"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"31": {
|
||||||
|
"inputs": {
|
||||||
|
"value": "图1的背景改成纯色深灰色,生成人物全身站立图,穿图2的衣服。维持衣服材质。"
|
||||||
|
},
|
||||||
|
"class_type": "PrimitiveStringMultiline",
|
||||||
|
"_meta": {
|
||||||
|
"title": "字符串(多行)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"inputs": {
|
||||||
|
"upscale_method": "nearest-exact",
|
||||||
|
"width": [
|
||||||
|
"29",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"height": [
|
||||||
|
"30",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"crop": "center",
|
||||||
|
"image": [
|
||||||
|
"24",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "ImageScale",
|
||||||
|
"_meta": {
|
||||||
|
"title": "缩放图像"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "ComfyUI",
|
||||||
|
"images": [
|
||||||
|
"32",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "保存图像"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"36": {
|
||||||
|
"inputs": {
|
||||||
|
"rgthree_comparer": {
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"name": "A",
|
||||||
|
"selected": true,
|
||||||
|
"url": "/api/view?filename=rgthree.compare._temp_tldsp_00001_.png&type=temp&subfolder=&rand=0.59726273806135"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "B",
|
||||||
|
"selected": true,
|
||||||
|
"url": "/api/view?filename=rgthree.compare._temp_tldsp_00002_.png&type=temp&subfolder=&rand=0.5669098706160076"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"image_a": [
|
||||||
|
"32",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"image_b": [
|
||||||
|
"11",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "Image Comparer (rgthree)",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Image Comparer (rgthree)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ComfyUI 换装服务
|
||||||
|
- 接受3个base64图片(模特、上衣、裤子)
|
||||||
|
- 上传到 ComfyUI,运行工作流
|
||||||
|
- 返回生成结果 base64 图片
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
COMFYUI_URL = "http://127.0.0.1:8188"
|
||||||
|
# COMFYUI_URL = "http://117.50.80.187:47697"
|
||||||
|
WORKFLOW_PATH = Path(__file__).parent / "change2_1_ex.json"
|
||||||
|
|
||||||
|
# 节点 ID 映射
|
||||||
|
NODE_MODEL = "11" # 模特
|
||||||
|
NODE_SHIRT = "10" # 上衣
|
||||||
|
# NODE_PANTS = "4" # 裤子
|
||||||
|
NODE_OUTPUT = "33" # 输出
|
||||||
|
|
||||||
|
app = FastAPI(title="ComfyUI 换装服务")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TryOnRequest(BaseModel):
|
||||||
|
model_image: str # base64 模特图片
|
||||||
|
shirt_image: str # base64 上衣图片
|
||||||
|
pants_image: str # base64 裤子图片
|
||||||
|
|
||||||
|
|
||||||
|
class TryOnResponse(BaseModel):
|
||||||
|
result_image: str # base64 结果图片
|
||||||
|
|
||||||
|
|
||||||
|
def decode_base64_image(b64_str: str) -> bytes:
|
||||||
|
"""解码 base64 图片,自动处理 data:image/... 前缀"""
|
||||||
|
if "," in b64_str:
|
||||||
|
b64_str = b64_str.split(",", 1)[1]
|
||||||
|
return base64.b64decode(b64_str)
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_image(client: httpx.AsyncClient, image_bytes: bytes, filename: str) -> str:
|
||||||
|
"""上传图片到 ComfyUI,返回服务器文件名"""
|
||||||
|
files = {
|
||||||
|
"image": (filename, io.BytesIO(image_bytes), "image/jpeg"),
|
||||||
|
}
|
||||||
|
data = {"overwrite": "true"}
|
||||||
|
resp = await client.post(f"{COMFYUI_URL}/upload/image", files=files, data=data)
|
||||||
|
resp.raise_for_status()
|
||||||
|
result = resp.json()
|
||||||
|
return result["name"]
|
||||||
|
|
||||||
|
|
||||||
|
async def queue_prompt(client: httpx.AsyncClient, workflow: dict) -> str:
|
||||||
|
"""提交工作流到队列,返回 prompt_id"""
|
||||||
|
payload = {"prompt": workflow, "client_id": str(uuid.uuid4())}
|
||||||
|
resp = await client.post(f"{COMFYUI_URL}/prompt", json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()["prompt_id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_result(client: httpx.AsyncClient, prompt_id: str, timeout: int = 300) -> dict:
|
||||||
|
"""轮询历史记录,等待任务完成,返回输出节点数据"""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
resp = await client.get(f"{COMFYUI_URL}/history/{prompt_id}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
history = resp.json()
|
||||||
|
if prompt_id in history:
|
||||||
|
entry = history[prompt_id]
|
||||||
|
status = entry.get("status", {})
|
||||||
|
if status.get("completed"):
|
||||||
|
return entry.get("outputs", {})
|
||||||
|
if status.get("status_str") == "error":
|
||||||
|
messages = status.get("messages", [])
|
||||||
|
raise HTTPException(status_code=500, detail=f"ComfyUI 工作流执行出错: {messages}")
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
raise HTTPException(status_code=504, detail="等待 ComfyUI 超时(300秒)")
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_image_as_base64(client: httpx.AsyncClient, filename: str, subfolder: str = "", type_: str = "output") -> str:
|
||||||
|
"""从 ComfyUI 下载图片并编码为 base64"""
|
||||||
|
params = {"filename": filename, "subfolder": subfolder, "type": type_}
|
||||||
|
resp = await client.get(f"{COMFYUI_URL}/view", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
content_type = resp.headers.get("content-type", "image/png")
|
||||||
|
b64 = base64.b64encode(resp.content).decode()
|
||||||
|
return f"data:{content_type};base64,{b64}"
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/try-on", response_model=TryOnResponse)
|
||||||
|
async def try_on(req: TryOnRequest):
|
||||||
|
"""
|
||||||
|
换装接口
|
||||||
|
- model_image: 模特图片 base64
|
||||||
|
- shirt_image: 上衣图片 base64
|
||||||
|
- pants_image: 裤子图片 base64
|
||||||
|
返回 result_image: 换装结果 base64
|
||||||
|
"""
|
||||||
|
|
||||||
|
print(f"接收到换装请求,正在处理...")
|
||||||
|
# 加载工作流模板
|
||||||
|
workflow = json.loads(WORKFLOW_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
# 解码三张图片
|
||||||
|
try:
|
||||||
|
model_bytes = decode_base64_image(req.model_image)
|
||||||
|
shirt_bytes = decode_base64_image(req.shirt_image)
|
||||||
|
# pants_bytes = decode_base64_image(req.pants_image)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"图片解码失败: {e}")
|
||||||
|
|
||||||
|
# 生成唯一文件名,避免缓存冲突
|
||||||
|
uid = uuid.uuid4().hex[:8]
|
||||||
|
model_fname = f"model_{uid}.jpg"
|
||||||
|
shirt_fname = f"shirt_{uid}.jpg"
|
||||||
|
# pants_fname = f"pants_{uid}.jpg"
|
||||||
|
|
||||||
|
# 并发上传三张图片
|
||||||
|
try:
|
||||||
|
model_name, shirt_name, pants_name = await asyncio.gather(
|
||||||
|
upload_image(client, model_bytes, model_fname),
|
||||||
|
upload_image(client, shirt_bytes, shirt_fname),
|
||||||
|
# upload_image(client, pants_bytes, pants_fname),
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"上传图片到 ComfyUI 失败: {e}")
|
||||||
|
|
||||||
|
# 修改工作流节点的图片输入
|
||||||
|
workflow[NODE_MODEL]["inputs"]["image"] = model_name
|
||||||
|
workflow[NODE_SHIRT]["inputs"]["image"] = shirt_name
|
||||||
|
# workflow[NODE_PANTS]["inputs"]["image"] = pants_name
|
||||||
|
|
||||||
|
# 提交工作流
|
||||||
|
try:
|
||||||
|
prompt_id = await queue_prompt(client, workflow)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"提交工作流失败: {e}")
|
||||||
|
|
||||||
|
# 等待结果(最多5分钟)
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as poll_client:
|
||||||
|
outputs = await wait_for_result(poll_client, prompt_id, timeout=300)
|
||||||
|
|
||||||
|
# 从节点33的输出获取图片
|
||||||
|
node_output = outputs.get(NODE_OUTPUT)
|
||||||
|
if not node_output:
|
||||||
|
raise HTTPException(status_code=500, detail=f"工作流未返回节点 {NODE_OUTPUT} 的输出")
|
||||||
|
|
||||||
|
images = node_output.get("images", [])
|
||||||
|
if not images:
|
||||||
|
raise HTTPException(status_code=500, detail="输出节点没有图片")
|
||||||
|
|
||||||
|
img_info = images[0]
|
||||||
|
filename = img_info["filename"]
|
||||||
|
subfolder = img_info.get("subfolder", "")
|
||||||
|
type_ = img_info.get("type", "output")
|
||||||
|
|
||||||
|
# 下载结果图片
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as dl_client:
|
||||||
|
result_b64 = await fetch_image_as_base64(dl_client, filename, subfolder, type_)
|
||||||
|
|
||||||
|
return TryOnResponse(result_image=result_b64)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""健康检查"""
|
||||||
|
return {"status": "ok", "comfyui": COMFYUI_URL}
|
||||||
|
|
||||||
|
|
||||||
|
# 挂载静态文件(前端页面)
|
||||||
|
static_dir = Path(__file__).parent / "static"
|
||||||
|
static_dir.mkdir(exist_ok=True)
|
||||||
|
print(f"静态文件目录: {static_dir}")
|
||||||
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index():
|
||||||
|
return FileResponse(str(static_dir / "index.html"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=47698, log_level="info")
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI 换装系统</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: #0f0f13;
|
||||||
|
color: #e0e0e0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 32px 16px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, #a78bfa, #60a5fa, #34d399);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card {
|
||||||
|
background: #1a1a24;
|
||||||
|
border: 2px dashed #333;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
position: relative;
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card:hover {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
background: #1e1e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card.has-image {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #6d28d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card input[type="file"] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-label {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #c4b5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-img.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-run {
|
||||||
|
padding: 14px 56px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50px;
|
||||||
|
background: linear-gradient(135deg, #7c3aed, #2563eb);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, transform 0.1s;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-run:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-run:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
min-height: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar.running { color: #60a5fa; }
|
||||||
|
.status-bar.success { color: #34d399; }
|
||||||
|
.status-bar.error { color: #f87171; }
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid #60a5fa44;
|
||||||
|
border-top-color: #60a5fa;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.result-section {
|
||||||
|
margin-top: 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section.visible { display: flex; }
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-img {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 0 40px #7c3aed44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download {
|
||||||
|
padding: 10px 36px;
|
||||||
|
border: 2px solid #7c3aed;
|
||||||
|
border-radius: 50px;
|
||||||
|
background: transparent;
|
||||||
|
color: #a78bfa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download:hover {
|
||||||
|
background: #7c3aed22;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.upload-grid { grid-template-columns: 1fr; }
|
||||||
|
h1 { font-size: 1.5rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>AI 换装系统</h1>
|
||||||
|
<p class="subtitle">上传模特、上衣、裤子三张图片,AI 自动完成换装</p>
|
||||||
|
|
||||||
|
<div class="upload-grid">
|
||||||
|
<!-- 模特 -->
|
||||||
|
<div class="upload-card" id="card-model">
|
||||||
|
<input type="file" accept="image/*" id="input-model" />
|
||||||
|
<div class="upload-icon">🧍</div>
|
||||||
|
<div class="upload-label">模特图片</div>
|
||||||
|
<div class="upload-hint">节点 11 · 人物全身照</div>
|
||||||
|
<img class="preview-img" id="preview-model" alt="模特预览" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 上衣 -->
|
||||||
|
<div class="upload-card" id="card-shirt">
|
||||||
|
<input type="file" accept="image/*" id="input-shirt" />
|
||||||
|
<div class="upload-icon">👕</div>
|
||||||
|
<div class="upload-label">上衣图片</div>
|
||||||
|
<div class="upload-hint">节点 10 · 上身服装</div>
|
||||||
|
<img class="preview-img" id="preview-shirt" alt="上衣预览" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 裤子 -->
|
||||||
|
<div class="upload-card" id="card-pants">
|
||||||
|
<input type="file" accept="image/*" id="input-pants" />
|
||||||
|
<div class="upload-icon">👖</div>
|
||||||
|
<div class="upload-label">裤子图片</div>
|
||||||
|
<div class="upload-hint">节点 4 · 下身服装</div>
|
||||||
|
<img class="preview-img" id="preview-pants" alt="裤子预览" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-run" id="btn-run" disabled>开始换装</button>
|
||||||
|
<div class="status-bar" id="status-bar"></div>
|
||||||
|
|
||||||
|
<div class="result-section" id="result-section">
|
||||||
|
<div class="result-title">换装结果</div>
|
||||||
|
<img class="result-img" id="result-img" alt="换装结果" />
|
||||||
|
<button class="btn-download" id="btn-download">下载图片</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const images = { model: null, shirt: null, pants: null };
|
||||||
|
|
||||||
|
function setupUpload(inputId, previewId, cardId, key) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
const preview = document.getElementById(previewId);
|
||||||
|
const card = document.getElementById(cardId);
|
||||||
|
|
||||||
|
input.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
images[key] = ev.target.result; // data:image/...;base64,...
|
||||||
|
preview.src = ev.target.result;
|
||||||
|
preview.classList.add('visible');
|
||||||
|
card.classList.add('has-image');
|
||||||
|
updateRunButton();
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupUpload('input-model', 'preview-model', 'card-model', 'model');
|
||||||
|
setupUpload('input-shirt', 'preview-shirt', 'card-shirt', 'shirt');
|
||||||
|
setupUpload('input-pants', 'preview-pants', 'card-pants', 'pants');
|
||||||
|
|
||||||
|
function updateRunButton() {
|
||||||
|
const btn = document.getElementById('btn-run');
|
||||||
|
btn.disabled = !(images.model && images.shirt && images.pants);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg, cls = '') {
|
||||||
|
const el = document.getElementById('status-bar');
|
||||||
|
el.className = 'status-bar ' + cls;
|
||||||
|
el.innerHTML = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elapsed(start) {
|
||||||
|
const s = Math.round((Date.now() - start) / 1000);
|
||||||
|
return `${s}秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-run').addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('btn-run');
|
||||||
|
const resultSection = document.getElementById('result-section');
|
||||||
|
const resultImg = document.getElementById('result-img');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
resultSection.classList.remove('visible');
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
let timer = setInterval(() => {
|
||||||
|
setStatus(`<span class="spinner"></span>正在换装中,请稍候… 已等待 ${elapsed(start)}`, 'running');
|
||||||
|
}, 1000);
|
||||||
|
setStatus('<span class="spinner"></span>正在上传图片并提交工作流…', 'running');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/try-on', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model_image: images.model,
|
||||||
|
shirt_image: images.shirt,
|
||||||
|
// pants_image: images.pants,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
clearInterval(timer);
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||||
|
throw new Error(err.detail || '请求失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
resultImg.src = data.result_image;
|
||||||
|
resultSection.classList.add('visible');
|
||||||
|
setStatus(`✅ 换装完成!耗时 ${elapsed(start)}`, 'success');
|
||||||
|
|
||||||
|
// 下载按钮
|
||||||
|
document.getElementById('btn-download').onclick = () => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = data.result_image;
|
||||||
|
a.download = `tryon_${Date.now()}.png`;
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
clearInterval(timer);
|
||||||
|
setStatus(`❌ 错误:${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
updateRunButton();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user