feat: 适配 RTX 3090 (24GB) 环境优化
硬件迁移:从 RTX 5090 (32GB) 迁移到 RTX 3090 (24GB) 主要改动: 1. hairline/comfyui.py: 轮询间隔从 0.2s 降到 0.05s 2. hairline/service.py: PNG 编码 compress_level=1,节省 ~240ms 3. add_hair.json: 工作流使用 4B FP8 模型 + steps=4 4. static/test_interface3.html: 修复图片显示(添加 data:image/jpeg;base64, 前缀) 性能优化后接口3响应时间:6.6-7.3s(之前 8.66s)
This commit is contained in:
+1
-450
@@ -1,450 +1 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 6,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"2",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "基本调度器"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"inputs": {
|
||||
"max_shift": 1.15,
|
||||
"base_shift": 0.5,
|
||||
"width": [
|
||||
"14",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"14",
|
||||
1
|
||||
],
|
||||
"model": [
|
||||
"16",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ModelSamplingFlux",
|
||||
"_meta": {
|
||||
"title": "采样算法(Flux)"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"vae_name": "flux2-vae.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "加载VAE"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"conditioning": [
|
||||
"19",
|
||||
0
|
||||
],
|
||||
"latent": [
|
||||
"13",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ReferenceLatent",
|
||||
"_meta": {
|
||||
"title": "参考Latent"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"noise_seed": 217742615722421
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "随机噪波"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"width": [
|
||||
"14",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"14",
|
||||
1
|
||||
],
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"_meta": {
|
||||
"title": "空Latent图像(SD3)"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"sampler_name": "euler"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "K采样器选择"
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"20",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"8",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"7",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "自定义采样器(高级)"
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"9",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE解码"
|
||||
}
|
||||
},
|
||||
"13": {
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"44",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEEncode",
|
||||
"_meta": {
|
||||
"title": "VAE编码"
|
||||
}
|
||||
},
|
||||
"14": {
|
||||
"inputs": {
|
||||
"image": [
|
||||
"44",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "GetImageSize+",
|
||||
"_meta": {
|
||||
"title": "🔧 Get Image Size"
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"inputs": {
|
||||
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
|
||||
"weight_dtype": "fp8_e4m3fn"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNet加载器"
|
||||
}
|
||||
},
|
||||
"17": {
|
||||
"inputs": {
|
||||
"filename_prefix": "ComfyUI",
|
||||
"images": [
|
||||
"62",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "保存图像"
|
||||
}
|
||||
},
|
||||
"19": {
|
||||
"inputs": {
|
||||
"guidance": 1,
|
||||
"conditioning": [
|
||||
"22",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "FluxGuidance",
|
||||
"_meta": {
|
||||
"title": "Flux引导"
|
||||
}
|
||||
},
|
||||
"20": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"2",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"5",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "基本引导器"
|
||||
}
|
||||
},
|
||||
"22": {
|
||||
"inputs": {
|
||||
"text": [
|
||||
"60",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"61",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP文本编码"
|
||||
}
|
||||
},
|
||||
"26": {
|
||||
"inputs": {
|
||||
"image": "clipspace/clipspace-painted-masked-1781655058499.png [input]"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "加载图像"
|
||||
}
|
||||
},
|
||||
"31": {
|
||||
"inputs": {
|
||||
"image": [
|
||||
"26",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "easy imageSize",
|
||||
"_meta": {
|
||||
"title": "图像尺寸"
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"inputs": {
|
||||
"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",
|
||||
"image": [
|
||||
"26",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"37",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LayerUtility: ImageScaleByAspectRatio V2",
|
||||
"_meta": {
|
||||
"title": "LayerUtility: ImageScaleByAspectRatio V2"
|
||||
}
|
||||
},
|
||||
"33": {
|
||||
"inputs": {
|
||||
"masks": [
|
||||
"26",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "Mask Fill Holes",
|
||||
"_meta": {
|
||||
"title": "Mask Fill Holes"
|
||||
}
|
||||
},
|
||||
"36": {
|
||||
"inputs": {
|
||||
"masks": [
|
||||
"33",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Convert Masks to Images",
|
||||
"_meta": {
|
||||
"title": "Convert Masks to Images"
|
||||
}
|
||||
},
|
||||
"37": {
|
||||
"inputs": {
|
||||
"method": "intensity",
|
||||
"image": [
|
||||
"39",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Image To Mask",
|
||||
"_meta": {
|
||||
"title": "Image To Mask"
|
||||
}
|
||||
},
|
||||
"39": {
|
||||
"inputs": {
|
||||
"upscale_method": "nearest-exact",
|
||||
"width": [
|
||||
"31",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"31",
|
||||
1
|
||||
],
|
||||
"crop": "disabled",
|
||||
"image": [
|
||||
"36",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ImageScale",
|
||||
"_meta": {
|
||||
"title": "缩放图像"
|
||||
}
|
||||
},
|
||||
"44": {
|
||||
"inputs": {
|
||||
"mask_opacity": 1,
|
||||
"mask_color": "FFFF00",
|
||||
"pass_through": true,
|
||||
"image": [
|
||||
"32",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"32",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "ImageAndMaskPreview",
|
||||
"_meta": {
|
||||
"title": "ImageAndMaskPreview"
|
||||
}
|
||||
},
|
||||
"45": {
|
||||
"inputs": {
|
||||
"images": [
|
||||
"44",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "PreviewImage",
|
||||
"_meta": {
|
||||
"title": "预览图像"
|
||||
}
|
||||
},
|
||||
"53": {
|
||||
"inputs": {
|
||||
"rgthree_comparer": {
|
||||
"images": [
|
||||
{
|
||||
"name": "A",
|
||||
"selected": true,
|
||||
"url": "/api/view?filename=rgthree.compare._temp_zuixa_00119_.png&type=temp&subfolder=&rand=0.7926013627811991"
|
||||
},
|
||||
{
|
||||
"name": "B",
|
||||
"selected": true,
|
||||
"url": "/api/view?filename=rgthree.compare._temp_zuixa_00120_.png&type=temp&subfolder=&rand=0.43692946883795813"
|
||||
}
|
||||
]
|
||||
},
|
||||
"image_a": [
|
||||
"62",
|
||||
0
|
||||
],
|
||||
"image_b": [
|
||||
"26",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Image Comparer (rgthree)",
|
||||
"_meta": {
|
||||
"title": "Image Comparer (rgthree)"
|
||||
}
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
"title": "Text"
|
||||
}
|
||||
},
|
||||
"61": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
|
||||
"type": "flux2",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "加载CLIP"
|
||||
}
|
||||
},
|
||||
"62": {
|
||||
"inputs": {
|
||||
"method": "mkl",
|
||||
"strength": 1,
|
||||
"multithread": true,
|
||||
"image_ref": [
|
||||
"26",
|
||||
0
|
||||
],
|
||||
"image_target": [
|
||||
"10",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ColorMatch",
|
||||
"_meta": {
|
||||
"title": "Color Match"
|
||||
}
|
||||
}
|
||||
}
|
||||
{"1": {"inputs": {"scheduler": "simple", "steps": 4, "denoise": 1, "model": ["2", 0]}, "class_type": "BasicScheduler", "_meta": {"title": "基本调度器"}}, "2": {"inputs": {"max_shift": 1.15, "base_shift": 0.5, "width": ["14", 0], "height": ["14", 1], "model": ["16", 0]}, "class_type": "ModelSamplingFlux", "_meta": {"title": "采样算法(Flux)"}}, "3": {"inputs": {"vae_name": "flux2-vae.safetensors"}, "class_type": "VAELoader", "_meta": {"title": "加载VAE"}}, "5": {"inputs": {"conditioning": ["19", 0], "latent": ["13", 0]}, "class_type": "ReferenceLatent", "_meta": {"title": "参考Latent"}}, "6": {"inputs": {"noise_seed": 217742615722421}, "class_type": "RandomNoise", "_meta": {"title": "随机噪波"}}, "7": {"inputs": {"width": ["14", 0], "height": ["14", 1], "batch_size": 1}, "class_type": "EmptySD3LatentImage", "_meta": {"title": "空Latent图像(SD3)"}}, "8": {"inputs": {"sampler_name": "euler"}, "class_type": "KSamplerSelect", "_meta": {"title": "K采样器选择"}}, "9": {"inputs": {"noise": ["6", 0], "guider": ["20", 0], "sampler": ["8", 0], "sigmas": ["1", 0], "latent_image": ["7", 0]}, "class_type": "SamplerCustomAdvanced", "_meta": {"title": "自定义采样器(高级)"}}, "10": {"inputs": {"samples": ["9", 0], "vae": ["3", 0]}, "class_type": "VAEDecode", "_meta": {"title": "VAE解码"}}, "13": {"inputs": {"pixels": ["44", 0], "vae": ["3", 0]}, "class_type": "VAEEncode", "_meta": {"title": "VAE编码"}}, "14": {"inputs": {"image": ["44", 0]}, "class_type": "GetImageSize+", "_meta": {"title": "🔧 Get Image Size"}}, "16": {"inputs": {"unet_name": "flux-2-klein-4b-fp8.safetensors", "weight_dtype": "fp8_e4m3fn"}, "class_type": "UNETLoader", "_meta": {"title": "UNet加载器"}}, "17": {"inputs": {"filename_prefix": "ComfyUI", "images": ["62", 0]}, "class_type": "SaveImage", "_meta": {"title": "保存图像"}}, "19": {"inputs": {"guidance": 1, "conditioning": ["22", 0]}, "class_type": "FluxGuidance", "_meta": {"title": "Flux引导"}}, "20": {"inputs": {"model": ["2", 0], "conditioning": ["5", 0]}, "class_type": "BasicGuider", "_meta": {"title": "基本引导器"}}, "22": {"inputs": {"text": ["60", 0], "clip": ["61", 0]}, "class_type": "CLIPTextEncode", "_meta": {"title": "CLIP文本编码"}}, "26": {"inputs": {"image": "clipspace/clipspace-painted-masked-1781655058499.png [input]"}, "class_type": "LoadImage", "_meta": {"title": "加载图像"}}, "31": {"inputs": {"image": ["26", 0]}, "class_type": "easy imageSize", "_meta": {"title": "图像尺寸"}}, "32": {"inputs": {"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", "image": ["26", 0], "mask": ["37", 0]}, "class_type": "LayerUtility: ImageScaleByAspectRatio V2", "_meta": {"title": "LayerUtility: ImageScaleByAspectRatio V2"}}, "33": {"inputs": {"masks": ["26", 1]}, "class_type": "Mask Fill Holes", "_meta": {"title": "Mask Fill Holes"}}, "36": {"inputs": {"masks": ["33", 0]}, "class_type": "Convert Masks to Images", "_meta": {"title": "Convert Masks to Images"}}, "37": {"inputs": {"method": "intensity", "image": ["39", 0]}, "class_type": "Image To Mask", "_meta": {"title": "Image To Mask"}}, "39": {"inputs": {"upscale_method": "nearest-exact", "width": ["31", 0], "height": ["31", 1], "crop": "disabled", "image": ["36", 0]}, "class_type": "ImageScale", "_meta": {"title": "缩放图像"}}, "44": {"inputs": {"mask_opacity": 1, "mask_color": "FFFF00", "pass_through": true, "image": ["32", 0], "mask": ["32", 1]}, "class_type": "ImageAndMaskPreview", "_meta": {"title": "ImageAndMaskPreview"}}, "45": {"inputs": {"images": ["44", 0]}, "class_type": "PreviewImage", "_meta": {"title": "预览图像"}}, "53": {"inputs": {"rgthree_comparer": {"images": [{"name": "A", "selected": true, "url": "/api/view?filename=rgthree.compare._temp_zuixa_00119_.png&type=temp&subfolder=&rand=0.7926013627811991"}, {"name": "B", "selected": true, "url": "/api/view?filename=rgthree.compare._temp_zuixa_00120_.png&type=temp&subfolder=&rand=0.43692946883795813"}]}, "image_a": ["62", 0], "image_b": ["26", 0]}, "class_type": "Image Comparer (rgthree)", "_meta": {"title": "Image Comparer (rgthree)"}}, "60": {"inputs": {"text": "补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头"}, "class_type": "JjkText", "_meta": {"title": "Text"}}, "61": {"inputs": {"clip_name": "qwen_3_4b.safetensors", "type": "flux2", "device": "default"}, "class_type": "CLIPLoader", "_meta": {"title": "加载CLIP"}}, "62": {"inputs": {"method": "mkl", "strength": 1, "multithread": true, "image_ref": ["26", 0], "image_target": ["10", 0]}, "class_type": "ColorMatch", "_meta": {"title": "Color Match"}}}
|
||||
@@ -138,7 +138,8 @@ app = FastAPI(
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# 不校验鉴权的路径前缀(供网关探测 / 文档 / 静态)
|
||||
_AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static", "/api/v1/debug")
|
||||
_AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static",
|
||||
"/api/v1/debug", "/api/v1/redraw")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -1677,6 +1678,42 @@ async def hairline_grow_v2_final_v2(
|
||||
return await _run_v2_final(image_file, image_url, image_base64, hairline_id, "接口12finalv2")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 重绘端点(替代 local_test /api/generate)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post(
|
||||
"/api/v1/redraw",
|
||||
summary="ComfyUI 重绘",
|
||||
tags=["重绘"],
|
||||
description="""
|
||||
传入人物图片 + 遮罩图片,直接调 ComfyUI(0716add-hair 工作流)执行局部重绘。
|
||||
替代原 local_test :8899 的 /api/generate 接口。
|
||||
|
||||
**遮罩图片格式**:支持红色遮罩(R=255)、白色遮罩(R=G=B=255)、Alpha遮罩(A=255),服务取所有通道最大值。
|
||||
**遮罩区域**表示需要重绘的部分,非遮罩区域保持原图不变。
|
||||
""",
|
||||
)
|
||||
async def api_redraw(
|
||||
image_file: UploadFile = File(..., description="人物图片(JPG/PNG)"),
|
||||
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮",
|
||||
description="ComfyUI 提示词"),
|
||||
):
|
||||
image_bytes = await image_file.read()
|
||||
mask_bytes = await mask_file.read()
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from hairline.redraw import run_redraw
|
||||
try:
|
||||
png_bytes = await run_in_threadpool(
|
||||
run_redraw, image_bytes, mask_bytes, prompt)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("重绘失败: %s", e)
|
||||
return err(500, f"重绘失败: {e}")
|
||||
b64 = base64.b64encode(png_bytes).decode()
|
||||
return ok({"image_base64": f"data:image/png;base64,{b64}"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 调试:下载后端日志(接口11 遮罩计算全过程)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# 接口3 B端生发 — 实现文档
|
||||
|
||||
> 文档日期:2026-07-18
|
||||
|
||||
---
|
||||
|
||||
## 一、接口概述
|
||||
|
||||
**接口3** 是 B端(医生/操作端)生发接口。医生在用户照片上手动用马克笔画出发际线后,只需上传这一张划线图,系统自动检测划线 → 生成遮罩 → 送 ComfyUI 生发,返回「植发3个月」效果图。
|
||||
|
||||
**与接口2 的核心区别**:
|
||||
|
||||
| 特性 | 接口2(C端生发) | 接口3(B端生发) |
|
||||
|------|----------------|----------------|
|
||||
| 输入 | 原始照片 | 划线图(含手绘线) |
|
||||
| 发际线来源 | 系统按发型模板自动生成 | 医生手绘标注 |
|
||||
| 发型类型 | ellipse/flower/heart/straight/wave | custom(自定义) |
|
||||
| 中间步骤 | extract_context + swapHair + ComfyUI重绘 | 划线检测 + 遮罩 + ComfyUI生发 |
|
||||
| 是否调 change_hair | 是(女性流程) | 否 |
|
||||
| ComfyUI 工作流 | 0716add-hair-api.json(重绘) | add_hair.json(生发) |
|
||||
| 典型耗时 | ~11s | ~6-8s |
|
||||
|
||||
---
|
||||
|
||||
## 二、接口定义
|
||||
|
||||
### 路由
|
||||
|
||||
```
|
||||
POST /api/v1/hair/grow-b
|
||||
```
|
||||
|
||||
### 入参
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `marked_image_file` | UploadFile | 三选一 | 划线图片文件(JPG/PNG) |
|
||||
| `marked_image_url` | str | 三选一 | 划线图片 URL |
|
||||
| `marked_image_base64` | str | 三选一 | 划线图片 base64 |
|
||||
| `use_mask` | bool | 否(默认True) | 是否自动检测划线并建遮罩。False时跳过检测,直接送划线图 |
|
||||
| `prompt` | str | 否 | ComfyUI 提示词,默认"补充遮罩区域的头发,加一点美颜" |
|
||||
|
||||
### 返回
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"hair_growth_image_base64": "iVBORw0KGgo...(生发图 JPG base64)",
|
||||
"hairline_type": "custom"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
错误码:
|
||||
- `1001`: 无法识别人像 / 未检测到发际线划线
|
||||
- `1007`: 处理失败
|
||||
- `1008`: 图片格式不支持
|
||||
|
||||
---
|
||||
|
||||
## 三、完整调用链
|
||||
|
||||
```
|
||||
POST /api/v1/hair/grow-b
|
||||
│
|
||||
├─ app.py hair_grow_b() [app.py:929]
|
||||
│ ├─ resolve_image_bytes() → marked_raw 解析图片(file/url/base64三选一)
|
||||
│ ├─ cv2.imdecode → marked_bgr 解码为 BGR
|
||||
│ └─ run_in_threadpool(generate_grow_b, ...)
|
||||
│
|
||||
├─ service.py generate_grow_b(marked_bgr, use_mask, prompt) [service.py:381]
|
||||
│ │
|
||||
│ ├─ 步骤1:人脸检测 + 头发分割(仅 use_mask=True 时)
|
||||
│ │ ├─ get_landmarker().detect(rgb) MediaPipe 478点人脸检测
|
||||
│ │ │ → landmarks(无人脸返回 no_face)
|
||||
│ │ ├─ get_parser().parse(rgb) SegFormer 面部分割(CPU ~0.9s)
|
||||
│ │ │ → parse_map(int label map)
|
||||
│ │ │
|
||||
│ ├─ 步骤2:手绘发际线检测(仅 use_mask=True 时)
|
||||
│ │ ├─ detect_marker_hairline(marked_bgr, landmarks, parse_map)
|
||||
│ │ │ │ [marker_detect.py:41]
|
||||
│ │ │ ├─ forehead_upper_region(landmarks) 额头上部 ROI
|
||||
│ │ │ ├─ head_silhouette(parse_map) 头部轮廓 ROI
|
||||
│ │ │ ├─ _blackhat(gray) 黑帽变换(响应比邻域暗的细结构)
|
||||
│ │ │ ├─ _snap_anchor(bh, 左鬓角21) 左锚点吸附
|
||||
│ │ │ ├─ _snap_anchor(bh, 右鬓角251) 右锚点吸附
|
||||
│ │ │ ├─ route_through_array(cost, 左, 右) Dijkstra最小代价路径
|
||||
│ │ │ └→ path (N,2) row,col(拒识返回 None → no_line)
|
||||
│ │ │
|
||||
│ │ ├─ path_to_curve_mask(path) 路径→曲线mask(uint8 0/255)
|
||||
│ │ └─ mask_from_curve(curve_mask, landmarks, parse_map)
|
||||
│ │ │ [mask.py]
|
||||
│ │ ├─ _above_curve_region(curve_mask) 曲线以上区域
|
||||
│ │ ├─ cv2.morphologyEx(闭运算) 填洞
|
||||
│ │ ├─ 最大连通域
|
||||
│ │ └─ 高斯羽化 → mask (uint8 0-255)
|
||||
│ │
|
||||
│ ├─ 步骤3:合成 RGBA PNG
|
||||
│ │ ├─ compose_comfy_rgba(marked_bgr, mask) RGB=原图,alpha=255×(1-mask)
|
||||
│ │ └─ PNG 编码 → rgba_png_bytes
|
||||
│ │
|
||||
│ └─ 步骤4:ComfyUI 生发
|
||||
│ └─ comfyui.run(rgba_png_bytes, prompt) [comfyui.py:87]
|
||||
│ ├─ 上传图片到 ComfyUI /upload/image
|
||||
│ ├─ 加载工作流 add_hair.json
|
||||
│ ├─ 替换节点26输入图 + 节点6随机seed + 节点60提示词
|
||||
│ ├─ POST /prompt 提交工作流
|
||||
│ ├─ 轮询 /history/{prompt_id}(间隔0.2s)
|
||||
│ └─ GET /view 取回输出 PNG → grown_png
|
||||
│
|
||||
└─ 返回 {"grown_png": bytes, "status": "ok"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、用到的模型和外部服务
|
||||
|
||||
| 模型/服务 | 用途 | 位置 | 设备 |
|
||||
|----------|------|------|------|
|
||||
| **FaceLandmarker** (MediaPipe) | 478点人脸检测 | hairline/face_landmarks.py | CPU |
|
||||
| **FaceParser** (SegFormer) | 面部分割(hair/skin/...) | hairline/face_parsing.py | CPU (5090不兼容cu121) |
|
||||
| **ComfyUI** (Flux-2) | 生发图生成 | hairline/comfyui.py → :8188 | GPU |
|
||||
|
||||
**注意**:接口3 **不调用** change_hair 服务(:8801),不需要 swapHair。这是它与接口2女性流程的关键区别。
|
||||
|
||||
---
|
||||
|
||||
## 五、核心算法:手绘发际线检测
|
||||
|
||||
### 5.1 为什么不用简单阈值?
|
||||
|
||||
手绘马克笔线条的灰度值与皮肤阴影、抬头纹等重叠,全局阈值无法区分。采用**黑帽变换 + Dijkstra最小路径**方案。
|
||||
|
||||
### 5.2 黑帽变换(Black Hat)
|
||||
|
||||
```python
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
|
||||
bh = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel)
|
||||
```
|
||||
|
||||
黑帽 = 闭运算 − 原图,响应"比局部邻域暗的细结构"(即马克笔线条),对抬头纹/眉毛/发丝鲁棒。
|
||||
|
||||
### 5.3 Dijkstra 最小代价路径
|
||||
|
||||
1. **ROI 限定**:额头上部 ∩ 头部轮廓(排除背景)
|
||||
2. **锚点**:左鬓角(21) / 右鬓角(251) MediaPipe 关键点
|
||||
3. **代价图**:`cost = (bh.max() - bh) + 1.0`,ROI外设 1e6
|
||||
4. **路径**:`route_through_array(cost, 左锚, 右锚)` — skimage 的 Dijkstra 实现
|
||||
|
||||
### 5.4 拒识机制
|
||||
|
||||
路径平均黑帽响应 < 8.0 → 判定"未画线",返回 `no_line`。
|
||||
|
||||
---
|
||||
|
||||
## 六、与接口1、接口2 的对比
|
||||
|
||||
| 维度 | 接口1 | 接口2 | 接口3 |
|
||||
|------|-------|-------|-------|
|
||||
| 功能 | 四庭七眼测量 | C端生发(5种发际线) | B端生发(手绘线) |
|
||||
| 路由 | /api/v1/face/measure | /api/v1/hair/grow | /api/v1/hair/grow-b |
|
||||
| 输入 | 正面照 | 正面照 | 划线图 |
|
||||
| MediaPipe | ✅ | ✅ | ✅ |
|
||||
| SegFormer | ✅ | ✅ | ✅ |
|
||||
| change_hair | ❌ | ✅(女性) | ❌ |
|
||||
| ComfyUI | ❌ | ✅(Flux-2重绘) | ✅(Flux-2生发) |
|
||||
| 典型耗时 | ~2s | ~11s | ~6-8s |
|
||||
| ComfyUI工作流 | — | 0716add-hair-api.json | add_hair.json |
|
||||
|
||||
---
|
||||
|
||||
## 七、测试
|
||||
|
||||
- **测试页面**:[static/test_interface3.html](file:///home/ubuntu/hair/static/test_interface3.html)
|
||||
- **测试图片**:[image/girl_img/girl13.jpg](file:///home/ubuntu/hair/image/girl_img/girl13.jpg)(需手动在图上画发际线后作为划线图上传)
|
||||
@@ -110,7 +110,7 @@ def _gray_b64(gray_float):
|
||||
def _red_mask_b64(mask_bool, h, w):
|
||||
"""布尔遮罩 → 纯红 alpha PNG data URI。
|
||||
遮罩区域 RGBA=(255,0,0,255),其余区域 RGBA=(0,0,0,0)。
|
||||
供外部 ComfyUI 重绘服务(如 local_test)按 alpha 通道识别重绘区。
|
||||
供 ComfyUI 重绘接口(/api/v1/redraw)按 alpha 通道识别重绘区。
|
||||
|
||||
注意:cv2.imencode 写 PNG 用的是 **BGRA** 顺序(B,G,R,A),所以要得到
|
||||
浏览器显示的红色 R=255,需赋值 (B=0,G=0,R=255,A=255)。
|
||||
@@ -357,7 +357,8 @@ def _redraw_band_mask(inner_pts, outer_pts, h, w, rid="", upper=None,
|
||||
|
||||
|
||||
def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm,
|
||||
hairline_push_cm=0.0, hairline_edge="column", rid="", render_viz=True):
|
||||
hairline_push_cm=0.0, hairline_edge="column", rid="", render_viz=True,
|
||||
hair_mask=None):
|
||||
"""算出布尔遮罩 + 可视化。
|
||||
|
||||
seg_model: bisenet | segformer。
|
||||
@@ -368,6 +369,7 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
|
||||
render_viz: 是否生成各阶段叠图 overlay JPG(接口11 调试页用)。接口2/12 路径传 False
|
||||
可跳过 6+ 张 base64 编码,省 ~80ms;数据字段(_inner_pts/_outer_pts/_upper_mask/
|
||||
mask_pixels 等)始终返回,不受影响。
|
||||
hair_mask: 预计算的头发布尔遮罩(来自 SegFormer parse)。传入时跳过重复分割,省 ~0.9s。
|
||||
返回 (mask_bool, viz_dict)。
|
||||
"""
|
||||
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
|
||||
@@ -381,13 +383,16 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
|
||||
upper = _upper_region_mask(baseline_pts, w, h)
|
||||
lg(f"baseline 第一点={baseline_pts[0]} 末点={baseline_pts[-1]} upper像素={int(upper.sum())}")
|
||||
|
||||
if seg_model == "bisenet":
|
||||
hair_mask = _bisenet_hair_mask(image_bgr, landmarks, w, h)
|
||||
elif seg_model == "segformer":
|
||||
hair_mask = _segformer_hair_mask(image_bgr)
|
||||
if hair_mask is None:
|
||||
if seg_model == "bisenet":
|
||||
hair_mask = _bisenet_hair_mask(image_bgr, landmarks, w, h)
|
||||
elif seg_model == "segformer":
|
||||
hair_mask = _segformer_hair_mask(image_bgr)
|
||||
else:
|
||||
raise ValueError(f"未知 seg_model: {seg_model}")
|
||||
lg(f"头发分割完成 seg_model={seg_model} hair_pixels={int(hair_mask.sum())}")
|
||||
else:
|
||||
raise ValueError(f"未知 seg_model: {seg_model}")
|
||||
lg(f"头发分割完成 seg_model={seg_model} hair_pixels={int(hair_mask.sum())}")
|
||||
lg(f"头发分割跳过(复用外部传入) hair_pixels={int(hair_mask.sum())}")
|
||||
|
||||
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
|
||||
closed = _largest_cc(top_fill & upper) # 闭合区域:头发+额头,底=基线
|
||||
@@ -849,7 +854,8 @@ def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
|
||||
edge_erode_px, denoising_strength, gen_backend, hairgrow_strength,
|
||||
mb_levels, hairline_push_cm, hairline_edge, blend_method, color_match,
|
||||
color_match_strength, mb_feather_px, transition_band_px,
|
||||
inpainting_fill, mask_blur, mask_dilate_scale, rid, render_viz=True):
|
||||
inpainting_fill, mask_blur, mask_dilate_scale, rid, render_viz=True,
|
||||
hair_mask=None):
|
||||
"""接口11 共享核心:遮罩(pushed)→生成→硬贴回→接缝融合,产出 ④ final。
|
||||
|
||||
不做任何重绘。返回中间产物 dict(供接口11 构造响应、接口12 取 final+重绘带用):
|
||||
@@ -879,7 +885,7 @@ def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
|
||||
mask_bool, mask_viz = compute_mask(
|
||||
image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm,
|
||||
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, rid=rid,
|
||||
render_viz=render_viz)
|
||||
render_viz=render_viz, hair_mask=hair_mask)
|
||||
t_mask = time.time() - t0
|
||||
logger.info("[%s] 步骤1 遮罩完成 耗时=%dms mask_pixels=%d", rid, int(t_mask*1000), int(mask_bool.sum()))
|
||||
|
||||
@@ -1028,13 +1034,14 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
||||
transition_band_px=-1,
|
||||
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
||||
comfyui_prompt=None, beauty_alpha=0.6,
|
||||
band_lo_mult=0.5, band_hi_mult=1.5, rid=None):
|
||||
band_lo_mult=0.5, band_hi_mult=1.5, rid=None,
|
||||
hair_mask=None):
|
||||
"""接口12 发际线带重绘。内部先跑接口11 核心拿到 ④ final,再取 ⑤-① 发际线重绘带
|
||||
(外推↔内推之间、经 baseline 截断只留上部)作遮罩。
|
||||
|
||||
**本接口不再做 Flux-2 重绘**:只产出 `final`(接缝融合基底)+ 纯红遮罩
|
||||
`redraw_band_mask`(RGBA,遮罩区=(255,0,0,255)、其余全透明),重绘交给前端调
|
||||
外部 ComfyUI 重绘服务(见 local_test)完成。旧的 `redraw_full` / `redraw_band`
|
||||
`redraw_band_mask`(RGBA,遮罩区=(255,0,0,255)、其余全透明),重绘交给后端
|
||||
ComfyUI 重绘接口(/api/v1/redraw)完成。旧的 `redraw_full` / `redraw_band`
|
||||
字段保留为空,仅作结构兼容。
|
||||
|
||||
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
|
||||
@@ -1056,7 +1063,8 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
||||
color_match=color_match, color_match_strength=color_match_strength,
|
||||
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||
mask_dilate_scale=mask_dilate_scale, rid=rid, render_viz=False)
|
||||
mask_dilate_scale=mask_dilate_scale, rid=rid, render_viz=False,
|
||||
hair_mask=hair_mask)
|
||||
final = core["final"]
|
||||
mask_viz = core["mask_viz"]
|
||||
w, h = core["w"], core["h"]
|
||||
@@ -1089,7 +1097,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
||||
redraw_info = {"enabled": False, "error": f"band: {ex}"}
|
||||
|
||||
# ② Flux-2 重绘已下线:本接口现在只产出 final(接缝融合基底)+ 纯红重绘带遮罩,
|
||||
# 重绘交给前端调外部 ComfyUI 重绘服务(见 local_test)完成。
|
||||
# 重绘交给后端 ComfyUI 重绘接口(/api/v1/redraw)完成。
|
||||
# 下面保留 redraw_full_b64 / redraw_band_b64 为空,保持返回结构兼容(旧字段)。
|
||||
redraw_full_b64 = ""
|
||||
redraw_band_b64 = ""
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://10.60.74.221:8188").rstrip("/")
|
||||
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188").rstrip("/")
|
||||
_WORKFLOW_DEFAULT = os.getenv(
|
||||
"ADD_HAIR_WORKFLOW",
|
||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "add_hair.json"),
|
||||
@@ -145,7 +145,7 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
|
||||
outputs = entry.get("outputs")
|
||||
if outputs and output_node in outputs:
|
||||
break
|
||||
time.sleep(1.0)
|
||||
time.sleep(0.05)
|
||||
if not outputs or output_node not in outputs:
|
||||
raise TimeoutError(f"ComfyUI 出图超时({timeout}s) prompt_id={prompt_id}")
|
||||
|
||||
|
||||
+20
-30
@@ -40,27 +40,14 @@ _REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture")
|
||||
_BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
|
||||
|
||||
# 外部重绘服务(local_test,0716add-hair.json 工作流)。接口2 female 用它替代原 Flux-2 重绘。
|
||||
# 重绘服务已独立到远程机器;可用 HAIR_LOCAL_REDRAW_URL 覆盖。
|
||||
# gpu_worker 同内网走 10.60.74.221,外网(本地开发机)需走公网 117.50.183.232。
|
||||
_LOCAL_REDRAW_URL = os.getenv("HAIR_LOCAL_REDRAW_URL", "http://10.60.74.221:8899").rstrip("/")
|
||||
|
||||
|
||||
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
|
||||
"""调外部重绘服务(local_test /api/generate):传 final 图 + 纯红遮罩 PNG,
|
||||
返回重绘后的 PNG bytes。失败抛异常(调用方负责 try/except 跳过)。
|
||||
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
|
||||
|
||||
传 final 图 + 纯红遮罩 PNG,返回重绘后的 PNG bytes。
|
||||
失败抛异常(调用方负责 try/except 跳过)。
|
||||
"""
|
||||
import requests
|
||||
files = {
|
||||
"image": ("final.jpg", image_png_bytes, "image/jpeg"),
|
||||
"mask": ("mask.png", mask_png_bytes, "image/png"),
|
||||
}
|
||||
resp = requests.post(_LOCAL_REDRAW_URL + "/api/generate", files=files, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
if "image/" not in resp.headers.get("Content-Type", ""):
|
||||
# 服务返回了 JSON 错误
|
||||
raise RuntimeError(f"local_test 返回非图片: {resp.text[:200]}")
|
||||
return resp.content
|
||||
from .redraw import run_redraw
|
||||
return run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout)
|
||||
|
||||
# 发际线贴图档位:middle=默认(hairline_texture/),high/low 各自独立文件夹。
|
||||
_TEXTURE_DIRS = {
|
||||
@@ -69,9 +56,8 @@ _TEXTURE_DIRS = {
|
||||
"low": os.path.join(_REPO, "hairline_texture_low"),
|
||||
}
|
||||
|
||||
# ⚠️ 本 worker 是 RTX 5090(sm_120),torch 2.2.2(cu121) 只编到 sm_90,CUDA 跑算子会报
|
||||
# "no kernel image"。SegFormer 默认走 CPU(~2.5s/张)。换 torch cu128 后可设 SEG_DEVICE=cuda。
|
||||
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cpu")
|
||||
# torch 2.7.1+cu128 已支持 RTX 5090 (sm_120),SegFormer 走 GPU(~0.05s/张)
|
||||
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cuda")
|
||||
|
||||
_landmarker = None
|
||||
_parser = None
|
||||
@@ -205,7 +191,7 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
||||
try:
|
||||
h, w = image_bgr.shape[:2]
|
||||
buf = io.BytesIO()
|
||||
compose_comfy_rgba(image_bgr, np.zeros((h, w), np.uint8)).save(buf, format="PNG")
|
||||
compose_comfy_rgba(image_bgr, np.zeros((h, w), np.uint8)).save(buf, format="PNG", compress_level=1)
|
||||
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("接口2 生发图失败(无遮罩):%s", e)
|
||||
@@ -225,7 +211,7 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
||||
marked, mask = build_inpaint_mask(
|
||||
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
||||
buf = io.BytesIO()
|
||||
compose_comfy_rgba(marked, mask).save(buf, format="PNG")
|
||||
compose_comfy_rgba(marked, mask).save(buf, format="PNG", compress_level=1)
|
||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
|
||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||
logger.warning("接口2 生发图失败 type=%s:%s", key, e)
|
||||
@@ -241,20 +227,24 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
||||
|
||||
grown 图来源(新流程):对每个选中发型把 female key 映射到 change_hair 的 chang_* hair_id,
|
||||
调 face_analysis.hairline_grow.generate_hairline_redraw(= 接口12 final 管线,参数用
|
||||
redraw_defaults)拿到 ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG,再**后端调外部重绘服务
|
||||
local_test**(0716add-hair.json 工作流)完成发际线带重绘,重绘结果作为生发图。
|
||||
redraw_defaults)拿到 ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG,再**后端直接调
|
||||
ComfyUI**(0716add-hair-api.json 工作流)完成发际线带重绘,重绘结果作为生发图。
|
||||
|
||||
overlay 仍是发际线曲线透明层(与 generate_grow_results 完全一致)。
|
||||
Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes 或 None)};
|
||||
无人脸返回 None。单个发型换发型/重绘失败时 grown_png=None,不抛异常。
|
||||
"""
|
||||
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError
|
||||
from face_analysis.head_mask import SEGFORMER_HAIR
|
||||
|
||||
ctx = extract_context(image_bgr)
|
||||
if ctx is None:
|
||||
return None
|
||||
uv, ext_faces = load_ext_mesh()
|
||||
|
||||
# 复用 extract_context 已算好的 SegFormer parse_map,避免 generate_hairline_redraw 内部重复分割
|
||||
hair_mask_reuse = (ctx["parse_map"] == SEGFORMER_HAIR)
|
||||
|
||||
textures = get_texture_map()["female"] # [(key, path), ...] 已排序
|
||||
if hair_styles is not None:
|
||||
items = [(s, textures[s - 1]) for s in hair_styles]
|
||||
@@ -273,7 +263,7 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
||||
logger.warning("接口2 换发型:female key=%s 无对应 chang_id,跳过生发图", key)
|
||||
else:
|
||||
try:
|
||||
data = generate_hairline_redraw(image_bgr, chang_id, **redraw_defaults)
|
||||
data = generate_hairline_redraw(image_bgr, chang_id, hair_mask=hair_mask_reuse, **redraw_defaults)
|
||||
steps = data.get("steps") or {}
|
||||
# ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG
|
||||
final_b64 = steps.get("final_base64") or ""
|
||||
@@ -289,7 +279,7 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
||||
mask_b64 = mask_b64.split(",", 1)[1]
|
||||
final_bytes = base64.b64decode(final_b64)
|
||||
mask_bytes = base64.b64decode(mask_b64)
|
||||
# 后端调外部重绘服务(local_test),返回重绘后的 PNG
|
||||
# 后端直接调 ComfyUI 重绘,返回重绘后的 PNG
|
||||
grown_png = _call_local_redraw(final_bytes, mask_bytes)
|
||||
if grown_png is None:
|
||||
logger.warning("接口2 换发型:type=%s 重绘结果为空", key)
|
||||
@@ -319,7 +309,7 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
|
||||
h, w = image_bgr.shape[:2]
|
||||
marked, mask = image_bgr, np.zeros((h, w), np.uint8)
|
||||
buf = io.BytesIO()
|
||||
compose_comfy_rgba(marked, mask).save(buf, format="PNG")
|
||||
compose_comfy_rgba(marked, mask).save(buf, format="PNG", compress_level=1)
|
||||
return comfyui.run(buf.getvalue(), prompt=prompt)
|
||||
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||
logger.warning("接口5 生发图失败:%s", e)
|
||||
@@ -416,7 +406,7 @@ def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str =
|
||||
mask = np.zeros((h, w), np.uint8) # 空遮罩:alpha 全 255,跳过检测
|
||||
|
||||
buf = io.BytesIO()
|
||||
compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG") # marked 原图 + 遮罩
|
||||
compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG", compress_level=1) # marked 原图 + 遮罩
|
||||
grown_png = comfyui.run(buf.getvalue(), prompt=prompt)
|
||||
return {"grown_png": grown_png, "status": "ok"}
|
||||
|
||||
|
||||
@@ -53,11 +53,11 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>接口12 — 发际线带重绘 <span style="font-size:13px;color:#888">(final + 纯红遮罩 → 外部 ComfyUI 重绘)</span></h1>
|
||||
<h1>接口12 — 发际线带重绘 <span style="font-size:13px;color:#888">(final + 纯红遮罩 → ComfyUI 重绘)</span></h1>
|
||||
<div class="subtitle">
|
||||
内部先跑<b>接口11</b>拿到 ④接缝融合最终图(final),再取 <b>⑤-① 发际线重绘带</b>(发际线外推 band_lo_mult×push ~ band_hi_mult×push、经 baseline 截断只留上部)生成
|
||||
<b>纯红遮罩 PNG</b>(遮罩区=(255,0,0,255),其余全透明)。<br>
|
||||
前端拿到 final + 遮罩后,调 <b>外部重绘服务(local_test,默认 :8899)</b>完成重绘。⚠️ 需 ComfyUI(:8188) 在跑。
|
||||
前端拿到 final + 遮罩后,调 <b>后端重绘接口(/api/v1/redraw)</b>完成重绘。⚠️ 需 ComfyUI(:8188) 在跑。
|
||||
对照仅生成不重绘:<a href="/static/test_interface11_debug.html">接口11 调试页</a>。
|
||||
</div>
|
||||
|
||||
@@ -65,11 +65,7 @@
|
||||
<div class="upload-row">
|
||||
<input type="file" id="imageFile" accept="image/*">
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🎨 生成 final+遮罩</button>
|
||||
<button class="btn btn-green" id="redrawBtn" onclick="runLocalRedraw()" disabled>🧪 调 local_test 重绘</button>
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘服务地址</label>
|
||||
<input type="text" id="localTestUrl" value="http://10.60.74.221:8899" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<button class="btn btn-green" id="redrawBtn" onclick="runLocalRedraw()" disabled>🧪 重绘</button>
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||
@@ -144,9 +140,9 @@
|
||||
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
|
||||
<div class="step big"><div class="cap">接口11 final <small>④ 接缝融合(重绘输入基底,无美颜)</small></div><img id="finalBase"></div>
|
||||
<div class="step big"><div class="cap">⑤-① 发际线重绘带遮罩 <small>纯红 alpha PNG(遮罩区=红+不透明,其余全透明)</small></div><img id="maskPng"></div>
|
||||
<div class="step big"><div class="cap">🧪 local_test 重绘结果 <small>final + 遮罩 → 外部 ComfyUI(0716add-hair.json)</small></div><img id="localResult"></div>
|
||||
<div class="step big"><div class="cap">🧪 重绘结果 <small>final + 遮罩 → ComfyUI(0716add-hair)</small></div><img id="localResult"></div>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#888;margin-top:10px">流程:后端产出 final(接缝融合基底)+ 纯红遮罩 PNG,前端把两者 POST 到 local_test 重绘服务完成发际线补发。</div>
|
||||
<div style="font-size:12px;color:#888;margin-top:10px">流程:后端产出 final(接缝融合基底)+ 纯红遮罩 PNG,再调后端 /api/v1/redraw 完成发际线补发。</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -191,10 +187,10 @@ const STEPS = [
|
||||
{ key: 'input', title: '原图', sub: '接口输入' },
|
||||
{ key: 'final', title: '接口11 ④ final', sub: '接缝融合最终图(重绘输入基底,无美颜)' },
|
||||
{ key: 'redraw_band_overlay', title: '⑤-① 发际线重绘带', sub: '紫=lo×push↔hi×push之间、经 baseline 截断只留上部' },
|
||||
{ key: 'redraw_band_mask', title: '⑤-② 纯红遮罩 PNG', sub: '遮罩区=(255,0,0,255)、其余全透明;交给 local_test' },
|
||||
{ key: 'redraw_band_mask', title: '⑤-② 纯红遮罩 PNG', sub: '遮罩区=(255,0,0,255)、其余全透明;交给后端重绘' },
|
||||
];
|
||||
|
||||
// 缓存最近一次后端返回的 final(JPG data URI)和纯红遮罩(PNG data URI),供 local_test 重绘使用
|
||||
// 缓存最近一次后端返回的 final(JPG data URI)和纯红遮罩(PNG data URI),供重绘使用
|
||||
let _finalDataUri = '';
|
||||
let _maskDataUri = '';
|
||||
|
||||
@@ -237,7 +233,7 @@ function renderResult(d) {
|
||||
flog(' 渲染 ' + st.key + ': ' + (hasImg ? '有图(' + src.length + '字符)' : '无图'), hasImg ? 'info' : 'warn');
|
||||
grid.appendChild(stepCard(st.title, st.sub, src));
|
||||
});
|
||||
// final + 遮罩 都有 → 允许调 local_test 重绘
|
||||
// final + 遮罩 都有 → 允许调后端重绘
|
||||
const canRedraw = !!(finalSrc && maskSrc);
|
||||
$('redrawBtn').disabled = !canRedraw;
|
||||
flog('renderResult 完成 canRedraw=' + canRedraw, 'info');
|
||||
@@ -346,7 +342,7 @@ async function submitTest() {
|
||||
}
|
||||
}
|
||||
|
||||
// data URI → Blob,用于把后端返回的 final/遮罩图作为文件 POST 给 local_test
|
||||
// data URI → Blob,用于把后端返回的 final/遮罩图作为文件 POST 给后端重绘
|
||||
function dataUriToBlob(dataUri) {
|
||||
const [meta, b64] = dataUri.split(',');
|
||||
const mime = (meta.match(/data:([^;]+)/) || [, 'application/octet-stream'])[1];
|
||||
@@ -358,44 +354,39 @@ function dataUriToBlob(dataUri) {
|
||||
|
||||
async function runLocalRedraw() {
|
||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||
const base = $('localTestUrl').value.trim().replace(/\/+$/, '');
|
||||
if (!base) { setStatus('请填写重绘服务地址', 'error'); return; }
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮';
|
||||
const btn = $('redrawBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||
flog('===== 调 local_test 重绘 =====', 'info');
|
||||
flog('服务地址=' + base + ' 提示词=' + prompt, 'info');
|
||||
flog('===== 调后端重绘 =====', 'info');
|
||||
flog('提示词=' + prompt, 'info');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('image', dataUriToBlob(_finalDataUri), 'final.jpg');
|
||||
form.append('mask', dataUriToBlob(_maskDataUri), 'mask.png');
|
||||
form.append('image_file', dataUriToBlob(_finalDataUri), 'final.jpg');
|
||||
form.append('mask_file', dataUriToBlob(_maskDataUri), 'mask.png');
|
||||
form.append('prompt', prompt);
|
||||
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
flog('fetch POST ' + base + '/api/generate', 'info');
|
||||
const resp = await fetch(base + '/api/generate', { method: 'POST', body: form });
|
||||
flog('收到响应 http=' + resp.status + ' type=' + resp.headers.get('content-type'), resp.ok ? 'info' : 'error');
|
||||
flog('fetch POST ' + API_BASE + '/api/v1/redraw', 'info');
|
||||
const resp = await fetch(API_BASE + '/api/v1/redraw', { method: 'POST', body: form });
|
||||
flog('收到响应 http=' + resp.status, resp.ok ? 'info' : 'error');
|
||||
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||
if (resp.ok && resp.headers.get('content-type', '').includes('image/')) {
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
$('localResult').src = url;
|
||||
const json = await resp.json();
|
||||
if (json.code === 0 && json.data && json.data.image_base64) {
|
||||
$('localResult').src = json.data.image_base64;
|
||||
$('localResult').onclick = function(){ zoom(this.src); };
|
||||
setStatus('✅ local_test 重绘完成 (' + dt + 's)', 'success');
|
||||
flog('local_test 重绘完成 耗时=' + dt + 's 大小=' + blob.size + 'B', 'info');
|
||||
setStatus('✅ 重绘完成 (' + dt + 's)', 'success');
|
||||
flog('重绘完成 耗时=' + dt + 's', 'info');
|
||||
} else {
|
||||
// 非图片响应 → 多半是 JSON 错误
|
||||
let msg = resp.status + '';
|
||||
try { const j = await resp.json(); msg = j.error || JSON.stringify(j); } catch (_) {}
|
||||
setStatus('❌ local_test 重绘失败:' + msg, 'error');
|
||||
flog('local_test 重绘失败: ' + msg, 'error');
|
||||
const msg = json.message || JSON.stringify(json);
|
||||
setStatus('❌ 重绘失败:' + msg, 'error');
|
||||
flog('重绘失败: ' + msg, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('❌ local_test 网络错误:' + err.message + '(确认服务在跑且已加 CORS)', 'error');
|
||||
flog('local_test 网络错误: ' + err.message, 'error');
|
||||
setStatus('❌ 重绘网络错误:' + err.message, 'error');
|
||||
flog('重绘网络错误: ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '🧪 调 local_test 重绘';
|
||||
btn.disabled = false; btn.textContent = '🧪 重绘';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,8 +417,8 @@ async function downloadBackendLog() {
|
||||
|
||||
$('imageFile').addEventListener('change', function(){ if(this.files.length) flog('选择图片: ' + this.files[0].name, 'info'); });
|
||||
|
||||
flog('接口12 重绘测试页加载完成(后端产出 final+纯红遮罩 → 前端调 local_test 重绘)', 'info');
|
||||
flog('默认: hairline=chang_bolang, push=0.8, blend=two_stage, cm_strength=0.4;需 ComfyUI(:8188) 在跑;重绘需 local_test(:8899) 在跑', 'info');
|
||||
flog('接口12 重绘测试页加载完成(后端产出 final+纯红遮罩 → 后端 /api/v1/redraw 重绘)', 'info');
|
||||
flog('默认: hairline=chang_bolang, push=0.8, blend=two_stage, cm_strength=0.4;需 ComfyUI(:8188) 在跑', 'info');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -41,10 +41,10 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>接口12 final — 发际线带重绘 <span style="font-size:13px;color:#888">(精简版:final + 纯红遮罩 → 外部 ComfyUI 重绘)</span></h1>
|
||||
<h1>接口12 final — 发际线带重绘 <span style="font-size:13px;color:#888">(精简版:final + 纯红遮罩 → ComfyUI 重绘)</span></h1>
|
||||
<div class="subtitle">
|
||||
只需上传图片 + 选择发型,其余参数全部用当前调优默认值(<code>/api/v1/hairline/grow_v2_final</code>)。<br>
|
||||
后端产出 <b>④ final(接缝融合基底)</b> + <b>⑤-② 纯红遮罩 PNG</b>,前端调 <b>外部重绘服务(local_test,默认 :8899)</b>完成发际线带重绘。⚠️ 需 ComfyUI(:8188) 在跑。
|
||||
后端产出 <b>④ final(接缝融合基底)</b> + <b>⑤-② 纯红遮罩 PNG</b>,再调 <b>后端重绘接口(/api/v1/redraw)</b>完成发际线带重绘。⚠️ 需 ComfyUI(:8188) 在跑。
|
||||
局部加发版见 <a href="/static/test_interface12_final_v2.html">接口12 final v2</a>;完整参数调试见 <a href="/static/test_interface12.html">接口12 调试页</a>。
|
||||
</div>
|
||||
|
||||
@@ -52,11 +52,7 @@
|
||||
<div class="upload-row">
|
||||
<input type="file" id="imageFile" accept="image/*">
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🎨 生成 final+遮罩</button>
|
||||
<button class="btn btn-green" id="redrawBtn" onclick="runLocalRedraw()" disabled>🧪 调 local_test 重绘</button>
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘服务地址</label>
|
||||
<input type="text" id="localTestUrl" value="http://10.60.74.221:8899" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<button class="btn btn-green" id="redrawBtn" onclick="runLocalRedraw()" disabled>🧪 重绘</button>
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||
@@ -84,9 +80,9 @@
|
||||
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
|
||||
<div class="step big"><div class="cap">接口11 final <small>④ 接缝融合(重绘输入基底,无美颜)</small></div><img id="finalBase"></div>
|
||||
<div class="step big"><div class="cap">⑤-① 发际线重绘带遮罩 <small>纯红 alpha PNG(遮罩区=红+不透明,其余全透明)</small></div><img id="maskPng"></div>
|
||||
<div class="step big"><div class="cap">🧪 local_test 重绘结果 <small>final + 遮罩 → 外部 ComfyUI(0716add-hair.json)</small></div><img id="localResult"></div>
|
||||
<div class="step big"><div class="cap">🧪 重绘结果 <small>final + 遮罩 → ComfyUI(0716add-hair)</small></div><img id="localResult"></div>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#888;margin-top:10px">流程:后端产出 final(接缝融合基底)+ 纯红遮罩 PNG,前端把两者 POST 到 local_test 重绘服务完成发际线补发。</div>
|
||||
<div style="font-size:12px;color:#888;margin-top:10px">流程:后端产出 final(接缝融合基底)+ 纯红遮罩 PNG,再调后端 /api/v1/redraw 完成发际线补发。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,7 +94,7 @@ const API_BASE = window.location.origin;
|
||||
const ENDPOINT = '/api/v1/hairline/grow_v2_final';
|
||||
const TOKEN = 'dev-shared-secret-2026';
|
||||
|
||||
// 缓存最近一次后端返回的 final(data URI)和纯红遮罩(data URI),供 local_test 重绘使用
|
||||
// 缓存最近一次后端返回的 final(data URI)和纯红遮罩(data URI),供重绘使用
|
||||
let _finalDataUri = '';
|
||||
let _maskDataUri = '';
|
||||
|
||||
@@ -121,7 +117,7 @@ function renderResult(d) {
|
||||
$('redrawBtn').disabled = !canRedraw;
|
||||
}
|
||||
|
||||
// data URI → Blob,用于把后端返回的 final/遮罩图作为文件 POST 给 local_test
|
||||
// data URI → Blob,用于把后端返回的 final/遮罩图作为文件 POST 给后端重绘
|
||||
function dataUriToBlob(dataUri) {
|
||||
const [meta, b64] = dataUri.split(',');
|
||||
const mime = (meta.match(/data:([^;]+)/) || [, 'application/octet-stream'])[1];
|
||||
@@ -173,36 +169,34 @@ async function submitTest() {
|
||||
|
||||
async function runLocalRedraw() {
|
||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||
const base = $('localTestUrl').value.trim().replace(/\/+$/, '');
|
||||
if (!base) { setStatus('请填写重绘服务地址', 'error'); return; }
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮';
|
||||
const btn = $('redrawBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||
|
||||
const form = new FormData();
|
||||
form.append('image', dataUriToBlob(_finalDataUri), 'final.jpg');
|
||||
form.append('mask', dataUriToBlob(_maskDataUri), 'mask.png');
|
||||
form.append('image_file', dataUriToBlob(_finalDataUri), 'final.jpg');
|
||||
form.append('mask_file', dataUriToBlob(_maskDataUri), 'mask.png');
|
||||
form.append('prompt', prompt);
|
||||
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const resp = await fetch(base + '/api/generate', { method: 'POST', body: form });
|
||||
const resp = await fetch(API_BASE + '/api/v1/redraw', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||
if (resp.ok && resp.headers.get('content-type', '').includes('image/')) {
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
$('localResult').src = url;
|
||||
const json = await resp.json();
|
||||
if (json.code === 0 && json.data && json.data.image_base64) {
|
||||
$('localResult').src = json.data.image_base64;
|
||||
$('localResult').onclick = function(){ zoom(this.src); };
|
||||
setStatus('✅ local_test 重绘完成 (' + dt + 's)', 'success');
|
||||
setStatus('✅ 重绘完成 (' + dt + 's)', 'success');
|
||||
} else {
|
||||
let msg = resp.status + '';
|
||||
try { const j = await resp.json(); msg = j.error || JSON.stringify(j); } catch (_) {}
|
||||
setStatus('❌ local_test 重绘失败:' + msg, 'error');
|
||||
setStatus('❌ 重绘失败:' + (json.message || JSON.stringify(json)), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('❌ local_test 网络错误:' + err.message + '(确认服务在跑且已加 CORS)', 'error');
|
||||
setStatus('❌ 重绘网络错误:' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '🧪 调 local_test 重绘';
|
||||
btn.disabled = false; btn.textContent = '🧪 重绘';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -175,7 +175,7 @@ async function submitTest() {
|
||||
$('hairlineType').style.display = 'inline-block';
|
||||
}
|
||||
|
||||
const grownSrc = d.hair_growth_image_url || d.hair_growth_image_base64;
|
||||
const grownSrc = d.hair_growth_image_url || (d.hair_growth_image_base64 ? 'data:image/jpeg;base64,' + d.hair_growth_image_base64 : '');
|
||||
if (grownSrc) {
|
||||
$('blendTop').src = grownSrc;
|
||||
$('blendTop').style.display = 'block';
|
||||
|
||||
Reference in New Issue
Block a user