添加单张图片变换
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -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
@@ -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
@@ -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: $!)"
|
||||||
|
|
||||||
|
|||||||
+8
-7
@@ -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,9 +398,9 @@
|
|||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
human_img: images.human,
|
human_img: images.human,
|
||||||
cloth_img: images.cloth,
|
|
||||||
change_desc: getChangeDesc(),
|
change_desc: getChangeDesc(),
|
||||||
};
|
};
|
||||||
|
if (images.cloth) body.cloth_img = images.cloth;
|
||||||
if (images.kuzi) body.kuzi_img = images.kuzi;
|
if (images.kuzi) body.kuzi_img = images.kuzi;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user