添加服务
This commit is contained in:
@@ -0,0 +1,174 @@
|
|||||||
|
# 发型补全服务 API 文档
|
||||||
|
|
||||||
|
## 服务概述
|
||||||
|
|
||||||
|
本服务提供基于 ComfyUI 的发型补全(局部重绘)能力。通过传入人物图片和遮罩图片,调用 ComfyUI 工作流(`0716add-hair.json`)生成补全后的图片。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **框架**: Flask
|
||||||
|
- **依赖**: requests, Pillow, numpy
|
||||||
|
- **后端**: ComfyUI (http://127.0.0.1:8188)
|
||||||
|
|
||||||
|
## 服务地址
|
||||||
|
|
||||||
|
- **HTTP**: `http://127.0.0.1:8899`
|
||||||
|
- **前端页面**: `http://127.0.0.1:8899/`
|
||||||
|
- **API接口**: `http://127.0.0.1:8899/api/generate`
|
||||||
|
|
||||||
|
## 启动方式
|
||||||
|
|
||||||
|
### 使用脚本(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动服务
|
||||||
|
cd /home/ubuntu/hair/local_test
|
||||||
|
./start.sh
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
./stop.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 直接运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/ubuntu/hair/local_test
|
||||||
|
/home/ubuntu/ComfyUI/venv/bin/python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### POST /api/generate
|
||||||
|
|
||||||
|
调用 ComfyUI 工作流,传入图片和遮罩,返回生成结果。
|
||||||
|
|
||||||
|
#### 请求参数
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| image | File | 是 | 人物图片(支持 jpg, png 等常见格式) |
|
||||||
|
| mask | File | 是 | 遮罩图片(支持 jpg, png,遮罩区域可用红色/白色/alpha 通道标识) |
|
||||||
|
| prompt | String | 否 | 提示词,默认值:"填充遮罩区域的头发,皮肤加一点磨皮" |
|
||||||
|
|
||||||
|
#### 遮罩图片格式说明
|
||||||
|
|
||||||
|
服务支持多种遮罩格式,自动提取遮罩区域:
|
||||||
|
|
||||||
|
| 格式类型 | 示例 | 遮罩区域标识 |
|
||||||
|
|----------|------|--------------|
|
||||||
|
| 红色遮罩 | 红色画笔绘制 | R=255 的像素 |
|
||||||
|
| 白色遮罩 | 白色画笔绘制 | R=G=B=255 的像素 |
|
||||||
|
| Alpha 遮罩 | 透明背景 | A=255 的像素 |
|
||||||
|
|
||||||
|
服务会取所有通道的最大值作为遮罩强度,因此以上格式均可混用。
|
||||||
|
|
||||||
|
**注意**: 遮罩区域表示需要重绘的部分,非遮罩区域保持原图不变。
|
||||||
|
|
||||||
|
#### 请求示例(curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8899/api/generate \
|
||||||
|
-F "image=@/path/to/person.jpg" \
|
||||||
|
-F "mask=@/path/to/mask.png" \
|
||||||
|
-F "prompt=填充遮罩区域的头发,皮肤加一点磨皮" \
|
||||||
|
--output result.png
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 请求示例(Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
url = "http://127.0.0.1:8899/api/generate"
|
||||||
|
files = {
|
||||||
|
"image": open("person.jpg", "rb"),
|
||||||
|
"mask": open("mask.png", "rb"),
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮"
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = requests.post(url, files=files, data=data, timeout=600)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
with open("result.png", "wb") as f:
|
||||||
|
f.write(resp.content)
|
||||||
|
else:
|
||||||
|
print(f"Error: {resp.json()}")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 响应
|
||||||
|
|
||||||
|
**成功 (HTTP 200)**:
|
||||||
|
|
||||||
|
返回 PNG 图片二进制数据,Content-Type: `image/png`。
|
||||||
|
|
||||||
|
**失败 (HTTP 4xx/5xx)**:
|
||||||
|
|
||||||
|
返回 JSON 格式错误信息:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "错误描述"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 错误码
|
||||||
|
|
||||||
|
| 状态码 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| 500 | 内部错误(文件处理失败、ComfyUI 返回错误等) |
|
||||||
|
| 503 | 无法连接到 ComfyUI(服务未启动或端口错误) |
|
||||||
|
| 500 | 超时(工作流执行超过 5 分钟) |
|
||||||
|
|
||||||
|
## 工作流说明
|
||||||
|
|
||||||
|
服务使用的工作流 `0716add-hair.json` 包含以下处理步骤:
|
||||||
|
|
||||||
|
1. **加载模型**: Flux 2 Klein 9B (FP8) + Qwen 3.8B CLIP
|
||||||
|
2. **图片上传**: 将原图与遮罩合成为 RGBA 格式上传至 ComfyUI
|
||||||
|
3. **遮罩处理**: 填充孔洞 → 转换为图像 → 缩放 → 转换回遮罩
|
||||||
|
4. **图像缩放**: 按比例缩放至合适尺寸(最大边长 1024,8 的倍数)
|
||||||
|
5. **VAE 编码**: 将图像编码为 latent
|
||||||
|
6. **采样生成**: 使用 Flux 模型 + ReferenceLatent 进行局部重绘
|
||||||
|
7. **VAE 解码**: 将 latent 解码为图像
|
||||||
|
8. **颜色匹配**: 使用 ColorMatch 保持颜色一致
|
||||||
|
9. **保存结果**: 返回生成的图片
|
||||||
|
|
||||||
|
## 前置依赖
|
||||||
|
|
||||||
|
启动服务前需确保:
|
||||||
|
|
||||||
|
1. **ComfyUI 已启动**: `http://127.0.0.1:8188` 可访问
|
||||||
|
2. **模型文件存在**:
|
||||||
|
- `models/unet/flux2.0/flux-2-klein-9b-fp8.safetensors`
|
||||||
|
- `models/vae/flux2-vae.safetensors`
|
||||||
|
- `models/clip/qwen_3_8b_fp8mixed.safetensors`
|
||||||
|
3. **虚拟环境已激活**: 使用 `/home/ubuntu/ComfyUI/venv/bin/python`
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/ubuntu/hair/local_test/
|
||||||
|
├── app.py # Flask 后端服务
|
||||||
|
├── index.html # 前端测试页面
|
||||||
|
├── test_api.py # API 测试脚本
|
||||||
|
├── README.md # 本文档
|
||||||
|
├── output/ # 测试结果输出目录
|
||||||
|
├── 用来重绘.jpg # 测试人物图片
|
||||||
|
└── 用来重绘.png # 测试遮罩图片
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
1. 启动 ComfyUI(`python main.py --listen`)
|
||||||
|
2. 启动本服务(`python app.py`)
|
||||||
|
3. 调用 API 或访问前端页面上传图片和遮罩
|
||||||
|
4. 等待生成完成(通常 30-60 秒)
|
||||||
|
5. 获取返回的 PNG 图片
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 请求超时时间为 5 分钟,生成复杂图片可能需要较长时间
|
||||||
|
- 遮罩图片尺寸需与人物图片一致,服务会自动缩放对齐
|
||||||
|
- 建议使用红色或白色绘制遮罩,确保遮罩强度足够
|
||||||
|
- 服务会自动对遮罩边缘进行高斯模糊(radius=4),避免硬边
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Hair inpainting service - calls ComfyUI workflow with image + mask."""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
import requests
|
||||||
|
from flask import Flask, request, jsonify, send_file
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageFilter
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
COMFYUI_URL = "http://127.0.0.1:8188"
|
||||||
|
|
||||||
|
|
||||||
|
def build_workflow(image_filename, prompt_text, seed=None):
|
||||||
|
"""Build ComfyUI API workflow from the 0716add-hair.json structure."""
|
||||||
|
if seed is None:
|
||||||
|
seed = random.randint(0, 2**53)
|
||||||
|
|
||||||
|
return {
|
||||||
|
# Loaders
|
||||||
|
"16": {"class_type": "UNETLoader", "inputs": {
|
||||||
|
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
|
||||||
|
"weight_dtype": "fp8_e4m3fn"}},
|
||||||
|
"3": {"class_type": "VAELoader", "inputs": {
|
||||||
|
"vae_name": "flux2-vae.safetensors"}},
|
||||||
|
"61": {"class_type": "CLIPLoader", "inputs": {
|
||||||
|
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
||||||
|
"type": "flux2",
|
||||||
|
"device": "default"}},
|
||||||
|
|
||||||
|
# Input image (with mask in alpha channel)
|
||||||
|
"26": {"class_type": "LoadImage", "inputs": {
|
||||||
|
"image": image_filename}},
|
||||||
|
|
||||||
|
# Prompt
|
||||||
|
"60": {"class_type": "JjkText", "inputs": {
|
||||||
|
"text": prompt_text}},
|
||||||
|
"22": {"class_type": "CLIPTextEncode", "inputs": {
|
||||||
|
"clip": ["61", 0],
|
||||||
|
"text": ["60", 0]}},
|
||||||
|
|
||||||
|
# Image size
|
||||||
|
"31": {"class_type": "easy imageSize", "inputs": {
|
||||||
|
"image": ["26", 0]}},
|
||||||
|
|
||||||
|
# Mask processing: fill holes -> convert to image -> scale -> back to mask
|
||||||
|
"33": {"class_type": "Mask Fill Holes", "inputs": {
|
||||||
|
"masks": ["26", 1]}},
|
||||||
|
"36": {"class_type": "Convert Masks to Images", "inputs": {
|
||||||
|
"masks": ["33", 0]}},
|
||||||
|
"39": {"class_type": "ImageScale", "inputs": {
|
||||||
|
"image": ["36", 0],
|
||||||
|
"upscale_method": "nearest-exact",
|
||||||
|
"width": ["31", 0],
|
||||||
|
"height": ["31", 1],
|
||||||
|
"crop": "disabled"}},
|
||||||
|
"37": {"class_type": "Image To Mask", "inputs": {
|
||||||
|
"image": ["39", 0],
|
||||||
|
"method": "intensity"}},
|
||||||
|
|
||||||
|
# Scale image+mask by aspect ratio
|
||||||
|
"32": {"class_type": "LayerUtility: ImageScaleByAspectRatio V2", "inputs": {
|
||||||
|
"image": ["26", 0],
|
||||||
|
"mask": ["37", 0],
|
||||||
|
"aspect_ratio": "custom",
|
||||||
|
"proportional_width": ["31", 0],
|
||||||
|
"proportional_height": ["31", 1],
|
||||||
|
"fit": "letterbox",
|
||||||
|
"method": "lanczos",
|
||||||
|
"round_to_multiple": "8",
|
||||||
|
"scale_to_side": "None",
|
||||||
|
"scale_to_length": 1024,
|
||||||
|
"background_color": "#000000"}},
|
||||||
|
|
||||||
|
# Preview (pass_through=true, just passes the image through)
|
||||||
|
"44": {"class_type": "ImageAndMaskPreview", "inputs": {
|
||||||
|
"image": ["32", 0],
|
||||||
|
"mask": ["32", 1],
|
||||||
|
"mask_opacity": 1,
|
||||||
|
"mask_color": "FFFF00",
|
||||||
|
"pass_through": True}},
|
||||||
|
|
||||||
|
# Get size of scaled image
|
||||||
|
"14": {"class_type": "GetImageSize+", "inputs": {
|
||||||
|
"image": ["44", 0]}},
|
||||||
|
|
||||||
|
# VAE encode the image
|
||||||
|
"13": {"class_type": "VAEEncode", "inputs": {
|
||||||
|
"pixels": ["44", 0],
|
||||||
|
"vae": ["3", 0]}},
|
||||||
|
|
||||||
|
# Flux model setup
|
||||||
|
"2": {"class_type": "ModelSamplingFlux", "inputs": {
|
||||||
|
"model": ["16", 0],
|
||||||
|
"max_shift": 1.15,
|
||||||
|
"base_shift": 0.5,
|
||||||
|
"width": ["14", 0],
|
||||||
|
"height": ["14", 1]}},
|
||||||
|
"19": {"class_type": "FluxGuidance", "inputs": {
|
||||||
|
"conditioning": ["22", 0],
|
||||||
|
"guidance": 1}},
|
||||||
|
"5": {"class_type": "ReferenceLatent", "inputs": {
|
||||||
|
"conditioning": ["19", 0],
|
||||||
|
"latent": ["13", 0]}},
|
||||||
|
|
||||||
|
# Empty latent for sampling
|
||||||
|
"7": {"class_type": "EmptySD3LatentImage", "inputs": {
|
||||||
|
"width": ["14", 0],
|
||||||
|
"height": ["14", 1],
|
||||||
|
"batch_size": 1}},
|
||||||
|
|
||||||
|
# Scheduler & guider
|
||||||
|
"1": {"class_type": "BasicScheduler", "inputs": {
|
||||||
|
"model": ["2", 0],
|
||||||
|
"scheduler": "simple",
|
||||||
|
"steps": 6,
|
||||||
|
"denoise": 1}},
|
||||||
|
"20": {"class_type": "BasicGuider", "inputs": {
|
||||||
|
"model": ["2", 0],
|
||||||
|
"conditioning": ["5", 0]}},
|
||||||
|
|
||||||
|
# Noise & sampler
|
||||||
|
"6": {"class_type": "RandomNoise", "inputs": {
|
||||||
|
"noise_seed": seed}},
|
||||||
|
"8": {"class_type": "KSamplerSelect", "inputs": {
|
||||||
|
"sampler_name": "euler"}},
|
||||||
|
"9": {"class_type": "SamplerCustomAdvanced", "inputs": {
|
||||||
|
"noise": ["6", 0],
|
||||||
|
"guider": ["20", 0],
|
||||||
|
"sampler": ["8", 0],
|
||||||
|
"sigmas": ["1", 0],
|
||||||
|
"latent_image": ["7", 0]}},
|
||||||
|
|
||||||
|
# VAE decode
|
||||||
|
"10": {"class_type": "VAEDecode", "inputs": {
|
||||||
|
"samples": ["9", 0],
|
||||||
|
"vae": ["3", 0]}},
|
||||||
|
|
||||||
|
# Color match with original image
|
||||||
|
"62": {"class_type": "ColorMatch", "inputs": {
|
||||||
|
"image_ref": ["26", 0],
|
||||||
|
"image_target": ["10", 0],
|
||||||
|
"method": "mkl",
|
||||||
|
"strength": 1,
|
||||||
|
"multithread": True}},
|
||||||
|
|
||||||
|
# Save result
|
||||||
|
"17": {"class_type": "SaveImage", "inputs": {
|
||||||
|
"images": ["62", 0],
|
||||||
|
"filename_prefix": "hair_inpaint"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return send_file("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/generate", methods=["POST"])
|
||||||
|
def generate():
|
||||||
|
try:
|
||||||
|
image_file = request.files["image"]
|
||||||
|
mask_file = request.files["mask"]
|
||||||
|
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮")
|
||||||
|
|
||||||
|
# Load original image as RGB
|
||||||
|
image = Image.open(image_file).convert("RGB")
|
||||||
|
|
||||||
|
# Load mask and extract mask data from ALL channels (R, G, B, A)
|
||||||
|
# This handles different mask formats:
|
||||||
|
# - Red mask (R=255 where drawn): user-provided PNG
|
||||||
|
# - White mask (R=G=B=255 where drawn): frontend canvas
|
||||||
|
# - Alpha mask (A=255 where drawn): transparent brush
|
||||||
|
mask_img = Image.open(mask_file).convert("RGBA")
|
||||||
|
mask_arr = np.array(mask_img)
|
||||||
|
# Use max of all channels: 255 where any color/alpha is drawn, 0 where empty
|
||||||
|
mask_data = np.max(mask_arr, axis=2) # (H, W) uint8
|
||||||
|
|
||||||
|
# Ensure mask matches image size
|
||||||
|
mask_data_img = Image.fromarray(mask_data, mode="L")
|
||||||
|
if mask_data_img.size != image.size:
|
||||||
|
mask_data_img = mask_data_img.resize(image.size, Image.LANCZOS)
|
||||||
|
|
||||||
|
# Apply slight blur for soft edges (similar to ComfyUI's brush)
|
||||||
|
mask_data_img = mask_data_img.filter(ImageFilter.GaussianBlur(radius=4))
|
||||||
|
|
||||||
|
# ComfyUI LoadImage: mask = 1.0 - (alpha/255)
|
||||||
|
# So alpha=0 -> mask=1.0 (inpaint), alpha=255 -> mask=0.0 (keep)
|
||||||
|
# We want: drawn area (mask_data=255) -> inpaint -> alpha=0
|
||||||
|
# undrawn area (mask_data=0) -> keep -> alpha=255
|
||||||
|
comfyui_alpha = Image.eval(mask_data_img, lambda x: 255 - x)
|
||||||
|
|
||||||
|
# Combine into RGBA (split RGB into separate channels first)
|
||||||
|
r, g, b = image.split()
|
||||||
|
rgba = Image.merge("RGBA", (r, g, b, comfyui_alpha))
|
||||||
|
|
||||||
|
# Upload to ComfyUI
|
||||||
|
img_bytes = io.BytesIO()
|
||||||
|
rgba.save(img_bytes, format="PNG")
|
||||||
|
img_bytes.seek(0)
|
||||||
|
|
||||||
|
upload_resp = requests.post(
|
||||||
|
f"{COMFYUI_URL}/upload/image",
|
||||||
|
files={"image": ("hair_input.png", img_bytes, "image/png")},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
upload_data = upload_resp.json()
|
||||||
|
if "name" not in upload_data:
|
||||||
|
return jsonify({"error": f"Upload failed: {upload_data}"}), 500
|
||||||
|
image_filename = upload_data["name"]
|
||||||
|
|
||||||
|
# Build and queue workflow
|
||||||
|
workflow = build_workflow(image_filename, prompt_text)
|
||||||
|
prompt_resp = requests.post(
|
||||||
|
f"{COMFYUI_URL}/prompt",
|
||||||
|
json={"prompt": workflow},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
prompt_data = prompt_resp.json()
|
||||||
|
if "error" in prompt_data:
|
||||||
|
return jsonify({"error": json.dumps(prompt_data["error"], ensure_ascii=False)}), 500
|
||||||
|
prompt_id = prompt_data["prompt_id"]
|
||||||
|
|
||||||
|
# Poll for completion (5 min timeout)
|
||||||
|
for _ in range(150):
|
||||||
|
time.sleep(2)
|
||||||
|
history_resp = requests.get(
|
||||||
|
f"{COMFYUI_URL}/history/{prompt_id}", timeout=10
|
||||||
|
)
|
||||||
|
history_data = history_resp.json()
|
||||||
|
if prompt_id in history_data:
|
||||||
|
status = history_data[prompt_id].get("status", {})
|
||||||
|
if status.get("status_str") == "error":
|
||||||
|
return jsonify({"error": "Workflow execution failed"}), 500
|
||||||
|
outputs = history_data[prompt_id].get("outputs", {})
|
||||||
|
if "17" in outputs: # SaveImage node
|
||||||
|
image_info = outputs["17"]["images"][0]
|
||||||
|
filename = image_info["filename"]
|
||||||
|
subfolder = image_info.get("subfolder", "")
|
||||||
|
img_type = image_info.get("type", "output")
|
||||||
|
view_resp = requests.get(
|
||||||
|
f"{COMFYUI_URL}/view",
|
||||||
|
params={"filename": filename, "subfolder": subfolder, "type": img_type},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
return send_file(
|
||||||
|
io.BytesIO(view_resp.content), mimetype="image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({"error": "Timeout: workflow did not complete in 5 minutes"}), 500
|
||||||
|
|
||||||
|
except requests.ConnectionError:
|
||||||
|
return jsonify({"error": "Cannot connect to ComfyUI at " + COMFYUI_URL + ". Is it running?"}), 503
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8899, debug=False)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
* Serving Flask app 'app'
|
||||||
|
* Debug mode: off
|
||||||
|
Address already in use
|
||||||
|
Port 8899 is in use by another program. Either identify and stop that program, or start the server with a different port.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>发型补全工具</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; padding: 20px; }
|
||||||
|
h1 { text-align: center; margin-bottom: 20px; color: #e94560; font-size: 28px; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||||
|
.panel { background: #16213e; border-radius: 12px; padding: 20px; }
|
||||||
|
.panel h2 { margin-bottom: 16px; font-size: 18px; color: #e94560; }
|
||||||
|
.controls { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; align-items: center; }
|
||||||
|
.controls label { font-size: 14px; color: #aaa; }
|
||||||
|
input[type="file"] { color: #ddd; }
|
||||||
|
input[type="text"] { flex: 1; min-width: 200px; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; background: #0f3460; color: #eee; font-size: 14px; }
|
||||||
|
button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.2s; }
|
||||||
|
.btn-upload { background: #0f3460; color: #eee; border: 1px solid #e94560; }
|
||||||
|
.btn-upload:hover { background: #1a4a7a; }
|
||||||
|
.btn-generate { background: #e94560; color: #fff; font-size: 16px; padding: 12px 36px; }
|
||||||
|
.btn-generate:hover { background: #c73650; }
|
||||||
|
.btn-generate:disabled { background: #555; cursor: not-allowed; }
|
||||||
|
.image-wrapper { display: flex; gap: 12px; flex-wrap: wrap; border: 2px dashed #444; border-radius: 8px; padding: 12px; min-height: 300px; background: #0f3460; }
|
||||||
|
.image-item { flex: 1; min-width: 200px; }
|
||||||
|
.image-item img { max-width: 100%; border-radius: 6px; }
|
||||||
|
.image-item h4 { font-size: 12px; color: #aaa; margin-bottom: 6px; }
|
||||||
|
.placeholder { color: #666; font-size: 16px; text-align: center; padding: 60px 20px; width: 100%; }
|
||||||
|
.result-area { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||||
|
.result-area img { max-width: 100%; border-radius: 8px; }
|
||||||
|
.result-item { flex: 1; min-width: 250px; }
|
||||||
|
.result-item h3 { font-size: 14px; color: #aaa; margin-bottom: 8px; text-align: center; }
|
||||||
|
.loading { text-align: center; padding: 40px; color: #e94560; font-size: 18px; }
|
||||||
|
.loading .spinner { display: inline-block; width: 40px; height: 40px; border: 4px solid #333; border-top-color: #e94560; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 12px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.error { color: #ff6b6b; padding: 16px; background: #2a1a1a; border-radius: 8px; margin-top: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>💇 发型补全工具</h1>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Left: Input -->
|
||||||
|
<div class="panel">
|
||||||
|
<h2>1. 上传图片 & 遮罩</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<label>人物图片:</label>
|
||||||
|
<input type="file" id="imageInput" accept="image/*" class="btn-upload">
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<label>遮罩图片:</label>
|
||||||
|
<input type="file" id="maskInput" accept="image/*" class="btn-upload">
|
||||||
|
</div>
|
||||||
|
<div class="image-wrapper" id="imageWrapper">
|
||||||
|
<div class="placeholder" id="placeholder">请上传人物图片和遮罩图片</div>
|
||||||
|
</div>
|
||||||
|
<div class="controls" style="margin-top:16px">
|
||||||
|
<label>提示词:</label>
|
||||||
|
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮">
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center; margin-top:16px">
|
||||||
|
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Result -->
|
||||||
|
<div class="panel">
|
||||||
|
<h2>2. 对比结果</h2>
|
||||||
|
<div id="resultArea">
|
||||||
|
<div class="placeholder">生成结果将显示在这里</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const imageInput = document.getElementById('imageInput');
|
||||||
|
const maskInput = document.getElementById('maskInput');
|
||||||
|
const imageWrapper = document.getElementById('imageWrapper');
|
||||||
|
const placeholder = document.getElementById('placeholder');
|
||||||
|
const promptInput = document.getElementById('promptInput');
|
||||||
|
const generateBtn = document.getElementById('generateBtn');
|
||||||
|
const resultArea = document.getElementById('resultArea');
|
||||||
|
|
||||||
|
let originalImage = null;
|
||||||
|
let maskImage = null;
|
||||||
|
|
||||||
|
imageInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
originalImage = { img: img, file: file };
|
||||||
|
updatePreview();
|
||||||
|
checkReady();
|
||||||
|
};
|
||||||
|
img.src = ev.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
maskInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
maskImage = { img: img, file: file };
|
||||||
|
updatePreview();
|
||||||
|
checkReady();
|
||||||
|
};
|
||||||
|
img.src = ev.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
let html = '';
|
||||||
|
if (originalImage) {
|
||||||
|
html += `<div class="image-item"><h4>人物图片</h4><img src="${originalImage.img.src}" alt="原图"></div>`;
|
||||||
|
}
|
||||||
|
if (maskImage) {
|
||||||
|
html += `<div class="image-item"><h4>遮罩图片</h4><img src="${maskImage.img.src}" alt="遮罩"></div>`;
|
||||||
|
}
|
||||||
|
imageWrapper.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkReady() {
|
||||||
|
generateBtn.disabled = !(originalImage && maskImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateBtn.addEventListener('click', async () => {
|
||||||
|
if (!originalImage || !maskImage) return;
|
||||||
|
|
||||||
|
generateBtn.disabled = true;
|
||||||
|
generateBtn.textContent = '⏳ 生成中...';
|
||||||
|
resultArea.innerHTML = '<div class="loading"><div class="spinner"></div><br>正在调用 ComfyUI 生成,请耐心等待...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', originalImage.file, 'original.' + originalImage.file.name.split('.').pop());
|
||||||
|
formData.append('mask', maskImage.file, 'mask.' + maskImage.file.name.split('.').pop());
|
||||||
|
formData.append('prompt', promptInput.value);
|
||||||
|
|
||||||
|
const resp = await fetch('/api/generate', { method: 'POST', body: formData });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
throw new Error(err.error || 'Generation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultBlob = await resp.blob();
|
||||||
|
const resultUrl = URL.createObjectURL(resultBlob);
|
||||||
|
|
||||||
|
resultArea.innerHTML = `
|
||||||
|
<div class="result-area">
|
||||||
|
<div class="result-item">
|
||||||
|
<h3>原图</h3>
|
||||||
|
<img src="${originalImage.img.src}" alt="原图">
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<h3>生成结果</h3>
|
||||||
|
<img src="${resultUrl}" alt="生成结果">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (err) {
|
||||||
|
resultArea.innerHTML = `<div class="error">❌ ${err.message}</div>`;
|
||||||
|
} finally {
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
generateBtn.textContent = '🚀 生成';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 910 KiB |
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 启动发型补全服务
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
PID_FILE="hair_service.pid"
|
||||||
|
LOG_FILE="hair_service.log"
|
||||||
|
|
||||||
|
# 检查是否已在运行
|
||||||
|
if [ -f "$PID_FILE" ]; then
|
||||||
|
PID=$(cat "$PID_FILE")
|
||||||
|
if kill -0 "$PID" 2>/dev/null; then
|
||||||
|
echo "服务已在运行 (PID: $PID)"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "清理无效的 PID 文件..."
|
||||||
|
rm "$PID_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 ComfyUI 是否运行
|
||||||
|
if ! curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8188/ >/dev/null 2>&1; then
|
||||||
|
echo "警告: ComfyUI 未运行 (http://127.0.0.1:8188)"
|
||||||
|
echo "请先启动 ComfyUI: python /home/ubuntu/ComfyUI/main.py --listen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
echo "启动发型补全服务..."
|
||||||
|
/home/ubuntu/ComfyUI/venv/bin/python app.py >> "$LOG_FILE" 2>&1 &
|
||||||
|
PID=$!
|
||||||
|
echo "$PID" > "$PID_FILE"
|
||||||
|
|
||||||
|
# 等待启动
|
||||||
|
sleep 2
|
||||||
|
if curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8899/ | grep -q "200"; then
|
||||||
|
echo "服务启动成功!"
|
||||||
|
echo "地址: http://127.0.0.1:8899"
|
||||||
|
echo "PID: $PID"
|
||||||
|
echo "日志: $LOG_FILE"
|
||||||
|
else
|
||||||
|
echo "服务启动失败,请检查日志: $LOG_FILE"
|
||||||
|
rm "$PID_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 停止发型补全服务
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
PID_FILE="hair_service.pid"
|
||||||
|
|
||||||
|
if [ ! -f "$PID_FILE" ]; then
|
||||||
|
echo "服务未运行"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
PID=$(cat "$PID_FILE")
|
||||||
|
|
||||||
|
if kill -0 "$PID" 2>/dev/null; then
|
||||||
|
echo "正在停止服务 (PID: $PID)..."
|
||||||
|
kill "$PID"
|
||||||
|
|
||||||
|
# 等待进程退出
|
||||||
|
for i in {1..10}; do
|
||||||
|
if ! kill -0 "$PID" 2>/dev/null; then
|
||||||
|
echo "服务已停止"
|
||||||
|
rm "$PID_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# 强制终止
|
||||||
|
echo "强制终止进程..."
|
||||||
|
kill -9 "$PID"
|
||||||
|
rm "$PID_FILE"
|
||||||
|
echo "服务已停止"
|
||||||
|
else
|
||||||
|
echo "进程已不存在,清理 PID 文件..."
|
||||||
|
rm "$PID_FILE"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test script: send image+mask to the local service and save result."""
|
||||||
|
import requests
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
SERVICE_URL = "http://127.0.0.1:8899"
|
||||||
|
IMAGE_PATH = "/home/ubuntu/hair/local_test/用来重绘.jpg"
|
||||||
|
MASK_PATH = "/home/ubuntu/hair/local_test/用来重绘.png"
|
||||||
|
OUTPUT_DIR = "/home/ubuntu/hair/local_test/output"
|
||||||
|
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"Sending image: {IMAGE_PATH}")
|
||||||
|
print(f"Sending mask: {MASK_PATH}")
|
||||||
|
|
||||||
|
with open(IMAGE_PATH, "rb") as f:
|
||||||
|
img_data = f.read()
|
||||||
|
with open(MASK_PATH, "rb") as f:
|
||||||
|
mask_data = f.read()
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
f"{SERVICE_URL}/api/generate",
|
||||||
|
files={
|
||||||
|
"image": ("original.jpg", img_data, "image/jpeg"),
|
||||||
|
"mask": ("mask.png", mask_data, "image/png"),
|
||||||
|
},
|
||||||
|
data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮"},
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Status: {resp.status_code}")
|
||||||
|
print(f"Content-Type: {resp.headers.get('Content-Type')}")
|
||||||
|
|
||||||
|
if resp.status_code == 200 and "image" in resp.headers.get("Content-Type", ""):
|
||||||
|
out_path = os.path.join(OUTPUT_DIR, "result.png")
|
||||||
|
with open(out_path, "wb") as f:
|
||||||
|
f.write(resp.content)
|
||||||
|
print(f"SUCCESS! Result saved to: {out_path}")
|
||||||
|
else:
|
||||||
|
print(f"FAILED! Response: {resp.text[:2000]}")
|
||||||
|
sys.exit(1)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
Reference in New Issue
Block a user