添加单张图片变换

This commit is contained in:
Your Name
2026-05-06 00:12:55 +08:00
parent 0b9b5d21ff
commit 47ff49dc19
7 changed files with 1119 additions and 39 deletions
+44 -26
View File
@@ -7,61 +7,79 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Each service runs independently and must be started separately: Each service runs independently and must be started separately:
```bash ```bash
# Shirt-only try-on service (port 8888) # Single-image edit service (port 12221)
python change1/service1.py
# Shirt-only try-on service (port 12222)
python change2/service2.py python change2/service2.py
# Shirt + pants try-on service (port 8889) # Shirt + pants try-on service (port 12223)
python change3/service3.py python change3/service3.py
# Gateway router (port 28888) — requires Flask # Gateway router (port 28888) — requires Flask
python change_app.py python change_app.py
``` ```
ComfyUI must be running at `http://127.0.0.1:8188` before starting the services. ComfyUI must be reachable at the URL configured in each service (currently `http://112.126.94.241:28188`, with `:38188` as backup).
## Dependencies ## Dependencies
```bash ```bash
pip install fastapi uvicorn httpx pydantic flask requests pip install fastapi uvicorn httpx pydantic flask requests oss2 pillow
``` ```
## Architecture ## Architecture
This is a three-tier AI virtual try-on system built around ComfyUI. This is an AI virtual try-on / image-edit system built around ComfyUI, fronted by three FastAPI workers and a Flask gateway.
``` ```
Client Client
└─► change_app.py (Flask, port 28888) ← Gateway router └─► change_app.py (Flask, port 28888) ← Gateway router
├─► change2/service2.py (FastAPI, port 8888) ← shirt-only ├─► change1/service1.py (FastAPI, 12221) ← single image (model only)
─► change3/service3.py (FastAPI, port 8889) ← shirt + pants ─► change2/service2.py (FastAPI, 12222) ← model + shirt
└─► ComfyUI (port 8188) └─► change3/service3.py (FastAPI, 12223) ← model + shirt + pants
└─► ComfyUI
``` ```
**Gateway** (`change_app.py`): Accepts `POST /change_cloth_base64` with `human_img`, `cloth_img`, and optional `kuzi_img` (pants). Routes to `service3` when `kuzi_img` is present, otherwise to `service2`. Both backends are called at `/do_change_cloth`. **Gateway** (`change_app.py`): Accepts `POST /change_cloth_base64` with `human_img` (required) and optional `cloth_img` / `kuzi_img` / `change_desc`. Routing rules:
- `kuzi_img` present → `service3` (12223)
- `cloth_img` present (no `kuzi_img`) → `service2` (12222)
- only `human_img``service1` (12221)
**service2** (`change2/service2.py`): Handles shirt-only try-on. Exposes `POST /try-on` with `model_image` + `shirt_image`. Uses ComfyUI workflow `change2_1_ex.json`. All backends expose `POST /try-on` and return `{ "result_url": "<oss url>" }`. The gateway wraps that into `{ "ret": 0, "state": 0, "msg": "success", "data": <oss url> }`.
**service3** (`change3/service3.py`): Handles shirt + pants try-on. Exposes `POST /try-on` with `model_image` + `shirt_image` + `pants_image`. Uses ComfyUI workflow `change2_2_0308.json`. **service1** (`change1/service1.py`): Single-image edit (e.g. background replacement, full-body generation from a reference). Exposes `POST /try-on` with `model_image` + optional `change_desc`. Uses ComfyUI workflow `change1.json`.
**service2** (`change2/service2.py`): Shirt-only try-on. Exposes `POST /try-on` with `model_image` + `shirt_image` + optional `change_desc`. Uses ComfyUI workflow `change2_1_ex.json`.
**service3** (`change3/service3.py`): Shirt + pants try-on. Exposes `POST /try-on` with `model_image` + `shirt_image` + `pants_image` + optional `change_desc`. Uses ComfyUI workflow `change2_2_0308.json`.
### ComfyUI Workflow Integration ### ComfyUI Workflow Integration
Both services dynamically modify a JSON workflow template before submission: Each service dynamically modifies a JSON workflow template before submission:
| Node ID | Purpose | service2 | service3 | | Node ID | Purpose | service1 | service2 | service3 |
|---------|---------|----------|----------| |---------|---------|----------|----------|----------|
| `"11"` | Model/person image | ✓ | ✓ | | `"11"` | Model/person image (LoadImage) | ✓ | ✓ | ✓ |
| `"10"` | Shirt image | ✓ | ✓ | | `"10"` | Shirt image (LoadImage) | — | ✓ | ✓ |
| `"4"` | Pants image | — | ✓ | | `"4"` | Pants image (LoadImage) | — | — | ✓ |
| `"33"` | Output image | ✓ | ✓ | | `"31"` | Text prompt (PrimitiveStringMultiline, overridden when `change_desc` is non-empty) | ✓ | ✓ | ✓ |
| `"33"` | Output image (SaveImage) | ✓ | ✓ | ✓ |
The request flow within each service: The request flow within each service:
1. Decode base64 images 1. Pick a healthy ComfyUI host (primary, fallback to backup)
2. Upload images to ComfyUI (`/upload/image`) with UUID-prefixed filenames to avoid cache collisions 2. Decode base64 images
3. Inject filenames into workflow node inputs 3. Upload images to ComfyUI (`/upload/image`) with UUID-prefixed filenames to avoid cache collisions
4. Submit workflow to ComfyUI queue (`/prompt`) 4. Inject filenames into workflow node inputs; if `change_desc` is non-empty, replace node `31` inputs with `{"value": change_desc}`
5. Poll `/history/{prompt_id}` until `status.completed` is true (max 300s timeout, 2s poll interval) 5. Submit workflow to ComfyUI queue (`/prompt`)
6. Download result from `/view` and return as base64 6. Poll `/history/{prompt_id}` until `status.completed` is true (max 300s timeout, 2s poll interval)
7. Download the result from `/view`, push it to Aliyun OSS, and return the signed URL
### Frontend ### Frontend
Each service has its own test UI at `/` (served from `<service_dir>/static/index.html`). The `change2` UI accepts 2 images; `change3` accepts 3 images. Both POST directly to the service's `/try-on` endpoint. Each service has its own test UI at `/` (served from `<service_dir>/static/index.html`):
- `change1` UI: 1 upload card (model) + prompt panel
- `change2` UI: 2 upload cards (model, shirt) + prompt panel
- `change3` UI: 3 upload cards (model, shirt, pants) + prompt panel
All three POST directly to the service's own `/try-on` endpoint.
+400
View File
@@ -0,0 +1,400 @@
{
"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": 1024,
"ref_crop": "center",
"ref_upscale": "lanczos",
"to_vl": true,
"vl_resize": true,
"vl_target_size": 384,
"vl_crop": "center",
"vl_upscale": "bicubic",
"image": [
"11",
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": 2048,
"image": [
"11",
0
]
},
"class_type": "QwenEditAdaptiveLongestEdge",
"_meta": {
"title": "Qwen Edit Adaptive Longest Edge"
}
},
"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"
}
},
"21": {
"inputs": {
"samples1": [
"15",
1
],
"samples2": [
"14",
1
]
},
"class_type": "LatentAdd",
"_meta": {
"title": "Latent相加"
}
},
"22": {
"inputs": {
"seed": 814498275732948,
"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": "缩放图像(比例)"
}
},
"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的背景改成纯色深灰色,生成人物全身站立图。"
},
"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_pfeet_00001_.png&type=temp&subfolder=&rand=0.983916958744797"
},
{
"name": "B",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_pfeet_00002_.png&type=temp&subfolder=&rand=0.7859180062182538"
}
]
},
"image_a": [
"32",
0
],
"image_b": [
"11",
0
]
},
"class_type": "Image Comparer (rgthree)",
"_meta": {
"title": "Image Comparer (rgthree)"
}
}
}
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
ComfyUI 单图编辑服务
- 接受 1 个 base64 图片(模特/原图)
- 上传到 ComfyUI,运行工作流 change1.json
- 返回生成结果 OSS URL
"""
import asyncio
import base64
import io
import json
import os
import tempfile
import time
import uuid
from pathlib import Path
import httpx
import oss2
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://112.126.94.241:28188"
COMFYUI_URL_BACKUP = "http://112.126.94.241:38188"
WORKFLOW_PATH = Path(__file__).parent / "change1.json"
NODE_MODEL = "11" # 输入图(LoadImage
NODE_OUTPUT = "33" # 输出(SaveImage
NODE_PROMPT = "31" # 文本提示(PrimitiveStringMultiline
app = FastAPI(title="ComfyUI 单图编辑服务")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
def upload_to_oss(image_path, object_name=None):
access_key_id = 'LTAI5tGp1sLzedqxihcNC1eb'
access_key_secret = 'IFZE1b8YYreCP6zfA6GaZ9uBT678qO'
endpoint = 'oss-cn-beijing.aliyuncs.com'
bucket_name = 'xiangsilian'
auth = oss2.Auth(access_key_id, access_key_secret)
bucket = oss2.Bucket(auth, endpoint, bucket_name)
if object_name is None:
object_name = image_path.split('/')[-1]
try:
bucket.put_object_from_file(object_name, image_path)
url = bucket.sign_url('GET', object_name, 3600 * 24 * 365 * 10)
print(f"文件上传成功,URL: {url}")
return url
except Exception as e:
print(f"上传失败: {str(e)}")
return None
class TryOnRequest(BaseModel):
model_image: str # base64 输入图片
change_desc: str = "" # 编辑描述,可空;非空时替换节点 31 的 inputs
class TryOnResponse(BaseModel):
result_url: str # OSS 图片 URL
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 check_comfyui_alive(base_url: str, timeout: float = 3.0) -> bool:
"""检查指定的 ComfyUI 服务器是否可用"""
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(f"{base_url}/system_stats")
return resp.status_code == 200
except Exception as e:
print(f"ComfyUI 健康检查失败 {base_url}: {e}")
return False
async def pick_comfyui_url() -> str:
"""优先返回正式服务器,不可用时切换到备份服务器"""
if await check_comfyui_alive(COMFYUI_URL):
print(f"使用正式服务器: {COMFYUI_URL}")
return COMFYUI_URL
print(f"正式服务器不可用,尝试切换到备份服务器: {COMFYUI_URL_BACKUP}")
if await check_comfyui_alive(COMFYUI_URL_BACKUP):
print(f"使用备份服务器: {COMFYUI_URL_BACKUP}")
return COMFYUI_URL_BACKUP
raise HTTPException(status_code=503, detail="正式与备份 ComfyUI 服务器均不可用")
async def upload_image(client: httpx.AsyncClient, base_url: str, image_bytes: bytes, filename: str) -> str:
"""上传图片到 ComfyUI,返回服务器文件名"""
files = {
"image": (filename, io.BytesIO(image_bytes), "image/jpeg"),
}
data = {"overwrite": "true"}
resp = await client.post(f"{base_url}/upload/image", files=files, data=data)
resp.raise_for_status()
result = resp.json()
return result["name"]
async def queue_prompt(client: httpx.AsyncClient, base_url: str, workflow: dict) -> str:
"""提交工作流到队列,返回 prompt_id"""
payload = {"prompt": workflow, "client_id": str(uuid.uuid4())}
resp = await client.post(f"{base_url}/prompt", json=payload)
resp.raise_for_status()
return resp.json()["prompt_id"]
async def wait_for_result(client: httpx.AsyncClient, base_url: str, prompt_id: str, timeout: int = 300) -> dict:
"""轮询历史记录,等待任务完成,返回输出节点数据"""
deadline = time.time() + timeout
while time.time() < deadline:
resp = await client.get(f"{base_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_bytes(client: httpx.AsyncClient, base_url: str, filename: str, subfolder: str = "", type_: str = "output") -> bytes:
"""从 ComfyUI 下载图片,返回原始字节"""
params = {"filename": filename, "subfolder": subfolder, "type": type_}
resp = await client.get(f"{base_url}/view", params=params)
resp.raise_for_status()
return resp.content
@app.post("/try-on", response_model=TryOnResponse)
async def try_on(req: TryOnRequest):
"""
单图编辑接口
- model_image: 输入图片 base64
- change_desc: 可选的编辑描述(非空时覆盖工作流节点 31)
返回 result_url: 生成结果的 OSS URL
"""
print(f"接收到单图编辑请求,正在处理...")
comfyui_url = await pick_comfyui_url()
workflow = json.loads(WORKFLOW_PATH.read_text(encoding="utf-8"))
desc = (req.change_desc or "").strip()
if desc:
workflow[NODE_PROMPT]["inputs"] = {"value": desc}
async with httpx.AsyncClient(timeout=60.0) as client:
try:
model_bytes = decode_base64_image(req.model_image)
except Exception as e:
raise HTTPException(status_code=400, detail=f"图片解码失败: {e}")
uid = uuid.uuid4().hex[:8]
model_fname = f"model_{uid}.jpg"
try:
model_name = await upload_image(client, comfyui_url, model_bytes, model_fname)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"上传图片到 ComfyUI 失败: {e}")
workflow[NODE_MODEL]["inputs"]["image"] = model_name
try:
prompt_id = await queue_prompt(client, comfyui_url, workflow)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"提交工作流失败: {e}")
async with httpx.AsyncClient(timeout=30.0) as poll_client:
outputs = await wait_for_result(poll_client, comfyui_url, prompt_id, timeout=300)
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_bytes = await fetch_image_bytes(dl_client, comfyui_url, filename, subfolder, type_)
suffix = Path(filename).suffix or ".png"
object_name = f"tryon/{uuid.uuid4().hex}{suffix}"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(result_bytes)
tmp_path = tmp.name
try:
result_url = upload_to_oss(tmp_path, object_name)
finally:
os.unlink(tmp_path)
if not result_url:
raise HTTPException(status_code=500, detail="上传结果图片到 OSS 失败")
return TryOnResponse(result_url=result_url)
@app.get("/health")
async def health():
"""健康检查"""
primary_ok = await check_comfyui_alive(COMFYUI_URL)
backup_ok = await check_comfyui_alive(COMFYUI_URL_BACKUP)
if primary_ok:
active = COMFYUI_URL
elif backup_ok:
active = COMFYUI_URL_BACKUP
else:
active = None
return {
"status": "ok" if active else "degraded",
"comfyui_active": active,
"comfyui_primary": {"url": COMFYUI_URL, "alive": primary_ok},
"comfyui_backup": {"url": COMFYUI_URL_BACKUP, "alive": backup_ok},
}
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=12221, log_level="info")
+390
View File
@@ -0,0 +1,390 @@
<!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: 1fr;
gap: 20px;
width: 100%;
max-width: 460px;
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;
}
.prompt-panel {
width: 100%;
max-width: 460px;
margin-bottom: 24px;
background: #1a1a24;
border-radius: 16px;
padding: 20px;
border: 1px solid #2a2a3a;
}
.prompt-panel .prompt-title {
font-size: 0.85rem;
font-weight: 600;
color: #a78bfa;
margin-bottom: 12px;
}
.radio-row {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 10px;
cursor: pointer;
font-size: 0.9rem;
color: #ccc;
}
.radio-row input { margin-top: 3px; accent-color: #7c3aed; flex-shrink: 0; }
.change-desc-input {
width: 100%;
margin-top: 4px;
padding: 12px;
border-radius: 10px;
border: 1px solid #333;
background: #12121a;
color: #e0e0e0;
font-family: inherit;
font-size: 0.88rem;
resize: vertical;
min-height: 88px;
}
.change-desc-input:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (max-width: 640px) {
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>
<div class="prompt-panel" id="prompt-panel">
<div class="prompt-title">提示词(change_desc</div>
<label class="radio-row">
<input type="radio" name="prompt_mode" id="prompt-default" value="default" checked />
<span>使用默认(传空,不覆盖工作流节点 31)</span>
</label>
<label class="radio-row">
<input type="radio" name="prompt_mode" id="prompt-custom" value="custom" />
<span>自定义提示词</span>
</label>
<textarea id="change-desc" class="change-desc-input" placeholder="输入编辑描述…" rows="4" disabled></textarea>
</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 };
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');
const promptDefault = document.getElementById('prompt-default');
const promptCustom = document.getElementById('prompt-custom');
const changeDescEl = document.getElementById('change-desc');
function syncPromptUi() {
changeDescEl.disabled = !promptCustom.checked;
}
promptDefault.addEventListener('change', syncPromptUi);
promptCustom.addEventListener('change', syncPromptUi);
function getChangeDesc() {
return promptCustom.checked ? changeDescEl.value.trim() : '';
}
function updateRunButton() {
const btn = document.getElementById('btn-run');
btn.disabled = !images.model;
}
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,
change_desc: getChangeDesc(),
}),
});
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_url;
resultSection.classList.add('visible');
setStatus(`✅ 生成完成!耗时 ${elapsed(start)}`, 'success');
document.getElementById('btn-download').onclick = () => {
const a = document.createElement('a');
a.href = data.result_url;
a.download = `tryon_${Date.now()}.png`;
a.target = '_blank';
a.click();
};
} catch (err) {
clearInterval(timer);
setStatus(`❌ 错误:${err.message}`, 'error');
} finally {
updateRunButton();
}
});
</script>
</body>
</html>
+9 -3
View File
@@ -26,8 +26,8 @@ def change_cloth_base64():
human_img = data.get('human_img') human_img = data.get('human_img')
cloth_img = data.get('cloth_img') cloth_img = data.get('cloth_img')
if not human_img or not cloth_img: if not human_img:
return jsonify({"ret": -1, "state": -1, "msg": "Both human_img and cloth_img are required"}), 400 return jsonify({"ret": -1, "state": -1, "msg": "human_img is required"}), 400
kuzi_img = data.get('kuzi_img') kuzi_img = data.get('kuzi_img')
@@ -46,13 +46,19 @@ def change_cloth_base64():
'change_desc': change_desc, 'change_desc': change_desc,
} }
response = requests.post('http://127.0.0.1:12223/try-on', json=payload, timeout=360) response = requests.post('http://127.0.0.1:12223/try-on', json=payload, timeout=360)
else: elif cloth_img:
payload = { payload = {
'model_image': human_img, 'model_image': human_img,
'shirt_image': cloth_img, 'shirt_image': cloth_img,
'change_desc': change_desc, 'change_desc': change_desc,
} }
response = requests.post('http://127.0.0.1:12222/try-on', json=payload, timeout=360) response = requests.post('http://127.0.0.1:12222/try-on', json=payload, timeout=360)
else:
payload = {
'model_image': human_img,
'change_desc': change_desc,
}
response = requests.post('http://127.0.0.1:12221/try-on', json=payload, timeout=360)
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
+6 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# 启动本机换装网关与 service2 / service3。 # 启动本机换装网关与 service1 / service2 / service3。
# ComfyUI 在其它机器运行;service2/service3 内已配置远端 COMFYUI_URL。 # ComfyUI 在其它机器运行;service1/service2/service3 内已配置远端 COMFYUI_URL。
ROOT="/home/ubuntu/change_cloth_server" ROOT="/home/ubuntu/change_cloth_server"
LOG_DIR="/home/ubuntu/log" LOG_DIR="/home/ubuntu/log"
@@ -9,6 +9,7 @@ mkdir -p "$LOG_DIR"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始停止旧进程..." echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始停止旧进程..."
# 按命令行中的脚本绝对路径结束旧实例(与下方 nohup 路径一致) # 按命令行中的脚本绝对路径结束旧实例(与下方 nohup 路径一致)
pkill -f "${ROOT}/change1/service1.py" 2>/dev/null && echo " 已停止 service1" || echo " service1 未在运行"
pkill -f "${ROOT}/change2/service2.py" 2>/dev/null && echo " 已停止 service2" || echo " service2 未在运行" pkill -f "${ROOT}/change2/service2.py" 2>/dev/null && echo " 已停止 service2" || echo " service2 未在运行"
pkill -f "${ROOT}/change3/service3.py" 2>/dev/null && echo " 已停止 service3" || echo " service3 未在运行" pkill -f "${ROOT}/change3/service3.py" 2>/dev/null && echo " 已停止 service3" || echo " service3 未在运行"
pkill -f "${ROOT}/change_app.py" 2>/dev/null && echo " 已停止 change_app" || echo " change_app 未在运行" pkill -f "${ROOT}/change_app.py" 2>/dev/null && echo " 已停止 change_app" || echo " change_app 未在运行"
@@ -19,6 +20,9 @@ echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始启动服务..."
export PATH="/home/ubuntu/.local/bin:$PATH" export PATH="/home/ubuntu/.local/bin:$PATH"
nohup python3 "${ROOT}/change1/service1.py" >> "$LOG_DIR/service1.log" 2>&1 &
echo " service1 已启动 (PID: $!)"
nohup python3 "${ROOT}/change2/service2.py" >> "$LOG_DIR/service2.log" 2>&1 & nohup python3 "${ROOT}/change2/service2.py" >> "$LOG_DIR/service2.log" 2>&1 &
echo " service2 已启动 (PID: $!)" echo " service2 已启动 (PID: $!)"
+9 -8
View File
@@ -249,7 +249,7 @@
</head> </head>
<body> <body>
<h1>AI 换装测试</h1> <h1>AI 换装测试</h1>
<p class="subtitle">上传模特和衣服图片调用 /change_cloth_base64 接口测试</p> <p class="subtitle">上传图片调用 /change_cloth_base64 接口;只传模特→单图编辑,加上衣→换装,再加裤子→换装+换裤</p>
<div class="upload-grid"> <div class="upload-grid">
<!-- 模特 --> <!-- 模特 -->
@@ -262,13 +262,14 @@
<img class="preview-img" id="preview-human" alt="模特预览" /> <img class="preview-img" id="preview-human" alt="模特预览" />
</div> </div>
<!-- 上衣 --> <!-- 上衣(可选) -->
<div class="upload-card" id="card-cloth"> <div class="upload-card optional" id="card-cloth">
<button class="btn-clear" id="clear-cloth" title="清除"></button> <button class="btn-clear" id="clear-cloth" title="清除"></button>
<input type="file" accept="image/*" id="input-cloth" /> <input type="file" accept="image/*" id="input-cloth" />
<div class="upload-icon">👕</div> <div class="upload-icon">👕</div>
<div class="upload-label">上衣图片</div> <div class="upload-label">上衣图片</div>
<div class="upload-hint">cloth_img · 必填</div> <div class="upload-hint">cloth_img · 可选</div>
<span class="badge-optional">无则走 service1,有则走 service2</span>
<img class="preview-img" id="preview-cloth" alt="上衣预览" /> <img class="preview-img" id="preview-cloth" alt="上衣预览" />
</div> </div>
@@ -279,7 +280,7 @@
<div class="upload-icon">👖</div> <div class="upload-icon">👖</div>
<div class="upload-label">裤子图片</div> <div class="upload-label">裤子图片</div>
<div class="upload-hint">kuzi_img · 可选</div> <div class="upload-hint">kuzi_img · 可选</div>
<span class="badge-optional">则走 8888 服务,无则走 8889</span> <span class="badge-optional">填了则走 service3</span>
<img class="preview-img" id="preview-kuzi" alt="裤子预览" /> <img class="preview-img" id="preview-kuzi" alt="裤子预览" />
</div> </div>
</div> </div>
@@ -365,7 +366,7 @@
} }
function updateRunButton() { function updateRunButton() {
document.getElementById('btn-run').disabled = !(images.human && images.cloth); document.getElementById('btn-run').disabled = !images.human;
} }
function setStatus(msg, cls = '') { function setStatus(msg, cls = '') {
@@ -397,10 +398,10 @@
const body = { const body = {
human_img: images.human, human_img: images.human,
cloth_img: images.cloth,
change_desc: getChangeDesc(), change_desc: getChangeDesc(),
}; };
if (images.kuzi) body.kuzi_img = images.kuzi; if (images.cloth) body.cloth_img = images.cloth;
if (images.kuzi) body.kuzi_img = images.kuzi;
try { try {
const resp = await fetch('/change_cloth_base64', { const resp = await fetch('/change_cloth_base64', {