From 74ccab0ff809a0e77e677715c722a5788d514c80 Mon Sep 17 00:00:00 2001 From: xsl Date: Sat, 18 Jul 2026 18:58:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=80=82=E9=85=8D=20RTX=203090=20(24GB?= =?UTF-8?q?)=20=E7=8E=AF=E5=A2=83=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 硬件迁移:从 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) --- add_hair.json | 451 +---------------------------- app.py | 39 ++- docs/iface3_implementation.md | 177 +++++++++++ face_analysis/hairline_grow.py | 38 ++- hairline/comfyui.py | 4 +- hairline/service.py | 50 ++-- static/test_interface12.html | 67 ++--- static/test_interface12_final.html | 46 ++- static/test_interface3.html | 2 +- 9 files changed, 311 insertions(+), 563 deletions(-) create mode 100644 docs/iface3_implementation.md diff --git a/add_hair.json b/add_hair.json index 342a3cb..da8d33a 100644 --- a/add_hair.json +++ b/add_hair.json @@ -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" - } - } -} \ 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"}, "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 f15b5c3..db27d6e 100644 --- a/app.py +++ b/app.py @@ -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 遮罩计算全过程) # --------------------------------------------------------------------------- diff --git a/docs/iface3_implementation.md b/docs/iface3_implementation.md new file mode 100644 index 0000000..a418bb0 --- /dev/null +++ b/docs/iface3_implementation.md @@ -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)(需手动在图上画发际线后作为划线图上传) diff --git a/face_analysis/hairline_grow.py b/face_analysis/hairline_grow.py index 269b274..32065b9 100644 --- a/face_analysis/hairline_grow.py +++ b/face_analysis/hairline_grow.py @@ -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 = "" diff --git a/hairline/comfyui.py b/hairline/comfyui.py index ace4b0b..7a93bf2 100644 --- a/hairline/comfyui.py +++ b/hairline/comfyui.py @@ -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}") diff --git a/hairline/service.py b/hairline/service.py index 65de667..87af026 100644 --- a/hairline/service.py +++ b/hairline/service.py @@ -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"} diff --git a/static/test_interface12.html b/static/test_interface12.html index 30b132c..e60f739 100644 --- a/static/test_interface12.html +++ b/static/test_interface12.html @@ -53,11 +53,11 @@
-

接口12 — 发际线带重绘 (final + 纯红遮罩 → 外部 ComfyUI 重绘)

+

接口12 — 发际线带重绘 (final + 纯红遮罩 → ComfyUI 重绘)

内部先跑接口11拿到 ④接缝融合最终图(final),再取 ⑤-① 发际线重绘带(发际线外推 band_lo_mult×push ~ band_hi_mult×push、经 baseline 截断只留上部)生成 纯红遮罩 PNG(遮罩区=(255,0,0,255),其余全透明)。
- 前端拿到 final + 遮罩后,调 外部重绘服务(local_test,默认 :8899)完成重绘。⚠️ 需 ComfyUI(:8188) 在跑。 + 前端拿到 final + 遮罩后,调 后端重绘接口(/api/v1/redraw)完成重绘。⚠️ 需 ComfyUI(:8188) 在跑。 对照仅生成不重绘:接口11 调试页
@@ -65,11 +65,7 @@
- -
-
- - +
@@ -144,9 +140,9 @@
接口11 final ④ 接缝融合(重绘输入基底,无美颜)
⑤-① 发际线重绘带遮罩 纯红 alpha PNG(遮罩区=红+不透明,其余全透明)
-
🧪 local_test 重绘结果 final + 遮罩 → 外部 ComfyUI(0716add-hair.json)
+
🧪 重绘结果 final + 遮罩 → ComfyUI(0716add-hair)
-
流程:后端产出 final(接缝融合基底)+ 纯红遮罩 PNG,前端把两者 POST 到 local_test 重绘服务完成发际线补发。
+
流程:后端产出 final(接缝融合基底)+ 纯红遮罩 PNG,再调后端 /api/v1/redraw 完成发际线补发。
@@ -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'); diff --git a/static/test_interface12_final.html b/static/test_interface12_final.html index e710574..8dc0fa5 100644 --- a/static/test_interface12_final.html +++ b/static/test_interface12_final.html @@ -41,10 +41,10 @@
-

接口12 final — 发际线带重绘 (精简版:final + 纯红遮罩 → 外部 ComfyUI 重绘)

+

接口12 final — 发际线带重绘 (精简版:final + 纯红遮罩 → ComfyUI 重绘)

只需上传图片 + 选择发型,其余参数全部用当前调优默认值(/api/v1/hairline/grow_v2_final)。
- 后端产出 ④ final(接缝融合基底) + ⑤-② 纯红遮罩 PNG,前端调 外部重绘服务(local_test,默认 :8899)完成发际线带重绘。⚠️ 需 ComfyUI(:8188) 在跑。 + 后端产出 ④ final(接缝融合基底) + ⑤-② 纯红遮罩 PNG,再调 后端重绘接口(/api/v1/redraw)完成发际线带重绘。⚠️ 需 ComfyUI(:8188) 在跑。 局部加发版见 接口12 final v2;完整参数调试见 接口12 调试页
@@ -52,11 +52,7 @@
- -
-
- - +
@@ -84,9 +80,9 @@
接口11 final ④ 接缝融合(重绘输入基底,无美颜)
⑤-① 发际线重绘带遮罩 纯红 alpha PNG(遮罩区=红+不透明,其余全透明)
-
🧪 local_test 重绘结果 final + 遮罩 → 外部 ComfyUI(0716add-hair.json)
+
🧪 重绘结果 final + 遮罩 → ComfyUI(0716add-hair)
-
流程:后端产出 final(接缝融合基底)+ 纯红遮罩 PNG,前端把两者 POST 到 local_test 重绘服务完成发际线补发。
+
流程:后端产出 final(接缝融合基底)+ 纯红遮罩 PNG,再调后端 /api/v1/redraw 完成发际线补发。
@@ -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 = '🧪 重绘'; } } diff --git a/static/test_interface3.html b/static/test_interface3.html index 9e40d5c..a160604 100644 --- a/static/test_interface3.html +++ b/static/test_interface3.html @@ -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';