diff --git a/local_test/README.md b/local_test/README.md new file mode 100644 index 0000000..3cc5d4d --- /dev/null +++ b/local_test/README.md @@ -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),避免硬边 diff --git a/local_test/app.py b/local_test/app.py new file mode 100644 index 0000000..8a92bf6 --- /dev/null +++ b/local_test/app.py @@ -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) diff --git a/local_test/hair_service.log b/local_test/hair_service.log new file mode 100644 index 0000000..3babec2 --- /dev/null +++ b/local_test/hair_service.log @@ -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. diff --git a/local_test/index.html b/local_test/index.html new file mode 100644 index 0000000..6c99a37 --- /dev/null +++ b/local_test/index.html @@ -0,0 +1,177 @@ + + + + + +发型补全工具 + + + +

💇 发型补全工具

+
+ +
+

1. 上传图片 & 遮罩

+
+ + +
+
+ + +
+
+
请上传人物图片和遮罩图片
+
+
+ + +
+
+ +
+
+ + +
+

2. 对比结果

+
+
生成结果将显示在这里
+
+
+
+ + + + diff --git a/local_test/output/result.png b/local_test/output/result.png new file mode 100644 index 0000000..e2a8c8d Binary files /dev/null and b/local_test/output/result.png differ diff --git a/local_test/start.sh b/local_test/start.sh new file mode 100755 index 0000000..4832fef --- /dev/null +++ b/local_test/start.sh @@ -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 diff --git a/local_test/stop.sh b/local_test/stop.sh new file mode 100755 index 0000000..1dd25cc --- /dev/null +++ b/local_test/stop.sh @@ -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 diff --git a/local_test/test_api.py b/local_test/test_api.py new file mode 100644 index 0000000..0bdc520 --- /dev/null +++ b/local_test/test_api.py @@ -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) diff --git a/local_test/用来重绘.jpg b/local_test/用来重绘.jpg new file mode 100644 index 0000000..d068227 Binary files /dev/null and b/local_test/用来重绘.jpg differ diff --git a/local_test/用来重绘.png b/local_test/用来重绘.png new file mode 100644 index 0000000..c56a184 Binary files /dev/null and b/local_test/用来重绘.png differ