diff --git a/change2/change2_1_ex.json b/change2/change2_1_ex.json new file mode 100644 index 0000000..68c8e0a --- /dev/null +++ b/change2/change2_1_ex.json @@ -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)" + } + } +} \ No newline at end of file diff --git a/change2/service2.py b/change2/service2.py new file mode 100644 index 0000000..5f14c4b --- /dev/null +++ b/change2/service2.py @@ -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") diff --git a/change2/static/index.html b/change2/static/index.html new file mode 100644 index 0000000..8a3ca57 --- /dev/null +++ b/change2/static/index.html @@ -0,0 +1,343 @@ + + +
+ + +上传模特、上衣、裤子三张图片,AI 自动完成换装
+ +