添加服务
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