change3 success

This commit is contained in:
Your Name
2026-03-12 03:37:27 +00:00
commit 49c0ac8d2a
4 changed files with 1117 additions and 0 deletions
+494
View File
@@ -0,0 +1,494 @@
{
"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"
}
},
"3": {
"inputs": {
"max_size": [
"25",
0
],
"image": [
"4",
0
]
},
"class_type": "QwenEditAdaptiveLongestEdge",
"_meta": {
"title": "Qwen Edit Adaptive Longest Edge"
}
},
"4": {
"inputs": {
"image": "82aaadb9-1c86-4521-86f9-3583184f6e0f.jpg"
},
"class_type": "LoadImage",
"_meta": {
"title": "加载图像"
}
},
"5": {
"inputs": {
"to_ref": true,
"ref_main_image": false,
"ref_longest_edge": [
"3",
0
],
"ref_crop": "center",
"ref_upscale": "lanczos",
"to_vl": true,
"vl_resize": true,
"vl_target_size": 384,
"vl_crop": "center",
"vl_upscale": "bicubic",
"image": [
"4",
0
],
"configs": [
"2",
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": [
"5",
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": [
"5",
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": 42692485840454,
"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的衣服,穿图3的衣服。维持衣服材质。"
},
"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_mpazx_00077_.png&type=temp&subfolder=&rand=0.7490888740442448"
},
{
"name": "B",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_mpazx_00078_.png&type=temp&subfolder=&rand=0.37548041933644327"
}
]
},
"image_a": [
"32",
0
],
"image_b": [
"11",
0
]
},
"class_type": "Image Comparer (rgthree)",
"_meta": {
"title": "Image Comparer (rgthree)"
}
}
}
+203
View File
@@ -0,0 +1,203 @@
#!/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_2_0308.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
"""
# 加载工作流模板
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")
+343
View File
@@ -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>