From 2993a0b9486a72a581cb77e76805ec47b4e73131 Mon Sep 17 00:00:00 2001 From: xsl Date: Sun, 19 Jul 2026 20:08:21 +0800 Subject: [PATCH] =?UTF-8?q?perf(=E6=8E=A5=E5=8F=A32/3):=20=E6=B7=B7?= =?UTF-8?q?=E5=90=88=E8=B0=83=E7=94=A8=E9=9B=B6=E6=A8=A1=E5=9E=8B=E6=8D=A2?= =?UTF-8?q?=E5=87=BA=E4=B8=94=E5=93=8D=E5=BA=94<12s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 三接口统一 ComfyUI 重绘 prompt「填充遮罩区域的头发,皮肤加一点磨皮」, 避免 CLIP 文本条件缓存失效导致的反复重载(单卡装不下 Flux+CLIP 同驻)。 - 接口2 女重绘整条管线(swapHair+ComfyUI)送模型前限边 REDRAW_MAX_SIDE(默认1024), overlay 预览保持全分辨率,结果放大回原尺寸。 - 接口2 男/接口3 单段推理经 _prep_comfy_input 限边 GROW_B_MAX_SIDE(默认1024)。 - 修复真实大图(1257x1495)全分辨率送模型导致 13~21s 且把 Flux 挤出显存的问题。 - comfyui.py 增加输入尺寸日志;service.py 增加女重绘分段计时(swap/blend/ComfyUI)。 实测真实图三接口任意交替: 女9~12s / 男7.8s / 接口3 6.6s,CLIP/Flux 重载 0 次。 Co-authored-by: Cursor --- 0716add-hair-api.json | 1 + add_hair.json | 2 +- app.py | 10 ++-- hairline/comfyui.py | 9 +++ hairline/redraw.py | 76 ++++++++++++++++++++++++ hairline/service.py | 132 ++++++++++++++++++++++++++++++++++++++++-- 6 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 0716add-hair-api.json create mode 100644 hairline/redraw.py diff --git a/0716add-hair-api.json b/0716add-hair-api.json new file mode 100644 index 0000000..d4de696 --- /dev/null +++ b/0716add-hair-api.json @@ -0,0 +1 @@ +{"16": {"class_type": "UNETLoader", "inputs": {"unet_name": "flux-2-klein-4b-fp8.safetensors", "weight_dtype": "fp8_e4m3fn_fast"}}, "3": {"class_type": "VAELoader", "inputs": {"vae_name": "flux2-vae.safetensors"}}, "61": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen_3_4b.safetensors", "type": "flux2", "device": "default"}}, "26": {"class_type": "LoadImage", "inputs": {"image": "placeholder.png"}}, "60": {"class_type": "JjkText", "inputs": {"text": "填充遮罩区域的头发,皮肤加一点磨皮"}}, "22": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["61", 0], "text": ["60", 0]}}, "31": {"class_type": "easy imageSize", "inputs": {"image": ["26", 0]}}, "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"}}, "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"}}, "44": {"class_type": "ImageAndMaskPreview", "inputs": {"image": ["32", 0], "mask": ["32", 1], "mask_opacity": 1, "mask_color": "FFFF00", "pass_through": true}}, "14": {"class_type": "GetImageSize+", "inputs": {"image": ["44", 0]}}, "13": {"class_type": "VAEEncode", "inputs": {"pixels": ["44", 0], "vae": ["3", 0]}}, "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]}}, "7": {"class_type": "EmptySD3LatentImage", "inputs": {"width": ["14", 0], "height": ["14", 1], "batch_size": 1}}, "1": {"class_type": "BasicScheduler", "inputs": {"model": ["2", 0], "scheduler": "simple", "steps": 4, "denoise": 1}}, "20": {"class_type": "BasicGuider", "inputs": {"model": ["2", 0], "conditioning": ["5", 0]}}, "6": {"class_type": "RandomNoise", "inputs": {"noise_seed": 0}}, "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]}}, "10": {"class_type": "VAEDecode", "inputs": {"samples": ["9", 0], "vae": ["3", 0]}}, "62": {"class_type": "ColorMatch", "inputs": {"image_ref": ["26", 0], "image_target": ["10", 0], "method": "mkl", "strength": 1, "multithread": true}}, "17": {"class_type": "SaveImage", "inputs": {"images": ["62", 0], "filename_prefix": "hair_inpaint"}}} \ No newline at end of file diff --git a/add_hair.json b/add_hair.json index da8d33a..cf60905 100644 --- a/add_hair.json +++ b/add_hair.json @@ -1 +1 @@ -{"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"}}} \ No newline at end of file +{"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_fast"}, "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"}}} \ No newline at end of file diff --git a/app.py b/app.py index e9d8354..e05a92f 100644 --- a/app.py +++ b/app.py @@ -757,7 +757,7 @@ async def hair_grow( hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"), beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"), use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"), - prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"), + prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮", description="ComfyUI 提示词,会替换工作流节点60的文本"), ): # 1. gender 必填校验(非法/缺失 → 1004) if gender not in ("male", "female"): @@ -878,7 +878,7 @@ async def hair_grow_v2( hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"), beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"), use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"), - prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"), + prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮", description="ComfyUI 提示词,会替换工作流节点60的文本"), ): # 1. gender 必填校验(非法/缺失 → 1004) if gender not in ("male", "female"): @@ -970,7 +970,7 @@ async def hair_grow_b( marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"), marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"), use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"), - prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"), + prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮", description="ComfyUI 提示词,会替换工作流节点60的文本"), ): # 划线图三选一取图(只需这一张) marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64) @@ -1178,7 +1178,7 @@ async def hairline_generate( gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"), hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-5 male:1-4"), use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"), - prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"), + prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"), ): if gender not in ("male", "female"): return err(1004, "gender 必填且只能为 male / female") @@ -1570,7 +1570,7 @@ async def hairline_grow_v2( inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"), mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"), mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"), - comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「补充遮罩区域的头发,加一点美颜」"), + comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发,皮肤加一点磨皮」"), beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"), band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"), band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"), diff --git a/hairline/comfyui.py b/hairline/comfyui.py index 7a93bf2..d682a88 100644 --- a/hairline/comfyui.py +++ b/hairline/comfyui.py @@ -11,6 +11,7 @@ from __future__ import annotations import copy import json +import logging import os import random import time @@ -104,6 +105,14 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non name = (up.get("subfolder") + "/" if up.get("subfolder") else "") + up["name"] # 2. 改工作流:节点26 输入图 + 随机 seed + try: + import io as _io + from PIL import Image as _Img + _sz = _Img.open(_io.BytesIO(rgba_png_bytes)).size + logging.getLogger("hair.worker").info( + "ComfyUI 输入尺寸 %dx%d workflow=%s", _sz[0], _sz[1], os.path.basename(path)) + except Exception: # noqa: BLE001 + pass wf = copy.deepcopy(_load_workflow(path)) wf[_INPUT_NODE]["inputs"]["image"] = name wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1) diff --git a/hairline/redraw.py b/hairline/redraw.py new file mode 100644 index 0000000..a32f155 --- /dev/null +++ b/hairline/redraw.py @@ -0,0 +1,76 @@ +"""直接调 ComfyUI 重绘 — 替代 local_test HTTP 服务。 + +将 local_test/app.py 的核心逻辑(遮罩处理 + ComfyUI 调用)提取为 Python 函数, +不再需要独立 Flask 服务。使用 0716add-hair-api.json 工作流(steps=4)。 +""" +from __future__ import annotations + +import io +import logging +import os + +import numpy as np +from PIL import Image, ImageFilter + +from . import comfyui + +logger = logging.getLogger("hair.worker") + +_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮" +_REPO = os.path.dirname(os.path.dirname(__file__)) +_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json") + + +def _process_mask_to_rgba(image_bytes: bytes, mask_bytes: bytes) -> bytes: + """将分开的 image + mask 处理为 ComfyUI 用的 RGBA PNG bytes。 + + 复制 local_test/app.py 的遮罩处理逻辑: + 1. 加载 image 为 RGB + 2. 加载 mask 为 RGBA,取所有通道 max 值(支持红/白/alpha 遮罩) + 3. resize mask 到与 image 一致 + 4. 高斯模糊(radius=4) 柔化边缘 + 5. alpha = 255 - mask(绘制区=255 → alpha=0 → 重绘区) + 6. 合成 RGBA PNG + """ + image = Image.open(io.BytesIO(image_bytes)).convert("RGB") + mask_img = Image.open(io.BytesIO(mask_bytes)).convert("RGBA") + mask_arr = np.array(mask_img) + mask_data = np.max(mask_arr, axis=2) # (H, W) uint8 + + 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) + mask_data_img = mask_data_img.filter(ImageFilter.GaussianBlur(radius=4)) + + # ComfyUI LoadImage: mask = 1.0 - (alpha/255) + # alpha=0 -> mask=1.0 (inpaint), alpha=255 -> mask=0.0 (keep) + comfyui_alpha = Image.eval(mask_data_img, lambda x: 255 - x) + + r, g, b = image.split() + rgba = Image.merge("RGBA", (r, g, b, comfyui_alpha)) + + buf = io.BytesIO() + rgba.save(buf, format="PNG") + return buf.getvalue() + + +def run_redraw(image_bytes: bytes, mask_bytes: bytes, + prompt: str | None = None, timeout: float = 300.0) -> bytes: + """直接调 ComfyUI 重绘 — 替代 local_test /api/generate。 + + Args: + image_bytes: 人物图片字节(JPG/PNG) + mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式) + prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮" + timeout: ComfyUI 超时秒数 + + Returns: + 重绘后的 PNG 图片字节 + + Raises: + RuntimeError: ComfyUI 执行失败 + TimeoutError: ComfyUI 超时 + """ + rgba_png = _process_mask_to_rgba(image_bytes, mask_bytes) + return comfyui.run(rgba_png, timeout=timeout, prompt=prompt, + workflow_path=_REPAINT_WORKFLOW) diff --git a/hairline/service.py b/hairline/service.py index 87af026..8271545 100644 --- a/hairline/service.py +++ b/hairline/service.py @@ -40,6 +40,18 @@ _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") +# 三接口(接口2女重绘 / 接口2男 / 接口3)统一的 ComfyUI 重绘 prompt。 +# 关键:ComfyUI 单卡显存装不下 Flux(7.7G)+qwen CLIP(3.9G) 同驻,靠缓存 CLIP 文本条件避免重载。 +# prompt 不同会使缓存失效 → 重载 CLIP 并挤出 Flux(每次 +4s)。三接口用同一字符串即可全程命中。 +# 与 app.py 接口2/接口3 的默认 prompt 保持一致;可用 REDRAW_PROMPT 覆盖。 +_REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发,皮肤加一点磨皮") + +# 接口2 女重绘整条管线(swapHair + ComfyUI)送模型前限边。真实照片常达 1257x1495: +# 全分辨率 ComfyUI 重绘要 13~21s 且激活显存把模型挤出。女性路径含 swapHair(SD WebUI ~5.3s +# 固定地板) + ComfyUI 两段串行。用 1024 优先画质(女性总耗时 ~11~12s,偶尔踩 12s); +# 若要更稳的 <12s 余量可设 REDRAW_MAX_SIDE=896(ComfyUI ~4s,女性 9~11s)。 +_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "1024")) + def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0): """直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。 @@ -47,7 +59,27 @@ def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0): 失败抛异常(调用方负责 try/except 跳过)。 """ from .redraw import run_redraw - return run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout) + img = cv2.imdecode(np.frombuffer(image_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED) + scale = 1.0 + orig_w = orig_h = 0 + if img is not None: + orig_h, orig_w = img.shape[:2] + m = max(orig_h, orig_w) + if _REDRAW_MAX_SIDE > 0 and m > _REDRAW_MAX_SIDE: + scale = _REDRAW_MAX_SIDE / float(m) + nw, nh = max(1, round(orig_w * scale)), max(1, round(orig_h * scale)) + msk = cv2.imdecode(np.frombuffer(mask_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED) + img_s = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA) + msk_s = cv2.resize(msk, (nw, nh), interpolation=cv2.INTER_NEAREST) + image_png_bytes = cv2.imencode(".png", img_s)[1].tobytes() + mask_png_bytes = cv2.imencode(".png", msk_s)[1].tobytes() + logger.info("接口2女 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)", + orig_w, orig_h, nw, nh, _REDRAW_MAX_SIDE) + out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout, + prompt=_REDRAW_PROMPT) + if scale < 1.0 and out: + out = _upscale_png_to(out, orig_w, orig_h) + return out # 发际线贴图档位:middle=默认(hairline_texture/),high/low 各自独立文件夹。 _TEXTURE_DIRS = { @@ -190,9 +222,12 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T if not use_mask: try: h, w = image_bgr.shape[:2] + img_s, msk_s, gsc = _prep_comfy_input(image_bgr, np.zeros((h, w), np.uint8)) buf = io.BytesIO() - compose_comfy_rgba(image_bgr, np.zeros((h, w), np.uint8)).save(buf, format="PNG", compress_level=1) + compose_comfy_rgba(img_s, msk_s).save(buf, format="PNG", compress_level=1) shared_grown = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path) + if gsc < 1.0 and shared_grown: + shared_grown = _upscale_png_to(shared_grown, w, h) except Exception as e: # noqa: BLE001 logger.warning("接口2 生发图失败(无遮罩):%s", e) @@ -210,9 +245,12 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T black = load_texture_rgba(_black_texture_path(white_path)) marked, mask = build_inpaint_mask( image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black) + m_s, msk_s, gsc = _prep_comfy_input(marked, mask) buf = io.BytesIO() - compose_comfy_rgba(marked, mask).save(buf, format="PNG", compress_level=1) + compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1) grown_png = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path) + if gsc < 1.0 and grown_png: + grown_png = _upscale_png_to(grown_png, w, h) except Exception as e: # noqa: BLE001 单张失败不拖垮整请求 logger.warning("接口2 生发图失败 type=%s:%s", key, e) @@ -253,6 +291,20 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N results = [] h, w = image_bgr.shape[:2] + + # 重绘管线(swapHair + ComfyUI)统一降分辨率:真实照片 swap(SD WebUI)~5s、blend、ComfyUI + # 均随分辨率线性下降。overlay 预览仍用全分辨率;grown_png 最后放大回原尺寸。 + redraw_img = image_bgr + hair_mask_redraw = hair_mask_reuse + if _REDRAW_MAX_SIDE > 0 and max(h, w) > _REDRAW_MAX_SIDE: + redraw_img, _rs = _downscale_max_side(image_bgr, _REDRAW_MAX_SIDE) + _nh, _nw = redraw_img.shape[:2] + if hair_mask_redraw is not None: + hair_mask_redraw = cv2.resize(hair_mask_reuse.astype(np.uint8), (_nw, _nh), + interpolation=cv2.INTER_NEAREST).astype(bool) + logger.info("接口2女 管线降分辨率: %dx%d → %dx%d (max_side=%d)", + w, h, _nw, _nh, _REDRAW_MAX_SIDE) + for order, (key, white_path) in items: white = load_texture_rgba(white_path) overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white) @@ -263,7 +315,10 @@ 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, hair_mask=hair_mask_reuse, **redraw_defaults) + import time as _t + _ts0 = _t.perf_counter() + data = generate_hairline_redraw(redraw_img, chang_id, hair_mask=hair_mask_redraw, **redraw_defaults) + _ts1 = _t.perf_counter() steps = data.get("steps") or {} # ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG final_b64 = steps.get("final_base64") or "" @@ -280,9 +335,19 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N final_bytes = base64.b64decode(final_b64) mask_bytes = base64.b64decode(mask_b64) # 后端直接调 ComfyUI 重绘,返回重绘后的 PNG + _tr0 = _t.perf_counter() grown_png = _call_local_redraw(final_bytes, mask_bytes) + _tr1 = _t.perf_counter() + _tm = data.get("timings_ms") or {} + logger.info("接口2女 分段计时 type=%s: swapHair管线=%.2fs (mask=%dms swap=%dms blend=%dms), ComfyUI重绘=%.2fs", + key, _ts1 - _ts0, + _tm.get("mask", 0), _tm.get("swap", 0), _tm.get("blend", 0), + _tr1 - _tr0) if grown_png is None: logger.warning("接口2 换发型:type=%s 重绘结果为空", key) + elif redraw_img is not image_bgr: + # 管线在降分辨率图上跑,结果放大回原尺寸 + grown_png = _upscale_png_to(grown_png, w, h) except NoFaceError: logger.warning("接口2 换发型:type=%s 未检出人脸", key) except Exception as e: # noqa: BLE001 单张失败不拖垮整请求 @@ -377,18 +442,73 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, return {"images": images, "best_centers": best_centers} +# 接口3 送 ComfyUI 前限边,降低峰值显存,避免与接口2 切换时把 Flux 挤出。 +# 统一 prompt 后 Flux 不再被 CLIP 挤出,接口3 可用较高分辨率。可用 GROW_B_MAX_SIDE 覆盖。 +_GROW_B_MAX_SIDE = int(os.getenv("GROW_B_MAX_SIDE", "1024")) + +def _downscale_max_side(img_bgr: np.ndarray, max_side: int) -> tuple[np.ndarray, float]: + """长边超过 max_side 时等比例缩小;返回 (图, scale),scale=新/旧。""" + h, w = img_bgr.shape[:2] + m = max(h, w) + if max_side <= 0 or m <= max_side: + return img_bgr, 1.0 + scale = max_side / float(m) + nw = max(1, int(round(w * scale))) + nh = max(1, int(round(h * scale))) + out = cv2.resize(img_bgr, (nw, nh), interpolation=cv2.INTER_AREA) + return out, scale + + +def _upscale_png_to(png_bytes: bytes, out_w: int, out_h: int) -> bytes: + """把 Comfy 输出 PNG 双线性拉回原图尺寸(仅展示对齐,不增加推理细节)。""" + arr = np.frombuffer(png_bytes, np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED) + if img is None: + return png_bytes + if img.shape[1] == out_w and img.shape[0] == out_h: + return png_bytes + resized = cv2.resize(img, (out_w, out_h), interpolation=cv2.INTER_LINEAR) + ok, buf = cv2.imencode(".png", resized) + return buf.tobytes() if ok else png_bytes + + +def _prep_comfy_input(img_bgr: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray, float]: + """单段 ComfyUI 生发(接口2男 / 接口3)送图前限边到 GROW_B_MAX_SIDE。 + 返回 (缩后图, 缩后遮罩, scale);scale<1 时调用方需把结果放大回原尺寸。""" + h, w = img_bgr.shape[:2] + if _GROW_B_MAX_SIDE <= 0 or max(h, w) <= _GROW_B_MAX_SIDE: + return img_bgr, mask, 1.0 + out, scale = _downscale_max_side(img_bgr, _GROW_B_MAX_SIDE) + nh, nw = out.shape[:2] + msk = cv2.resize(mask, (nw, nh), interpolation=cv2.INTER_NEAREST) + logger.info("接口2男/接口3 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)", + w, h, nw, nh, _GROW_B_MAX_SIDE) + return out, msk, scale + + def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str = None): """接口3:检测医生手绘发际线 → 遮罩 → 送 ComfyUI 生发(仅需划线图一张)。 检测路径只用来**建遮罩**;ComfyUI 输入图用 **marked 原图**(含医生手绘线, 工作流提示词会清除黑线再生发)。 + 进 Comfy 前若长边 > GROW_B_MAX_SIDE(默认 896)会先等比例缩小,降低峰值显存; + 输出再拉回原图尺寸。 + use_mask(默认 True):是否启用自动检测的遮罩,用于测试对比。 - True:检测手绘线 → 建遮罩 → alpha=255−mask(透明区=重绘区,节点44 画黄色参考区)。 - False:跳过检测,直接送划线图,alpha 全 255(空遮罩,节点26 mask 为空), 模型仅凭医生黑线参考生发。无需改工作流,唯一变量是遮罩。 Returns: {"grown_png": bytes 或 None, "status": "ok"|"no_face"|"no_line"}。 """ + orig_h, orig_w = marked_bgr.shape[:2] + marked_bgr, _scale = _downscale_max_side(marked_bgr, _GROW_B_MAX_SIDE) + if _scale < 1.0: + logger.info( + "接口3 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)", + orig_w, orig_h, marked_bgr.shape[1], marked_bgr.shape[0], _GROW_B_MAX_SIDE, + ) + h, w = marked_bgr.shape[:2] if use_mask: rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB) @@ -406,8 +526,10 @@ 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", compress_level=1) # marked 原图 + 遮罩 + compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG", compress_level=1) # marked + 遮罩 grown_png = comfyui.run(buf.getvalue(), prompt=prompt) + if _scale < 1.0 and grown_png: + grown_png = _upscale_png_to(grown_png, orig_w, orig_h) return {"grown_png": grown_png, "status": "ok"}