33 Commits
Author SHA1 Message Date
xslandCursor fe0e74ece8 feat: 接口1/6 标注层字号上调一档 + 眉心改用 9 号点定位
- annotation: 自适应字号系数 0.017→0.020(下限 8→9),标注文字更大更清晰
- measure: _brow_center 只取 FaceMesh 9 号点(眉间上点),不再与 151 取中点

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 23:03:33 +08:00
xsl 52913c94fc fix: 删除 MeasureResult.__init__ 中重复的七眼厘米赋值块 2026-07-27 23:17:22 +08:00
xsl 13526cb5b8 feat: 接口1/5/6 发际线弃用逻辑(顶庭<0.7cm)
发际线离头顶<0.7cm时判定分割不可靠,弃用发际线:
顶/上庭字段置null、face_total只算中下庭、标注图保留头顶线去掉发际线、
只标中/下庭。eye1/7竖向范围改用眉心。
2026-07-27 23:10:54 +08:00
xsl f509fe99b4 docs: 同步 integration.html / 接口文档.md / test_interface5.html
- 接口5: 补 generate_grow_image 参数说明(接口文档/integration/test_interface5 加控件)
- 接口1/5/6: 补 left_position/right_position 字段(MediaPipe 21/251号点)
- 接口4: features 字段纠正为固定6个英文字段(原误写~42项含中文, 与代码不符)
- 接口7: 完全移除(代码已 deprecated=True 固定返回错误, 文档却当正常接口详述)
- 错误码: 删错误的'1004已废弃'(1004仍用于接口2/5 gender校验), 补 1004 正确描述 + 1009(X-Internal-Token鉴权)
- test_interface5.html: 加 generate_grow_image 复选框
2026-07-24 00:42:34 +08:00
xsl a1d458eb20 feat(接口1/5/6): 返回数据新增 left_position/right_position(MediaPipe 21/251号点)
- face_mesh_landmarks.py: 加常量 LEFT_POSITION=21 / RIGHT_POSITION=251
- measure.py: MeasureResult 收 landmarks/宽高, to_response 顶层输出两点(原图像素 {x,y}, 与 landmarks 同格式)
- measure_face 透传 landmarks(签名不变, 6处调用零改动); __init__ 用 None 默认值守卫向后兼容
- 三接口自动生效: 接口1/6 在 data 顶层, 接口5 在 face_measure 对象里(复用同一 to_response)
- 实测坐标左右镜像合理, 44 个现有测试全过无回归
2026-07-24 00:42:26 +08:00
xsl 92e628b0d5 feat(接口5): 新增 generate_grow_image 参数控制是否生成生发效果图
- app.py: 接口5 路由加表单参数 generate_grow_image(bool, 默认 True)并透传
- hairline/service.py: generate_hairline_pngs 加同名参数, False 时跳过 ComfyUI 生发、grown_png 恒 None
- 默认行为不变(向后兼容); false 时仅返回三档发际线叠图与中心点, 大幅降低耗时
- 网关字节级透传 multipart, 新参数自动到达 worker, 无需改网关
2026-07-24 00:42:18 +08:00
xslandCursor b4714cedf3 perf(接口2女): REDRAW_MAX_SIDE 默认 1024→896 兜底
1024 档下部分大图(swapHair ~5.3s 地板 + ComfyUI 重绘)仍会踩 12s 线。
压到 896 后 ComfyUI 段稳定 ~4s,女性路径总耗时 9~11s,留出安全余量。
追画质可用环境变量 REDRAW_MAX_SIDE=1024 覆盖;接口2男/接口3 的
GROW_B_MAX_SIDE 保持 1024 不变(单段 ComfyUI,无 swapHair 地板)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:42:27 +08:00
xslandCursor e7b62f2b2e perf(接口2): 稳定混跑耗时至12s内 —— ComfyUI插队 + CLIP挪CPU + 提示词全局统一
问题:接口2 与接口3/5 乱序调用时耗时抖动(最差 15~22s)。两个根因:
1. GPU 24G 常驻 21.4G,Flux-2(3.9G) 无法完全驻留显存,每次采样动态换页,
   速度随空闲显存波动(2s~8s);
2. ComfyUI 单队列 FIFO,接口2 排在接口3/5 批量任务后面。

改动:
- hairline/comfyui.py: run() 新增 front 参数,/prompt 带 "front": true 插队到队列最前;
  redraw.py 透传;service.py 接口2 三处调用(女重绘 + 男有/无遮罩)传 front=True,
  接口3/5 仍走普通队列。
- add_hair.json / 0716add-hair-api.json: 节点61 CLIPLoader device default→cpu。
  qwen CLIP(4G) 不再占显存(文本条件缓存常年命中),ComfyUI 显存 8.8G→4.5G,
  Flux-2 完全驻留,采样稳定 ~3-5s。代价:换 prompt 后首次请求 CPU 编码 ~11s(一次性)。
- 提示词全局统一为「填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜」:
  app.py 4处默认值、service.py _REDRAW_PROMPT、redraw.py _DEFAULT_PROMPT、
  4个工作流节点60内置文案、测试页(test_interface2/3/7/12/12_final)、local_test。
  任何两个不同 prompt 交替提交都会打爆 CLIP 编码缓存(--cache-classic 只存最近一次),
  之前测试页旧文案与服务端不一致导致交替测试每次 +11s。
- app.py: 接口7 /api/v1/hair/grow-v2 下线(业务弃用;add_hair2.json 的 Klein-9b
  会把常驻 Klein-4b 挤出显存)。保留 stub 返回 1007 明确报错,避免裸 404。

实测(1024 档):接口2女 8.5~10s、接口2男 ~5s、接口3 ~7-10s,交替混跑无尖刺。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:01:48 +08:00
xslandCursor 4291f125d4 fix(接口2): 发际线钳制到头部轮廓,修复短发/光头照片发际线贴到头部外面
短发/剃光头照片(如男性椭圆发际线)中间锚点射线检测命中不到 hair 像素时,
sample_hairline 的 fallback 会用固定 0.18 归一化偏移把点顶到头部轮廓外的背景,
在有效/失效锚点交界处形成尖角,被贴图不透明像素蒙到后露出戳出头部的线条。

新增 clamp_hairline_to_silhouette + sample_hairline_clamped,在几何检测后按每列
SegFormer(skin∪hair) 轮廓上沿做安全网钳制;extract_context 固定改用钳制版本。
只在 fallback 越界时生效,正常长发照片结果与旧行为一致,纯 numpy/opencv 与 GPU 无关。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:41:52 +08:00
xslandCursor 7fc0210ce6 fix(pose): 修正正面照被误判为1003(solvePnP翻转解)
ITERATIVE 偶发收敛到相机后方(tz<0),roll≈±180° 超阈值,
把正面照误判为非正面。检测到负深度时回退 SQPNP 重解正深度解。
补充回归测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 16:47:04 +08:00
xslandCursor 085ad3ced0 接口1 四庭七眼标注:字体更小、数值带cm、增加百分比、线名右移居中对齐
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 23:37:59 +08:00
xslandCursor 2993a0b948 perf(接口2/3): 混合调用零模型换出且响应<12s
- 三接口统一 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 <cursoragent@cursor.com>
2026-07-19 20:08:21 +08:00
xslandCursor 08b31a3baa fix: 测试页同时兼容 base64 与 URL 图片字段
直连 worker 返回 *_base64、经网关则改写为 *_url;统一 resolveImgSrc 后接口1/2/3/5/6 测试页都能正确显示结果图。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 15:49:46 +08:00
xsl 51e313e845 save code 2026-07-19 00:51:46 +08:00
xsl fbbcd48418 feat: 系统配置文件适配 RTX 3090
更新系统服务配置文件:
- hair-worker.service: 路径从 /home/xsl/ 改为 /home/ubuntu/,添加 comfyui/change_hair 依赖
- comfyui.service: 添加 --cache-classic --fast 标志优化性能
2026-07-18 19:18:52 +08:00
xsl 74ccab0ff8 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)
2026-07-18 18:58:40 +08:00
xslandCursor 659c037270 feat: ComfyUI 改走 10.60.74.221,测试页上传图超阈值自动降采样
将 worker 默认 ComfyUI 地址改为远端 10.60.74.221:8188;前端测试页在像素超过 1536000 时等比缩小到 786432 以内。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 02:23:35 +08:00
xsl c1bb9614c7 save code 2026-07-17 01:54:04 +08:00
xsl 2b1f528ddd perf: 接口2/12 跳过 viz 叠图生成,省 ~80ms
compute_mask 新增 render_viz 开关(默认 True 保持接口11 行为不变)。
接口2/12 路径(generate_hairline_redraw)传 render_viz=False,跳过 6+ 张
overlay JPG 的 _jpg_b64 编码(baseline/upper/hair_seg/mask/hairline/pushed),
只保留必需的数据字段(_inner_pts/_outer_pts/_upper_mask/mask_pixels)。
2026-07-17 01:37:53 +08:00
xsl d9e96aca87 chore: 重绘服务改走内网 10.60.74.221:8899
gpu_worker 与重绘服务在同一内网,走内网地址(3ms 延迟,比公网更稳定)。
三处调用地址(service.py / test_interface12 / test_interface12_final)
由公网 117.50.183.232 改为内网 10.60.74.221。仍可用 HAIR_LOCAL_REDRAW_URL 覆盖。
2026-07-17 00:57:58 +08:00
xsl 8aed389d79 chore: 重绘服务拆到远程机器 117.50.183.232:8899
接口2 female 后端(generate_grow_results_swap)、test_interface12.html、
test_interface12_final.html 三处重绘调用地址由本机 127.0.0.1:8899 改为远程
117.50.183.232:8899,本机不再跑重绘服务(释放本机显存)。

地址仍可用 HAIR_LOCAL_REDRAW_URL 环境变量覆盖。
2026-07-17 00:44:20 +08:00
xsl c72e3ceda9 asdf 2026-07-17 00:32:13 +08:00
xsl 99ce21334a asdf 2026-07-17 00:26:47 +08:00
xsl 5fbc03a6df feat: 接口12/接口2 发际线重绘改走 local_test 外部 ComfyUI 服务
后端 generate_hairline_redraw 跳过内置 Flux-2 重绘,改为产出 final(接缝融合基底)
+ 纯红遮罩 PNG(redraw_band_mask_base64,遮罩区=(255,0,0,255)、其余全透明)。

接口2 female(generate_grow_results_swap) 取 final+遮罩后在后端调 local_test
(0716add-hair.json 工作流) 完成重绘,结果作为生发图返回;male 分支不变。

测试页 test_interface12.html / test_interface12_final.html 改为两阶段:
先生成 final+纯红遮罩,再前端调 local_test 重绘并展示;color_match 默认不勾选。

local_test/app.py 加 CORS 头(OPTIONS 预检),支持浏览器跨域直连。
2026-07-17 00:00:15 +08:00
xsl 0bbb15d668 添加服务 2026-07-16 22:57:12 +08:00
xsl 632e75317b 接口6 增加字段 2026-07-16 12:38:10 +08:00
xsl 1cd4115b26 Merge branch 'main' of http://git.xiangsilian.com:3000/xsl/hair 2026-07-16 09:38:53 +08:00
xslandCursor e8a2c5a8a1 fix: 日志目录去掉硬编码 /home/xsl,改为基于仓库根解析(支持 HAIR_LOG_DIR 覆盖)
worker 部署到 /home/ubuntu 等其他路径时,原硬编码 /home/xsl/hair/log
会导致 Permission denied。改为相对仓库根,兼容多机部署。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 08:07:03 +08:00
xsl 0f8a7e27c0 部署线上5090时候的改动git commit -m 部署线上5090时候的改动。 2026-07-16 07:54:07 +08:00
xsl 12f34c44f2 修改发型为最终发型 2026-07-16 00:22:54 +08:00
xslandCursor a208fe88ec feat: 拆分接口11/12,新增接口12 final / final v2 精简重绘端点
- 接口11 移除重绘,仅生成 final;接口12 (grow_v2) 负责发际线带 Flux-2 重绘
- 接口12 重绘带改为发际线外推 band_lo_mult~band_hi_mult 倍 push(默认 0.5~1.5),页面可调
- 接口12 同时产出 A 整帧重绘 与 B 局部加发+全脸美颜(beauty_alpha 可调)
- 新增 grow_v2_final(整帧重绘)/ grow_v2_final_v2(B 局部+美颜)端点:仅需图片+发型 ID,其余用固化默认值(color_match 关闭)
- 配套精简测试页 test_interface12_final.html / test_interface12_final_v2.html / test_interface12.html
- 恢复接口11 调试页多频段与换发型可调参数、color_match 默认不勾选
- 删除旧脚本 batch_grow_v2.py / gen_report_hairline_v2.py / test_simple.html

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 00:04:36 +08:00
xsl 4001df2c34 save code 2026-07-15 01:16:32 +08:00
xsl 9386f84c88 docs: 同步默认提示词「加一点美颜」到文档/接入页/测试页
- docs/接口文档.md:接口2/3/5/7 参数表统一更新默认提示词,接口2/3/7
  补全此前缺失的 prompt 参数行;顺带修正接口7 输出表 image_url 描述
  为透明 PNG(上一轮透明 PNG 改动的遗漏)
- static/integration.html:接口2 描述句 + 接口7 字段表同步透明 PNG 描述
- static/test_interface2/3/7.html:prompt 输入框默认值同步更新
2026-07-13 23:46:40 +08:00
73 changed files with 5498 additions and 1101 deletions
+4
View File
@@ -49,3 +49,7 @@ image/test/
# 批量报告输出(生成图+原图,体积大,不入 git)
static/report_hairline_v2/
static/report_hairline_v2.zip
# local_test 运行期日志 / pid(不入 git)
local_test/hair_service.log
local_test/hair_service.pid
+327
View File
@@ -0,0 +1,327 @@
{
"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": "cpu"
}
},
"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"
}
}
}
File diff suppressed because one or more lines are too long
+6 -6
View File
@@ -2,7 +2,7 @@
"1": {
"inputs": {
"scheduler": "simple",
"steps": 6,
"steps": 4,
"denoise": 1,
"model": [
"2",
@@ -170,8 +170,8 @@
},
"16": {
"inputs": {
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn"
"unet_name": "flux-2-klein-4b-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn_fast"
},
"class_type": "UNETLoader",
"_meta": {
@@ -410,7 +410,7 @@
},
"60": {
"inputs": {
"text": "补充遮罩区补充遮罩区域的头发,头发填满遮罩区域。发际线往下挡住额头"
"text": "充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
},
"class_type": "JjkText",
"_meta": {
@@ -419,9 +419,9 @@
},
"61": {
"inputs": {
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
"clip_name": "qwen_3_4b.safetensors",
"type": "flux2",
"device": "default"
"device": "cpu"
},
"class_type": "CLIPLoader",
"_meta": {
+354 -177
View File
@@ -138,7 +138,47 @@ 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",
"/api/swapHair", "/hairColor")
# ---------------------------------------------------------------------------
# change_hair 代理路由(解决 CORS 问题)
# ---------------------------------------------------------------------------
_CHANGE_HAIR_BASE = "http://127.0.0.1:8801"
@app.post("/api/swapHair/v1", tags=["change_hair"])
async def proxy_swap_hair(request: Request):
"""代理转发到 change_hair /api/swapHair/v1(换发型)"""
try:
import httpx
body = await request.body()
async with httpx.AsyncClient(timeout=300.0) as client:
resp = await client.post(f"{_CHANGE_HAIR_BASE}/api/swapHair/v1",
content=body,
headers={"Content-Type": "application/json"})
return JSONResponse(content=resp.json(), status_code=resp.status_code)
except Exception as e:
logger.exception("代理 swapHair 失败")
return err(1007, f"换发型服务异常:{e}")
@app.post("/hairColor/v2", tags=["change_hair"])
async def proxy_hair_color(request: Request):
"""代理转发到 change_hair /hairColor/v2(换发色)"""
try:
import httpx
body = await request.body()
async with httpx.AsyncClient(timeout=300.0) as client:
resp = await client.post(f"{_CHANGE_HAIR_BASE}/hairColor/v2",
content=body,
headers={"Content-Type": "application/json"})
return JSONResponse(content=resp.json(), status_code=resp.status_code)
except Exception as e:
logger.exception("代理 hairColor 失败")
return err(1007, f"换发色服务异常:{e}")
@app.middleware("http")
@@ -361,41 +401,65 @@ def _run_face_measure_data(image, variant="v1"):
logger.warning("头发/耳朵分割失败,回退方案A%s", seg_e)
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
discarded = result.hairline_discarded
data = result.to_response()
vd = result.vertical
if variant == "v6":
vd = result.vertical
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
data["four_courts"]["ratios"] = {
"upper_court": round(vd["upper_court_px"] / base_px, 3),
"middle_court": round(vd["middle_court_px"] / base_px, 3),
"lower_court": round(vd["lower_court_px"] / base_px, 3),
}
data["four_courts"].pop("top_court_cm", None)
data["face_total_height_cm"] = round(
result.upper_cm + result.middle_cm + result.lower_cm, 2)
data["landmarks"].pop("hair_top", None)
if discarded:
# 发际线弃用:接口6 的上庭也依赖发际线,一并置 null;只保留中/下庭。
base_px = vd["middle_court_px"] + vd["lower_court_px"]
data["four_courts"]["upper_court_cm"] = None
data["four_courts"]["ratios"] = {
"upper_court": None,
"middle_court": round(vd["middle_court_px"] / base_px, 3),
"lower_court": round(vd["lower_court_px"] / base_px, 3),
}
data["four_courts"].pop("top_court_cm", None)
data["face_total_height_cm"] = round(
result.middle_cm + result.lower_cm, 2)
data["landmarks"]["hairline"] = None
else:
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top
data["four_courts"]["ratios"] = {
"upper_court": round(vd["upper_court_px"] / base_px, 3),
"middle_court": round(vd["middle_court_px"] / base_px, 3),
"lower_court": round(vd["lower_court_px"] / base_px, 3),
}
data["four_courts"].pop("top_court_cm", None)
data["face_total_height_cm"] = round(
result.upper_cm + result.middle_cm + result.lower_cm, 2)
# 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、
# 占比、标注图仍按三庭处理,显示效果不变。
# 七眼:从左到右共 7 段宽度(cm),仅接口1(v1)含人头最左/最右端线段。
# eye1=左耳外段 eye2=左脸颊段 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊段 eye7=右耳外段
# 端线取自耳朵分割外缘(与标注图同源);某侧耳朵不可见 → 该侧端线缺失 → 对应 eye 置 null(保留键)。
if variant != "v6":
try:
# 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
try:
epts = result.eyes["points"]
lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0]
pc = result.px_per_cm
inner_xs = [lcx, epts["left_outer"][0], epts["left_inner"][0],
epts["right_inner"][0], epts["right_outer"][0], rcx]
for i in range(5):
a, b = inner_xs[i], inner_xs[i + 1]
data["seven_eyes"][f"eye{i + 2}"] = (
None if (a is None or b is None) else round((b - a) / pc, 2))
if variant != "v6":
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线。
# 竖向范围:发际线弃用时用眉心做上界(hair_top 不可靠),否则用头顶。
from face_analysis.annotation import _ear_edges_from_mask
epts = result.eyes["points"]
lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0]
top_y = (vd["brow_center"][1] if discarded
else vd["hair_top"][1])
head_l, head_r = _ear_edges_from_mask(
ear_mask, hair_mask,
result.vertical["hair_top"][1], result.vertical["chin_tip"][1],
top_y, vd["chin_tip"][1],
lcx, rcx, (lcx + rcx) / 2)
xs = [head_l, lcx, epts["left_outer"][0], epts["left_inner"][0],
epts["right_inner"][0], epts["right_outer"][0], rcx, head_r]
pc = result.px_per_cm
for i in range(7):
a, b = xs[i], xs[i + 1]
data["seven_eyes"][f"eye{i + 1}"] = (
None if (a is None or b is None) else round((b - a) / pc, 2))
except Exception as seg_e: # noqa: BLE001
logger.warning("七眼段宽度计算失败:%s", seg_e)
data["seven_eyes"]["eye1"] = (
None if (head_l is None) else round((lcx - head_l) / pc, 2))
data["seven_eyes"]["eye7"] = (
None if (head_r is None) else round((head_r - rcx) / pc, 2))
except Exception as seg_e: # noqa: BLE001
logger.warning("七眼段宽度计算失败:%s", seg_e)
return data, result, hair_mask, ear_mask
@@ -463,7 +527,7 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
**标注图片 UI 规范**(真实版本生效):
- 字体/线条/箭头颜色:`#FFFFFF 100%`,透明底
- 字号/线宽/虚线/箭头按图片短边自适应缩放
- 四庭数值(名+数值两行,不带 cm)在图片**左侧**呈现,七眼段宽**上下穿插**展示,底部标「单位cm」
- 四庭(名 + 数值带cm + 百分比 三行)在图片**左侧**呈现,七眼段宽**上下穿插**展示(数值带cm,下方另起一行标占头宽百分比)
- 横线/竖线渐变消失并略超出端点;段宽/庭高用虚线 + 实心三角双箭头标示
- 竖线含人头最左/最右端线(取自头发分割轮廓),共 8 线 7 段
""",
@@ -547,13 +611,13 @@ async def face_measure(
输入用户正面照,返回:
- 标注好四庭七眼数据的 **PNG 图片**(仅标注图层,不含人物)
- 三庭(上庭/中庭/下庭)各段**厘米数值及占比**(**不含顶庭**)
- 七眼(眼宽/脸宽/两眼间距)**厘米数值及占比**
- 五眼段宽(eye2~eye6:左脸颊/左眼/两眼间距/右眼/右脸颊)**厘米数值**,另含眼宽/脸宽/两眼间距
- 四个关键分界点的**原图像素坐标**(发际线/眉心/鼻翼下缘/下巴尖)
基于接口1 的变体,与接口1 的差异:
- **去顶庭**:不画头顶横线、不返回顶庭数据;`face_total_height_cm` 为三庭之和
- **竖线范围**:纵向竖线从发际线画到下巴尖(接口1 为头顶→下巴尖)
- **不画人头最左/最右端线**:仅七眼 6 点共 5 段标尺,不取头发轮廓端线(接口1 为 8 线 7 段)
- **不画人头最左/最右端线**:仅七眼 6 点共 5 段标尺eye2~eye6,不取头发轮廓端线(接口1 为 8 线 7 段 eye1~eye7
其余(箭头/虚线/字体/单位cm/七眼数据)与接口1 一致。
@@ -568,7 +632,7 @@ async def face_measure(
**标注图片 UI 规范**(真实版本生效):
- 字体/线条/箭头颜色:`#FFFFFF 100%`,透明底
- 字号/线宽/虚线/箭头按图片短边自适应缩放
- 三庭数值(名+数值两行,不带 cm)在图片**左侧**呈现,七眼段宽**上下穿插**展示,底部标「单位cm」
- 三庭(名 + 数值带cm + 百分比 三行)在图片**左侧**呈现,七眼段宽**上下穿插**展示(数值带cm,下方另起一行标占头宽百分比)
- 段宽/庭高用虚线 + 实心三角双箭头标示
""",
responses={
@@ -598,6 +662,7 @@ async def face_measure(
"face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44,
"ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143},
"eye2": 3.44, "eye3": 3.44, "eye4": 3.44, "eye5": 3.44, "eye6": 3.44,
},
"landmarks": {
"hairline": {"x": 540, "y": 430},
@@ -649,6 +714,11 @@ async def face_measure_v2(
- 发际线类型 `hairline_type`(英文 key
- 顺序 `order`(本期固定 `1..N`,不排序)
> **female 走「换发型」模式**:生发图 `grown_image_base64` 由换发型(change_hair
> + Flux-2 整帧重绘(= 接口12 final 管线,整帧美颜+整帧重绘)生成,其余参数用固化默认值。
> **male 仍走原生发(ComfyUI add_hair)管线**。入参与返回结构不变。
> female 依赖 change_hair 与 ComfyUI(:8188) 均在跑。
{_image_fields_desc}
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
@@ -705,7 +775,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"):
@@ -728,10 +798,17 @@ async def hair_grow(
try:
from fastapi.concurrency import run_in_threadpool
from hairline.service import generate_grow_results
# 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_styles)
# 预览 + 生发/换发型 都是阻塞且较慢,放线程池避免卡住事件循环
# female:换发型 + Flux-2 整帧重绘(= 接口12 final 管线);male:仍走原生发管线。
if gender == "female":
from hairline.service import generate_grow_results_swap
items = await run_in_threadpool(
generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS)
else:
from hairline.service import generate_grow_results
items = await run_in_threadpool(
generate_grow_results, image, gender, use_mask, prompt, hair_styles)
if items is None:
return err(1001, "无法识别人像")
@@ -751,117 +828,15 @@ async def hair_grow(
# ---------------------------------------------------------------------------
# 接口 7C 端生发 v2add_hair2.json 工作流)
# 接口 7C 端生发 v2 —— 已弃用add_hair2.json 用 Klein-9b 大模型,会把常驻的
# Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。
# 保留路由返回明确错误,避免老客户端拿到裸 404。
# ---------------------------------------------------------------------------
_WORKFLOW2_PATH = os.path.join(os.path.dirname(__file__), "add_hair2.json")
@app.post(
"/api/v1/hair/grow-v2",
summary="接口7 C端生发 v2add_hair2 工作流)",
tags=["生发"],
description=f"""
输入用户正面照 + **性别** + **发型序号**,使用 add_hair2.json 工作流生成指定发际线类型的预览图与生发图。
功能与接口2 完全一致,仅 ComfyUI 工作流不同。
{_image_fields_desc}
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
---
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。
非法或缺失返回 `1004`。
- **hair_style**(必填):`int`,发型序号。`female`1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave
`male`1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界返回 `1007`。
- **beauty_enabled**:本期保留但不生效。
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`female),
`ellipse` / `m` / `straight` / `inverse_arc`male)。
""",
responses={
200: {
"description": "成功",
"content": {
"application/json": {
"example": {
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"results": [
{"image_base64": "iVBORw0KGgo...", "hairline_type": "ellipse", "order": 1},
]
},
}
}
},
},
400: {
"description": "参数错误 / 图片识别失败",
"content": {
"application/json": {
"examples": {
"图片参数错误": {"value": {"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个", "request_id": "x", "data": None}},
"非正面照": {"value": {"code": 1003, "message": "角度问题,请上传正面照", "request_id": "x", "data": None}},
}
}
},
},
},
)
async def hair_grow_v2(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
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"),
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
prompt: str = Form(default="补充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
):
# 1. gender 必填校验(非法/缺失 → 1004)
if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female")
# 2. hair_style 必填校验(解析逗号分隔,越界 → 1007)
max_styles = {"female": 5, "male": 4}[gender]
hair_styles = _parse_hair_styles(hair_style, max_styles)
if hair_styles is None:
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
# 3. 三选一取图
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if image is None:
return err(1008, "图片格式不支持(仅 JPG / PNG)")
try:
from fastapi.concurrency import run_in_threadpool
from hairline.service import generate_grow_results
# 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_styles, _WORKFLOW2_PATH)
if items is None:
return err(1001, "无法识别人像")
results = []
for p in items:
results.append({
"image_base64": _jpg_b64(p["image_bgr"]), # 预览图 JPG
"grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG
if p["grown_png"] else None),
"hairline_type": p["hairline_type"],
"order": p["order"],
})
return ok({"results": results})
except Exception as ex: # noqa: BLE001
logger.exception("接口7 处理异常")
return err(1007, f"处理失败:{ex}")
@app.post("/api/v1/hair/grow-v2", include_in_schema=False, deprecated=True)
async def hair_grow_v2():
"""接口7 已弃用:请改用 /api/v1/hair/grow(接口2)。"""
return err(1007, "接口7/api/v1/hair/grow-v2)已弃用,请使用 /api/v1/hair/grow")
# ---------------------------------------------------------------------------
@@ -911,7 +886,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)
@@ -1042,6 +1017,8 @@ async def face_features(
`female`1=ellipse,2=flower,3=heart,4=straight,5=wave`male`1=ellipse,2=inverse_arc,3=m,4=straight。
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。
注:生发黑模板固定取 `hairline_texture_black/`middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle。
- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(ComfyUI 生发,全流程最耗时)。
`false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,大幅降低耗时。
**返回说明**
@@ -1050,8 +1027,10 @@ async def face_features(
- `grown_image_url`:该发型的**生发图**(生发失败时为 `null`)。
- `hairline_type`:发际线类型 key。
worker 返回 `*_base64`,网关落盘后改写为 `*_url`。
- `best_hairline_center_point`**首个选中发型**的 middle 档发际线曲线**面部中间点**坐标,
- `best_hairline_center_point`**首个选中发型**的 **middle 档**发际线曲线**面部中间点**坐标,
以**原图像素**为基准(左上角为原点,x 向右,y 向下)。
- `high_hairline_center_point`:同上,**high 档**发际线中点。
- `low_hairline_center_point`:同上,**low 档**发际线中点。
""",
responses={
200: {
@@ -1068,6 +1047,8 @@ async def face_features(
{"hairline_type": "flower", "image_middle_base64": "iVBORw0KGgo...", "image_high_base64": "iVBORw0KGgo...", "image_low_base64": "iVBORw0KGgo...", "grown_image_base64": None, "order": 2},
],
"best_hairline_center_point": {"x": 540, "y": 430},
"high_hairline_center_point": {"x": 540, "y": 380},
"low_hairline_center_point": {"x": 540, "y": 480},
"face_measure": {
"face_total_height_cm": 13.76,
"four_courts": {
@@ -1115,7 +1096,8 @@ 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的文本"),
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(ComfyUI 生发,最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
):
if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female")
@@ -1139,7 +1121,8 @@ async def hairline_generate(
from hairline.service import generate_hairline_pngs
res = await run_in_threadpool(
generate_hairline_pngs, image, gender, hair_styles, use_mask, prompt)
generate_hairline_pngs, image, gender, hair_styles, use_mask, prompt,
generate_grow_image=generate_grow_image)
if res is None:
return err(1001, "无法识别人像")
@@ -1155,10 +1138,14 @@ async def hairline_generate(
if it.get("grown_png") else None),
"order": it["order"],
})
c = res["best_center"]
c = res.get("best_centers") or {}
def _pt(p):
return ({"x": p[0], "y": p[1]} if p else None)
data = {
"hairline_images": hairline_images,
"best_hairline_center_point": ({"x": c[0], "y": c[1]} if c else None),
"best_hairline_center_point": _pt(c.get("middle")),
"high_hairline_center_point": _pt(c.get("high")),
"low_hairline_center_point": _pt(c.get("low")),
}
# face_measure:复用接口1测量数值(四庭/七眼 eye1~eye7/landmarks/姿态),不含标注图。
@@ -1395,8 +1382,16 @@ async def hairline_grow(
edge_erode_px: int = Form(default=3, description="贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3"),
denoising_strength: float = Form(default=0.6, description="换发型 webui 重绘强度(越大生发越激进),默认 0.6"),
mb_levels: int = Form(default=5, description="多频段金字塔层数(2~6,越大低频色差抹得越宽),默认 5"),
blend_method: str = Form(default="multiband", description="接缝融合方法:multiband(多频段金字塔,默认) | seamless(泊松无缝克隆) | two_stage(泊松→多频段两段式,大色差场景) | feather(高斯羽化) | alpha_gradient(距离变换内渐变)"),
color_match: bool = Form(default=True, description="融合前 Reinhard 颜色迁移消除整体色差(对 multiband/feather/alpha_gradient 有效;seamless/two_stage 自带调色故跳过),默认 True"),
color_match_strength: float = Form(default=1.0, description="颜色迁移强度(0~1,1=全迁移,<1 只迁移部分防过度改色),默认 1.0"),
mb_feather_px: int = Form(default=1, description="多频段最细层掩码轻羽化像素(0=不羽化,消除发丝边缘锯齿),默认 1"),
transition_band_px: int = Form(default=-1, description="keep-region 过渡带边距(-1=自动按层数 2**n,>=0 用绝对像素与层数解耦),默认 -1"),
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图(治染绿) | 1=填充噪声(默认/原始) | 2=纯色 | 3=潜变量噪声。默认 1"),
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素(原始11,越大颜色越易从边缘渗透),默认 11"),
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放(1.0=原始核尺寸,<1收缩防越界),默认 1.0"),
):
"""接口11:发际线生发 + 分步可视化。遮罩固定 pushed融合固定 multiband。"""
"""接口11:发际线生发 + 分步可视化**不含重绘**,重绘见接口12。遮罩固定 pushed融合默认 multiband(可选 seamless/two_stage/feather"""
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
@@ -1410,8 +1405,12 @@ async def hairline_grow(
from face_analysis.hairline_grow import generate_hairline_grow, NoFaceError, SwapError
from uuid import uuid4 as _uuid4
rid = _uuid4().hex[:8]
logger.info("[%s] 接口11 收到请求: hairline_push_cm=%s hairline_edge=%s mb_levels=%s",
rid, hairline_push_cm, hairline_edge, mb_levels)
logger.info("[%s] 接口11 收到请求: hairline_push_cm=%s hairline_edge=%s mb_levels=%s "
"blend=%s color_match=%s cm_strength=%s mb_feather_px=%s transition_band_px=%s "
"inpainting_fill=%s mask_blur=%s mask_dilate_scale=%s",
rid, hairline_push_cm, hairline_edge, mb_levels, blend_method,
color_match, color_match_strength, mb_feather_px, transition_band_px,
inpainting_fill, mask_blur, mask_dilate_scale)
try:
data = await run_in_threadpool(
generate_hairline_grow, image, hairline_id,
@@ -1419,7 +1418,11 @@ async def hairline_grow(
edge_erode_px=edge_erode_px, denoising_strength=denoising_strength,
gen_backend=gen_backend, hairgrow_strength=hairgrow_strength,
mb_levels=mb_levels, hairline_push_cm=hairline_push_cm,
hairline_edge=hairline_edge, rid=rid)
hairline_edge=hairline_edge, blend_method=blend_method,
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)
except NoFaceError:
return err(1001, "无法识别人像")
except SwapError as se:
@@ -1432,20 +1435,33 @@ async def hairline_grow(
# ---------------------------------------------------------------------------
# 接口 12:发际线生发(接口11 固定参数精简版
# 接口 12:发际线带重绘(调用接口11 的 ④final 作输入 + ⑤-①重绘带作遮罩,Flux-2 重绘
# ---------------------------------------------------------------------------
@app.post(
"/api/v1/hairline/grow_v2",
summary="接口12 发际线生发(固定 pushed 遮罩 + 多频段融合,仅返回最终图",
summary="接口12 发际线带重绘(接口11 final + 发际线重绘带 → Flux-2 保色重绘",
tags=["生发"],
description=f"""
接口11 的固定参数精简版,适合生产直调。与接口11 共用同一管线,遮罩固定 pushed(发际线外推)
融合固定 multiband(多频段金字塔)。本接口固定 `erode_cm=0.6`、`mb_levels=5` 不暴露,
**只返回 `final_base64`**(最终合成图),不附带分步可视化。
接口12 是接口11 的**下游重绘阶段**。内部先跑接口11 核心管线拿到 **④ 接缝融合最终图(final)**
再取 **⑤-① 发际线重绘带**(发际线沿外推方向 `band_lo_mult×push` ~ `band_hi_mult×push`
之间、经 ①-a baseline 截断只留上部的带状区域,默认 0.5×~1.5×push
作为遮罩,用 **Flux-2ComfyUI** 做 reference-latent 保色重绘(不易染绿),重绘结果再与 final 融合。
其余参数(hairline_id、seg_model、gen_backend、is_hr、denoising_strength、edge_erode_px、
hairline_push_cm、hairline_edge 等)保留为可选 Form,调用方可按需覆盖。
与接口11 的关系:接口11 只负责生成 final(不再含重绘);接口12 负责在 final 上做发际线带重绘。
接口11 的可调参数(seg_model/gen_backend/hairline_push_cm/blend_method/color_match 等)
在本接口同样暴露,用于内部生成 final 与重绘带;另有 `comfyui_prompt` 控制 Flux-2 提示词。
⚠️ 依赖 ComfyUI(默认 :8188)在跑,否则重绘失败会在 `data.redraw.c_error` 报告。
**同时产出两版结果供对比**
- `redraw_full`ComfyUI 整帧输出(全脸美颜 + 全脸重绘),与手动跑 ComfyUI 一致。
- `redraw_band`:加发只在发际线带、美颜保留全脸(band 内用 ComfyUI 重绘,band 外 = final 结构
+ 按 `beauty_alpha` 融入全脸美颜)。
返回 `data.steps``input`(原图)/ `final`(接口11 的 ④,重绘输入基底)/
`redraw_band_overlay`(⑤-① 重绘带可视化)/ `redraw_full`A 整帧)/ `redraw_band`B 局部加发+全脸美颜)/
`redraw_c`(兼容旧字段,=redraw_full)。
{_image_fields_desc}
""",
@@ -1459,13 +1475,27 @@ async def hairline_grow_v2(
hairgrow_strength: float = Form(default=0.75, description="区域生发强度(仅 hairgrow 后端),默认 0.75"),
is_hr: bool = Form(default=False, description="高清模式(换发型输出 1152×1536,否则 576×768"),
seg_model: str = Form(default="segformer", description="头发分割模型:bisenet | segformer(默认 segformer"),
erode_cm: float = Form(default=0.6, description="baseline 参考内缩距离(厘米),默认 0.6"),
swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask | as_is(默认 ext_mask"),
edge_erode_px: int = Form(default=3, description="贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3"),
denoising_strength: float = Form(default=0.6, description="换发型 webui 重绘强度(越大生发越激进),默认 0.6"),
mb_levels: int = Form(default=5, description="多频段金字塔层数(2~6),默认 5"),
hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米),默认 1.0"),
hairline_edge: str = Form(default="column", description="发际线提取方式:column | contour,默认 column"),
blend_method: str = Form(default="multiband", description="接缝融合方法:multiband | seamless | two_stage | feather | alpha_gradient"),
color_match: bool = Form(default=True, description="融合前 Reinhard 颜色迁移消除整体色差,默认 True"),
color_match_strength: float = Form(default=1.0, description="颜色迁移强度(0~1),默认 1.0"),
mb_feather_px: int = Form(default=1, description="多频段最细层掩码轻羽化像素,默认 1"),
transition_band_px: int = Form(default=-1, description="keep-region 过渡带边距(-1=自动),默认 -1"),
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 用默认「填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜」"),
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"),
):
"""接口12:发际线生发(固定 pushed 遮罩 + multiband 融合,仅返回最终图)"""
"""接口12:发际线带重绘。同时产出 redraw_full(整帧美颜) 与 redraw_band(局部加发+全脸美颜) 两版对比"""
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
@@ -1476,43 +1506,190 @@ async def hairline_grow_v2(
try:
from fastapi.concurrency import run_in_threadpool
from face_analysis.hairline_grow import generate_hairline_grow, NoFaceError, SwapError
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError, SwapError
from uuid import uuid4 as _uuid4
rid = _uuid4().hex[:8]
logger.info("[%s] 接口12 收到请求: hairline_id=%s hairline_push_cm=%s blend=%s comfyui_prompt=%r",
rid, hairline_id, hairline_push_cm, blend_method, comfyui_prompt)
try:
# 固定:mask_type=pushed、blend_method=multiband、erode_cm=0.6、mb_levels=5
data = await run_in_threadpool(
generate_hairline_grow, image, hairline_id,
is_hr=is_hr, seg_model=seg_model, erode_cm=0.6, swap_mode=swap_mode,
generate_hairline_redraw, image, hairline_id,
is_hr=is_hr, seg_model=seg_model, erode_cm=erode_cm, swap_mode=swap_mode,
edge_erode_px=edge_erode_px, denoising_strength=denoising_strength,
gen_backend=gen_backend, hairgrow_strength=hairgrow_strength,
mb_levels=5, hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge)
mb_levels=mb_levels, hairline_push_cm=hairline_push_cm,
hairline_edge=hairline_edge, blend_method=blend_method,
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, comfyui_prompt=comfyui_prompt,
beauty_alpha=beauty_alpha, band_lo_mult=band_lo_mult,
band_hi_mult=band_hi_mult, rid=rid)
except NoFaceError:
return err(1001, "无法识别人像")
except SwapError as se:
return err(1007, f"换发型失败:{se}")
# 精简返回:只取最终合成图,丢掉接口11 的全部分步可视化
return ok({
"hairline_id": data["hairline_id"],
"image_size": data["image_size"],
"final_base64": data["steps"]["final_base64"],
})
logger.info("[%s] 接口12 成功返回", rid)
return ok(data)
except Exception as ex: # noqa: BLE001
logger.exception("接口12 处理异常")
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 接口 12 final:精简版发际线带重绘(仅需图片 + 发型 ID,其余参数全用默认值)
# ---------------------------------------------------------------------------
# 接口12 final 固化的默认参数(= test_interface12.html 当前默认值,color_match 关闭)
_V2_FINAL_DEFAULTS = dict(
gen_backend="swaphair", hairgrow_strength=0.75, is_hr=False, seg_model="segformer",
erode_cm=0.6, swap_mode="ext_mask", edge_erode_px=3, denoising_strength=0.6,
mb_levels=5, hairline_push_cm=0.8, hairline_edge="column", blend_method="two_stage",
color_match=False, color_match_strength=0.4, mb_feather_px=1, 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,
)
async def _run_v2_final(image_file, image_url, image_base64, hairline_id, tag):
"""接口12 final / final v2 共用:仅需图片 + hairline_id,其余用固化默认值。
两者后端计算完全一致(同一次 ComfyUI 输出同时含 A 整帧与 B 局部+美颜),
差异仅在配套测试页展示哪一版。"""
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if image is None:
return err(1008, "图片格式不支持(仅 JPG / PNG)")
try:
from fastapi.concurrency import run_in_threadpool
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError, SwapError
from uuid import uuid4 as _uuid4
rid = _uuid4().hex[:8]
logger.info("[%s] %s 收到请求: hairline_id=%s(其余用默认值)", rid, tag, hairline_id)
try:
data = await run_in_threadpool(
generate_hairline_redraw, image, hairline_id,
rid=rid, **_V2_FINAL_DEFAULTS)
except NoFaceError:
return err(1001, "无法识别人像")
except SwapError as se:
return err(1007, f"换发型失败:{se}")
logger.info("[%s] %s 成功返回", rid, tag)
return ok(data)
except Exception as ex: # noqa: BLE001
logger.exception("%s 处理异常", tag)
return err(1007, f"处理失败:{ex}")
@app.post(
"/api/v1/hairline/grow_v2_final",
summary="接口12 final 精简重绘(整帧重绘;仅图片 + 发型 ID)",
tags=["生发"],
description=f"""
接口12 的**精简/生产版**:只需上传图片 + 选择 `hairline_id`,其余所有参数固化为当前调优默认值
hairline_push_cm=0.8 / blend_method=two_stage / **color_match=关闭** / color_match_strength=0.4 /
beauty_alpha=0.6 / band_lo_mult=0.5 / band_hi_mult=1.5 等)。
**最终重绘取整帧重绘**`data.steps.redraw_full`,全脸美颜 + 全脸重绘)。
返回结构与接口12 一致(`data.steps` 同时含 `redraw_full`A 整帧)/ `redraw_band`B 局部+美颜))。
⚠️ 依赖 ComfyUI(默认 :8188)在跑。
{_image_fields_desc}
""",
)
async def hairline_grow_v2_final(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
hairline_id: str = Form(..., description="发际线类型 ID= change_hair hair_id,如 chang_bolang"),
):
"""接口12 final:整帧重绘版,仅需图片 + hairline_id。"""
return await _run_v2_final(image_file, image_url, image_base64, hairline_id, "接口12final")
@app.post(
"/api/v1/hairline/grow_v2_final_v2",
summary="接口12 final v2 精简重绘(B 局部加发+全脸美颜;仅图片 + 发型 ID)",
tags=["生发"],
description=f"""
接口12 final 的**局部加发版**:参数与 `grow_v2_final` 完全相同(color_match 关闭等),
唯一区别是**最终重绘取 B 局部加发+全脸美颜**`data.steps.redraw_band`
加发只在发际线带内、band 外保留 final 结构并按 beauty_alpha 融入全脸美颜)。
返回结构与接口12 一致(`data.steps` 同时含 `redraw_full`A 整帧)/ `redraw_band`B 局部+美颜))。
⚠️ 依赖 ComfyUI(默认 :8188)在跑。
{_image_fields_desc}
""",
)
async def hairline_grow_v2_final_v2(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
hairline_id: str = Form(..., description="发际线类型 ID= change_hair hair_id,如 chang_bolang"),
):
"""接口12 final v2:B 局部加发+全脸美颜版,仅需图片 + hairline_id。"""
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="""
传入人物图片 + 遮罩图片,直接调 ComfyUI0716add-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 遮罩计算全过程)
# ---------------------------------------------------------------------------
@app.get("/api/v1/debug/hairline_log", include_in_schema=False)
async def download_hairline_log(rid: Optional[str] = None, tail: int = 500):
"""返回 /home/xsl/hair/log/hairline_grow.log 的内容。
"""返回 <仓库根>/log/hairline_grow.log 的内容。
rid 非空时只返回该 request id 相关的行;tail 限制返回最后 N 行(默认 500)。
供调试页"下载日志"按钮调用。
"""
from fastapi.responses import PlainTextResponse
log_path = "/home/xsl/hair/log/hairline_grow.log"
log_path = os.getenv(
"HAIR_LOG_DIR",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "log"),
)
log_path = os.path.join(log_path, "hairline_grow.log")
try:
with open(log_path, encoding="utf-8") as fh:
lines = fh.readlines()
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=ComfyUI (127.0.0.1:8188)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/ComfyUI
ExecStart=/home/ubuntu/ComfyUI/venv/bin/python main.py --listen 127.0.0.1 --port 8188 --cache-classic --fast
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,65 @@
{
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"hairline_id": "chang_zhixian",
"gen_backend": "swaphair",
"hairgrow_strength": 0.75,
"is_hr": false,
"seg_model": "segformer",
"mask_type": "pushed",
"erode_cm": 0.6,
"swap_mode": "ext_mask",
"blend_method": "multiband",
"edge_erode_px": 3,
"mb_levels": 5,
"hairline_push_cm": 1.0,
"hairline_edge": "column",
"denoising_strength": 0.6,
"color_match": true,
"color_match_strength": 1.0,
"mb_feather_px": 1,
"transition_band_px": -1,
"inpainting_fill": 1,
"mask_blur": 11,
"mask_dilate_scale": 1.0,
"px_per_cm": 47.5311,
"erode_px": 29,
"hair_pixels": 186798,
"closed_pixels": 191712,
"mask_pixels": 140299,
"image_size": {
"width": 1257,
"height": 1495
},
"timings_ms": {
"mask": 1462,
"swap": 5596,
"blend": 220
},
"redraw": {
"enabled": false
},
"_rid": "bc7205a4",
"steps": {
"input_base64": "<omitted 427407 chars>",
"baseline_overlay_base64": "<omitted 435895 chars>",
"upper_overlay_base64": "<omitted 384479 chars>",
"hair_seg_overlay_base64": "<omitted 428039 chars>",
"top_fill_overlay_base64": "",
"closed_overlay_base64": "",
"hairline_overlay_base64": "<omitted 445467 chars>",
"pushed_overlay_base64": "<omitted 452803 chars>",
"mask_overlay_base64": "<omitted 420375 chars>",
"mask_base64": "<omitted 8026 chars>",
"swap_raw_base64": "<omitted 340791 chars>",
"hard_paste_base64": "<omitted 420679 chars>",
"alpha_base64": "<omitted 7762 chars>",
"final_base64": "<omitted 415811 chars>",
"redraw_band_overlay_base64": "",
"redraw_a_base64": "",
"redraw_c_base64": ""
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

+177
View File
@@ -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_mapint 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) 路径→曲线maskuint8 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
│ │
│ └─ 步骤4ComfyUI 生发
│ └─ 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)(需手动在图上画发际线后作为划线图上传)
@@ -1,12 +1,12 @@
# 发际线生发遮罩算法(pushed 模式)
> 对应接口11 `/api/v1/hairline/grow`、接口12 `/api/v1/hairline/grow_v2`。
> 遮罩算法固定为 pushed融合算法固定为 multiband已移除其他选项)
> 代码:`face_analysis/hairline_grow.py``_extract_hairline` / `_pushed_mask` / `compute_mask`)。
> 遮罩算法固定为 pushed融合算法默认 multiband多频段金字塔),接口11 可切换 seamless/two_stage/feather
> 代码:`face_analysis/hairline_grow.py``_extract_hairline` / `_pushed_mask` / `compute_mask` / `_composite`)。
## 概述
pushed 是发际线生发的**唯一**遮罩算法multiband(多频段金字塔)是**唯一**融合算法。它从头发分割结果中提取「头发/皮肤交界线」(发际线),以眉心为圆心逐点径向外推一段距离,与 baseline 组成闭合区域作为最终遮罩。这样遮罩顶部会覆盖现有头发下沿一小段,贴回生发结果时顶部与真头发重叠、过渡自然。
pushed 是发际线生发的**唯一**遮罩算法。融合算法默认 multiband(多频段金字塔),接口11 暴露 `blend_method` 可切换为 seamless(泊松)/two_stage(泊松→多频段两段式)/feather(羽化),便于对比调优。它从头发分割结果中提取「头发/皮肤交界线」(发际线),以眉心为圆心逐点径向外推一段距离,与 baseline 组成闭合区域作为最终遮罩。这样遮罩顶部会覆盖现有头发下沿一小段,贴回生发结果时顶部与真头发重叠、过渡自然。
> 接口12 `/api/v1/hairline/grow_v2` 只需传 `image` + `hairline_id`,遮罩和融合全部固定,无需任何算法选择参数。
@@ -57,14 +57,48 @@ segformer(默认)或 bisenet 得到的头发二值掩码。
## 关键参数
遮罩算法(pushed融合算法(multiband)已固定,接口不再暴露选择参数可调的只有
遮罩算法(pushed固定。融合算法接口11 通过 `blend_method` 可切换(默认 multiband),其余融合参数可调:
| 参数 | 默认 | 说明 |
|---|---|---|
| `hairline_push_cm` | 1.0 | 内轮廓径向外推距离(厘米),= push_px / px_per_cm。`px_per_cm` 由虹膜直径标定 |
| `hairline_edge` | `column` | 兼容保留的入参;内轮廓提取(轮廓+内侧判定)不再按它分支,取值不影响结果 |
| `mb_levels` | 5 | 多频段金字塔层数(2~6,越大低频色差抹得越宽)|
| `blend_method` | `multiband` | 接缝融合:multiband(多频段金字塔) / seamless(泊松) / two_stage(泊松→多频段,大色差) / feather(羽化) / alpha_gradient。接口12 固定 multiband |
| `color_match` | `true` | 融合前 Reinhard 颜色迁移消除整体色差(multiband/feather/alpha_gradient 生效;seamless/two_stage 自带调色故跳过)|
| `color_match_strength` | 1.0 | 颜色迁移强度(0~1<1 只迁移部分,防 Reinhard 过度改色)|
| `mb_feather_px` | 1 | 多频段最细层掩码轻羽化像素(0=不羽化),消除发丝边缘锯齿 |
| `transition_band_px` | -1 | keep-region 过渡带边距(-1=自动按层数 `2**n`;>=0 用绝对像素与层数解耦)|
| `edge_erode_px` | 3 | 贴图前遮罩内缩像素(防边缘露皮/光晕)|
| `erode_cm` | 0.6(接口12 固定)| baseline 参考内缩距离,对 pushed 影响很小 |
| `redraw` | `false` | 发际线带重绘开关:开启后用 final(④融合图)在「外推线↔发际线」带重绘,swapHair/Flux-2 两路对比,结果单独展示(不替换 final)|
| `inpainting_fill` | 1 | change_hair 重绘填充:0=保留原图(治染绿) / 1=填充噪声(默认) / 2=纯色 / 3=潜变量噪声 |
| `mask_blur` | 11 | change_hair 遮罩边缘模糊像素(越大颜色越易从边缘渗透)|
| `mask_dilate_scale` | 1.0 | change_hair 遮罩膨胀核缩放(1.0=原始,<1 收缩防越界)|
| `comfyui_prompt` | `null` | redraw Flux-2 路提示词,null 用默认「补充遮罩区域的头发,加一点美颜」|
> 接口12 `/api/v1/hairline/grow_v2` 只需传 `image` + `hairline_id`,遮罩和融合全部用默认值(multiband + color_match=true),不暴露算法选择参数。
### 融合方法选择建议
- **multiband**(默认):常规首选。低频抹色差、高频保发丝。需配合 `color_match=true` 消除整体色差。
- **two_stage**:生成图与原图色差大时用。先泊松克隆统一色调,再多频段贴细节,兼顾调色与保发丝。比纯 seamless 更不易溢色。
- **seamless**:纯泊松梯度域调和,色调统一干净,但可能整体改色/边缘溢色。
- **feather / alpha_gradient**:单层 alpha 过渡,最轻量,但过渡带内色差不会被抹平,仅适合色差极小的场景。
## 发际线带重绘(redraw,接口11 可选)
`redraw=true` 时,在主流程(④接缝融合 final)之后额外跑一条重绘分支,结果单独展示(`steps.redraw_a` / `redraw_c`),**不替换** final。
**重绘区域** = ①-g 外推发际线(`outer_pts`)与 ①-f 发际线(`inner_pts`)两条折线端点相连组成的带状闭合区域(宽度 ≈ `hairline_push_cm`,只覆盖发际线交界处)。
**两路后端对比**(输入图 + 融合基底都用 final):
- **swapHair 路**`redraw_a`):final + 带遮罩调 change_hair → final 走 multiband 融合
- **Flux-2 路**`redraw_c`):final + 带遮罩调 ComfyUI`hair_repaint.json` 工作流)→ final 走 multiband 融合。Flux-2 经 reference latent + ColorMatch 双重保色,**不易染绿**
> `inpainting_fill` / `mask_blur` / `mask_dilate_scale` 透传 change_hair 服务端(仅影响 swapHair 路)。`comfyui_prompt` 仅影响 Flux-2 路。
> 两路独立容错:任一路失败只跳过该路,不影响另一路和主 final。
> ⚠️ Flux-2 路需 ComfyUI8188)在跑;swapHair 路需 change_hair8801)在跑。
## 与旧模式(eroded/closed,已移除)的区别
+182
View File
@@ -0,0 +1,182 @@
# 接口11 运行记录 — `image/hair_test.jpg`
> 实测时间:2026-07-15
> 调用:`POST http://127.0.0.1:8187/api/v1/hairline/grow`
> 鉴权:`X-Internal-Token: dev-shared-secret-2026`
> 输入图:`image/hair_test.jpg`1257×1495
> `hairline_id``chang_zhixian`(直线);其余全部走接口默认值
> 业务结果:`code=0``rid=bc7205a4`
> 产物目录:`docs/iface11_hair_test_run/`
---
## 1. 本次调用用到的全部默认参数
未在 Form 里显式传的参数均取 `app.py` / `generate_hairline_grow` 默认值;下表即本次实际生效值。
| 参数 | 本次值 | 说明 |
|------|--------|------|
| `hairline_id` | `chang_zhixian` | **必填**。发际线类型 = change_hair 的 `hair_id`(直线) |
| `gen_backend` | `swaphair` | 生成后端:换发型 LoRA |
| `hairgrow_strength` | `0.75` | 仅 `hairgrow` 后端用;本次未走该路径 |
| `is_hr` | `false` | 高清关闭(576×768 档,非 1152×1536 |
| `seg_model` | `segformer` | 头发分割模型 |
| `erode_cm` | `0.6` | baseline 参考内缩(cm);pushed 下影响很小 |
| `hairline_push_cm` | `1.0` | 发际线内轮廓径向外推距离(cm) |
| `hairline_edge` | `column` | 兼容入参;当前内轮廓提取不再按它分支 |
| `swap_mode` | `ext_mask` | 把 pushed 遮罩作为 `ext_mask` 传给 swapHair |
| `edge_erode_px` | `3` | 贴图前遮罩内缩像素 |
| `denoising_strength` | `0.6` | 换发型 webui 重绘强度 |
| `mb_levels` | `5` | 多频段金字塔层数 |
| `blend_method` | `multiband` | 接缝融合:多频段金字塔 |
| `color_match` | `true` | 融合前 Reinhard 颜色迁移 |
| `color_match_strength` | `1.0` | 颜色迁移强度(全迁移) |
| `mb_feather_px` | `1` | 多频段最细层掩码轻羽化 |
| `transition_band_px` | `-1` | keep-region 过渡带:自动按层数 `2**n` |
| `redraw` | `false` | 发际线带重绘关闭 |
| `inpainting_fill` | `1` | change_hair 填充噪声 |
| `mask_blur` | `11` | change_hair 遮罩边缘模糊像素 |
| `mask_dilate_scale` | `1.0` | change_hair 遮罩膨胀缩放 |
| `comfyui_prompt` | `null` | 仅 `redraw`+Flux-2 路用;本次未用 |
| `mask_type` | `pushed`(固定) | 代码写死,不可选 |
图片入参:仅传了 `image_file`(三选一中的文件上传)。
---
## 2. 返回元数据(无 base64
| 字段 | 值 |
|------|-----|
| `px_per_cm` | 47.5311(虹膜直径标定) |
| `erode_px` | 29(≈ 0.6cm × px_per_cm |
| `hair_pixels` | 186798 |
| `closed_pixels` | 191712 |
| `mask_pixels` | 140299 |
| `image_size` | 1257 × 1495 |
| `timings_ms.mask` | 1462 |
| `timings_ms.swap` | 5596 |
| `timings_ms.blend` | 220 |
| `redraw.enabled` | false |
| 总耗时(curl | ≈ 7.4 s |
完整精简 JSON`docs/iface11_hair_test_run/response_meta.json`
完整原始响应(含 base64):`docs/iface11_hair_test_run/response.json`
---
## 3. 管线分步说明与产物
管线:① pushed 遮罩 → ② swapHair 生成 → ③ 硬贴回 → ④ multiband 融合。
各步图保存在 `docs/iface11_hair_test_run/steps/`
### ①-a 发际线分割线(baseline
- **做什么**:MediaPipe 关键点连成眉骨折线(中心为 151 眉心),并向左右边缘水平延长。
- **图**[`steps/baseline_overlay.jpg`](iface11_hair_test_run/steps/baseline_overlay.jpg)
- **含义**:黄线 = baseline;151 中心点为后续径向外推圆心。
### ①-b 分割线上半区(upper
- **做什么**:baseline 折线以上的多边形区域,作为后续裁剪范围。
- **图**[`steps/upper_overlay.jpg`](iface11_hair_test_run/steps/upper_overlay.jpg)
- **含义**:青 = 上半区。
### ①-c 头发分割(hair_seg
- **做什么**:SegFormer 得到头发二值掩码。
- **图**[`steps/hair_seg_overlay.jpg`](iface11_hair_test_run/steps/hair_seg_overlay.jpg)
- **含义**:绿 = 原始头发像素(本次 `hair_pixels=186798`)。
### ①-d / ①-e(旧 eroded/closed 中间步)
- pushed 模式**不走**这两步;返回字段为空字符串。
- `top_fill_overlay` / `closed_overlay`:本次无图。
### ①-f 头发内轮廓线(hairline
- **做什么**:取头发朝脸一侧的内轮廓(额头弧 + 两侧到下颌),有序折线。
- **图**[`steps/hairline_overlay.jpg`](iface11_hair_test_run/steps/hairline_overlay.jpg)
- **含义**:绿 = 内轮廓;黄 = baseline。
### ①-g 外推发际线(pushed
- **做什么**:以眉心 151 为圆心,内轮廓逐点向外推 `hairline_push_cm=1.0`(≈ 47.5 px),与 baseline 组闭合区域。
- **图**[`steps/pushed_overlay.jpg`](iface11_hair_test_run/steps/pushed_overlay.jpg)
- **含义**:青 = 外推线;红 = 外推遮罩区域。
### ① 最终遮罩
- **叠加图**[`steps/mask_overlay.jpg`](iface11_hair_test_run/steps/mask_overlay.jpg) — 红 = 遮罩区(贴回/生成区)
- **纯遮罩**[`steps/mask.png`](iface11_hair_test_run/steps/mask.png) — 白 = 生成/贴回区
- 本次 `mask_pixels=140299`;贴图前再内缩 `edge_erode_px=3`
### ② 生成全帧(swap_raw
- **做什么**`gen_backend=swaphair` + `swap_mode=ext_mask`,把遮罩交给 change_hair`:8801`),LoRA=`chang_zhixian``denoising_strength=0.6`
- **图**[`steps/swap_raw.jpg`](iface11_hair_test_run/steps/swap_raw.jpg)
- **含义**:生成结果已与原图同分辨率对齐;耗时约 5.6 s。
### ③ 严格按遮罩贴回(hard_paste
- **做什么**:遮罩内用生成图,遮罩外保持原图,无融合。
- **图**[`steps/hard_paste.jpg`](iface11_hair_test_run/steps/hard_paste.jpg)
- **含义**:用于对比接缝融合前后差异。
### ④ 融合权重 alpha + 最终结果
- **做法**`blend_method=multiband``mb_levels=5``color_match=true`(强度 1.0),`mb_feather_px=1`
- **alpha**[`steps/alpha.png`](iface11_hair_test_run/steps/alpha.png) — 白 = 更多采用生成图
- **最终输出**[`steps/final.jpg`](iface11_hair_test_run/steps/final.jpg)(副本:[`final.jpg`](iface11_hair_test_run/final.jpg)
- **输入对照**[`steps/input.jpg`](iface11_hair_test_run/steps/input.jpg)
### ⑤ 发际线带重绘(本次关闭)
`redraw=false`,故 `redraw_band_overlay` / `redraw_a` / `redraw_c` 均为空。
---
## 4. 最终输出
**主结果文件**[`docs/iface11_hair_test_run/final.jpg`](iface11_hair_test_run/final.jpg)
含义:同一人、同一发型观感下,按直线发际线类型(`chang_zhixian`)压低发际线后的合成图;遮罩外像素保持原图不动。
---
## 5. 复现命令
```bash
curl -sS -X POST "http://127.0.0.1:8187/api/v1/hairline/grow" \
-H "X-Internal-Token: dev-shared-secret-2026" \
-F "image_file=@image/hair_test.jpg" \
-F "hairline_id=chang_zhixian" \
-o docs/iface11_hair_test_run/response.json
```
(其余参数全部省略即可走默认值。)
---
## 6. 产物清单
```
docs/接口11_hair_test运行记录.md ← 本文档
docs/iface11_hair_test_run/
final.jpg ← 最终结果
response.json ← 完整 API 响应(含 base64
response_meta.json ← 去掉大图的元数据
steps/
input.jpg
baseline_overlay.jpg
upper_overlay.jpg
hair_seg_overlay.jpg
hairline_overlay.jpg
pushed_overlay.jpg
mask_overlay.jpg
mask.png
swap_raw.jpg
hard_paste.jpg
alpha.png
final.jpg
```
+22 -60
View File
@@ -20,7 +20,6 @@
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
| 4 用户特征 | POST | `/api/v1/face/features` |
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
| 7 C 端生发 v2 | POST | `/api/v1/hair/grow-v2` |
---
@@ -87,6 +86,7 @@
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
| 1008 | 图片格式不支持 | 非 JPG / PNG |
| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token``/api/*` 路径鉴权) |
---
@@ -109,6 +109,8 @@
| four_courts | object | 四庭数据,见下表 |
| seven_eyes | object | 七眼数据,见下表 |
| landmarks | object | 关键分界点坐标(头顶 / 发际线 / 眉心 / 鼻翼下缘 / 下巴尖),原图像素坐标 |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
`four_courts`(四庭,自上而下):
@@ -209,8 +211,12 @@
| annotated_image_url | string | 标注图层 PNG URL(透明底,仅标注线/文字,不含人物) |
| face_total_height_cm | number | 面部总高度(cm)= 上庭 + 中庭 + 下庭(**不含顶庭**) |
| four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio**无顶庭** |
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距,各含 cm ratio |
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + **eye2~eye6** 共 5 段宽度 |
| landmarks | object | 四个关键点像素坐标(发际线/眉心/鼻翼下缘/下巴尖) |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
> 接口6 是**三庭五眼**`four_courts`/`landmarks` 不含顶庭与头顶点(无 `top_court_cm`/`hair_top`);`seven_eyes` 只含 **eye2~eye6**(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段),**无 eye1/eye7**(耳外段需头发轮廓端线,仅接口1 有)。
### 响应示例
@@ -228,7 +234,8 @@
},
"seven_eyes": {
"eye_width_cm": 3.44, "face_width_cm": 24.08, "inter_eye_distance_cm": 3.44,
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 }
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 },
"eye2": 3.0, "eye3": 3.44, "eye4": 3.44, "eye5": 3.44, "eye6": 3.0
},
"landmarks": {
"hairline": { "x": 540, "y": 430 },
@@ -261,6 +268,7 @@
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wavemale1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007` |
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
### 输出(data
@@ -315,6 +323,7 @@
|------|------|------|------|
| marked_image_* | file / string | 是 | 已用马克笔标注发际线的图片,三选一 |
| use_mask | bool | 否 | 是否画发际线,默认 `true``false` 时跳过划线检测、直接送划线图,模型仅凭手绘黑线生发,供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
### 输出(data
@@ -399,7 +408,8 @@
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 5 / male 4)。缺失/非法返回 `1004` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wavemale1=ellipse, 2=inverse_arc, 3=m, 4=straight。缺失/越界/非法返回 `1007` |
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发」,会替换工作流节点 60 的文本 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,可大幅降低耗时 |
> ⚠️ 三档叠图分别用 `hairline_texture` / `hairline_texture_high` / `hairline_texture_low` 三套同名贴图;**生发黑模板固定取自 `hairline_texture_black/`middle 档)**,即生发目标固定压到 middle 档,每个发型仅 1 张生发图。
@@ -408,7 +418,9 @@
| 字段 | 类型 | 说明 |
|------|------|------|
| hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 |
| best_hairline_center_point | object | **首个选中发型**的 middle 档发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
| best_hairline_center_point | object \| null | **首个选中发型**的 **middle 档**发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
| high_hairline_center_point | object \| null | 同上,**high 档**发际线中点(发际线偏高) |
| low_hairline_center_point | object \| null | 同上,**low 档**发际线中点(发际线偏低) |
| face_measure | object \| null | **复用接口1**的四庭七眼测量**数值**(不含标注图)。独立流程,测量失败(无人脸/非正面/分割失败)时为 `null`,不影响发际线主结果。字段结构见下表 |
`hairline_images` 元素:
@@ -419,7 +431,7 @@
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败时为 `null` |
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败`generate_grow_image=false` 时为 `null` |
| order | int | 发型序号(= 传入的 hair_style 值) |
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。
@@ -436,6 +448,8 @@
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算) |
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
@@ -466,6 +480,8 @@
}
],
"best_hairline_center_point": { "x": 540, "y": 430 },
"high_hairline_center_point": { "x": 540, "y": 380 },
"low_hairline_center_point": { "x": 540, "y": 480 },
"face_measure": {
"face_total_height_cm": 26.76,
"four_courts": {
@@ -499,59 +515,6 @@
---
## 接口 7:C 端生发 v2 接口
**说明**:功能与[接口 2](#接口-2c-端生发接口)完全一致,仅 ComfyUI 工作流不同——使用 `add_hair2.json` 替代 `add_hair.json`
**请求**`POST /api/v1/hair/grow-v2`
### 输入
与接口 2 完全相同。图片参数见「通用约定 → 图片传参字段」。专属参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wavemale1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007` |
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线) |
### 输出(data
与接口 2 完全相同。`results`:发际线方案数组,**数量 = 所选发型数**。每个元素:
| 字段 | 类型 | 说明 |
|------|------|------|
| image_url | string | 方案**预览图** URL(发际线曲线叠加图) |
| grown_image_url | string | **生发后图片** URLComfyUI/Flux「植发 3 个月」效果图) |
| hairline_type | string | 发际线类型 key |
| order | int | 排序序号 |
> ⚠️ 与接口 2 的区别:本接口使用 `add_hair2.json` 工作流(Flux-2 Klein 9b),输入/遮罩节点同为 26,
> SaveImage 输出节点为 75。
### 响应示例
```json
{
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"results": [
{
"image_url": "https://hair.xiangsilian.com/static/sample.jpg",
"grown_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
"hairline_type": "ellipse",
"order": 1
}
]
}
}
```
---
## 汇总:输入输出一览
| 接口 | 输入 | 主要输出 |
@@ -562,7 +525,6 @@
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
| 7 C 端生发 v2 | 用户照片 + gender + hair_style | 同接口2,使用 add_hair2.json 工作流 |
---
+49 -31
View File
@@ -6,9 +6,9 @@
- 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右,
把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线双箭头。
人头最左/最右取自耳朵分割外缘,看不到耳朵则省略该侧(最少 6 点 5 段)。
- 四庭:图片左侧,「名」「数值」下两行换行(不带 cm,带竖向虚线双箭头。
- 四庭:图片左侧,「名」「数值(带 cm)」「百分比」三行换行,带竖向虚线双箭头。
- 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。
- 单位 cm 统一标在底部「单位cm」。
- 每段数值直接带 cm 后缀,下方另起一行标百分比(不再单独标底部「单位cm」
中文字体用打包的思源黑体绝对路径加载,缺字体直接抛错(不静默降级成方块)。
"""
import os
@@ -189,9 +189,8 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
取自耳朵分割掩膜的外缘(方案 B,BiSeNet 类 7/8);耳朵不可见(被头发/侧脸
遮挡 → 掩膜空)或无掩膜时省略该侧端线,只画对应脸颊线。
- 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。
- 四庭(顶/上/中/下庭)在左侧:名 + 数值两行换行(无 cm,竖向虚线双箭头。
- 七眼段宽数值上下交替(上 3 / 下 4,无 cm),横向虚线双箭头。
- 底部统一标「单位cm」。
- 四庭(顶/上/中/下庭)在左侧:名 + 数值(带 cm) + 百分比三行换行,竖向虚线双箭头。
- 七眼段宽上下交替(上 3 / 下 4):数值(带 cm) 上、百分比(占头宽比)下,横向虚线双箭头。
variant="v6"(接口6):去掉头顶横线与顶庭(只画发际线/眉心/鼻翼下缘/下巴尖 4 条
横线 + 上/中/下庭),竖线纵向范围改为发际线→下巴尖,且不画人头最左/最右端线
@@ -203,7 +202,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
s = min(w, h)
font_size = max(11, round(s * 0.026)) # 字体更小
font_size = max(9, round(s * 0.020)) # 字号上调一档
line_w = max(1, round(s * 0.0022))
dash_len = max(4, round(s * 0.008))
gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线)
@@ -213,7 +212,11 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
buf = np.zeros((h, w, 4), dtype=np.uint8)
if variant == "v6":
# 发际线弃用(hairline_discarded):保留头顶横线,去掉发际线横线,
# 也不标顶/上庭(缺发际线作边界,算不出)。横线 = 头顶/眉心/鼻翼下缘/下巴尖。
if getattr(measure_result, "hairline_discarded", False):
order = ["hair_top", "brow_center", "nose_bottom", "chin_tip"]
elif variant == "v6":
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
else:
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
@@ -240,7 +243,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
face_cx = (fx0 + fx1) / 2
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
# v6 竖线纵向范围 = 发际线→下巴尖(不超出);v1 = 头顶→下巴尖并两端超出一点
# 竖线纵向范围v6 = 发际线→下巴尖(不超出);v1(含发际线弃用)= 头顶→下巴尖并两端超出一点
v_top = fy0 if variant == "v6" else fy0 - over
v_bot = fy1 if variant == "v6" else fy1 + over
@@ -256,70 +259,85 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
draw = ImageDraw.Draw(canvas)
font = _load_font(font_size)
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字在线上方 ---
name_x = fx1 + pad
name_gap = max(2, round(pad * 1.6)) # 文字底部到线的间距(再上移)
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字纵向居中对齐到线 ---
name_x = fx1 + over + pad # 移到横线右端外侧一点(往右)
for i, name in enumerate(order):
text = _LINE_NAMES[name]
tw, th = _text_size(draw, text, font)
tw, _ = _text_size(draw, text, font)
x = min(name_x, w - 2 - tw) # 右侧越界时回收
draw.text((x, max(2, ys[i] - th - name_gap)), text, fill=LINE_COLOR, font=font)
# anchor="lm"x 为左、y 为竖直中点 → 文字中线正好压在横线上(与线对齐)
draw.text((x, ys[i]), text, fill=LINE_COLOR, font=font, anchor="lm")
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
if variant == "v6":
# court_start:庭段在 order 里的起始索引。发际线弃用时 order 首位是头顶(无下界发际线,
# 顶/上庭不标),中庭从眉心开始 → 跳过 order[0]。
if getattr(measure_result, "hairline_discarded", False):
court_cm = [measure_result.middle_cm, measure_result.lower_cm]
court_name = ["中庭", "下庭"]
n_court = 2
court_start = 1
elif variant == "v6":
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
court_name = ["上庭", "中庭", "下庭"]
n_court = 3
court_start = 0
else:
court_cm = [measure_result.top_cm, measure_result.upper_cm,
measure_result.middle_cm, measure_result.lower_cm]
court_name = ["顶庭", "上庭", "中庭", "下庭"]
n_court = 4
court_start = 0
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
court_total = sum(court_cm) or 1.0 # 各庭占比分母 = 四庭(v6 三庭)之和
for i in range(n_court):
y_a, y_b = ys[i], ys[i + 1]
y_a, y_b = ys[court_start + i], ys[court_start + i + 1]
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
inset = min(arrow_size, (y_b - y_a) * 0.12)
draw_dashed_line_with_arrows(
draw, arrow_x, y_a + inset, arrow_x, y_b - inset,
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
# 名 + 数值行,右对齐到箭头左侧
# 名 + 数值(带 cm) + 百分比三行,右对齐到箭头左侧
name = court_name[i]
val = f"{court_cm[i]:.2f}"
val = f"{court_cm[i]:.2f}cm"
pct = f"{court_cm[i] / court_total * 100:.1f}%"
nw, _ = _text_size(draw, name, font)
vw, _ = _text_size(draw, val, font)
pw, _ = _text_size(draw, pct, font)
label_right = arrow_x - pad
y_mid = (y_a + y_b) / 2
y_top = y_mid - line_h
y_top = y_mid - 1.5 * line_h
draw.text((max(2, label_right - nw), y_top), name, fill=LINE_COLOR, font=font)
draw.text((max(2, label_right - vw), y_top + line_h), val, fill=LINE_COLOR, font=font)
draw.text((max(2, label_right - pw), y_top + 2 * line_h), pct, fill=LINE_COLOR, font=font)
# --- 4. 七眼每段宽度:上下交替(上 3 / 下 4),横向虚线双箭头 + 数值(无 cm ---
# 文字与箭头间留更大间距,避免文字压住箭头
# --- 4. 七眼每段宽度:上下交替(上 3 / 下 4),横向虚线双箭头 + 数值(带 cm) + 百分比 ---
# 每段两行:数值(带 cm) 上、百分比 下;百分比分母 = 整个头宽(七段之和)
txt_off = arrow_size + pad * 2
y_arrow_top = max(txt_off + font_size + 2, fy0 - pad - arrow_size)
y_arrow_bot = min(h - txt_off - font_size - 2, fy1 + pad + arrow_size)
txt_block = 2 * line_h # 两行文字总高(数值 + 百分比)
y_arrow_top = max(txt_off + txt_block + 2, fy0 - pad - arrow_size)
y_arrow_bot = min(h - txt_off - txt_block - 2, fy1 + pad + arrow_size)
head_w = (xs[-1] - xs[0]) or 1.0 # 头宽(像素)= 百分比分母
for i in range(len(xs) - 1):
x_a, x_b = xs[i], xs[i + 1]
if x_b - x_a < 1:
continue
seg_cm = (x_b - x_a) / pc
seg_pct = (x_b - x_a) / head_w * 100
cx_seg = (x_a + x_b) / 2
text = f"{seg_cm:.2f}"
tw, th = _text_size(draw, text, font)
val = f"{seg_cm:.2f}cm"
pct = f"{seg_pct:.1f}%"
vw, _ = _text_size(draw, val, font)
pw, _ = _text_size(draw, pct, font)
inset = min(arrow_size, (x_b - x_a) * 0.12)
on_top = (i % 2 == 1) # 奇数段在上 → 上 3 / 下 4
y_arrow = y_arrow_top if on_top else y_arrow_bot
draw_dashed_line_with_arrows(
draw, x_a + inset, y_arrow, x_b - inset, y_arrow,
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
ty = (y_arrow - th - txt_off) if on_top else (y_arrow + txt_off)
draw.text((cx_seg - tw / 2, ty), text, fill=LINE_COLOR, font=font)
# --- 5. 底部统一单位 ---
unit = "单位cm"
uw, uh = _text_size(draw, unit, font)
draw.text(((w - uw) / 2, h - uh - max(2, pad)), unit, fill=LINE_COLOR, font=font)
# 数值行在上、百分比行在下;on_top 时整块置于箭头上方,否则下方
text_top = (y_arrow - txt_off - txt_block) if on_top else (y_arrow + txt_off)
draw.text((cx_seg - vw / 2, text_top), val, fill=LINE_COLOR, font=font)
draw.text((cx_seg - pw / 2, text_top + line_h), pct, fill=LINE_COLOR, font=font)
return canvas
+2
View File
@@ -18,6 +18,8 @@ RIGHT_EYE_INNER = 362 # 右眼内角
RIGHT_EYE_OUTER = 263 # 右眼外角
LEFT_CHEEK = 234 # 左脸颧弓(脸宽左端)
RIGHT_CHEEK = 454 # 右脸颧弓(脸宽右端)
LEFT_POSITION = 21 # 左脸前侧定位点(脸颊/耳前区域,与 251 镜像)
RIGHT_POSITION = 251 # 右脸前侧定位点(与 21 镜像)
# --- 鼻尖(solvePnP 用,可选) ---
NOSE_TIP = 1 # 鼻尖(也有用 4 的版本)
+456 -83
View File
@@ -35,6 +35,7 @@ from face_analysis.detector import detector
from face_analysis.calibration import estimate_scale_factor
from face_analysis.head_mask import (
NoFaceError,
BASELINE_IDX,
_baseline_points,
_upper_region_mask,
_bisenet_hair_mask,
@@ -46,8 +47,11 @@ from face_analysis.head_mask import (
_draw_baseline,
)
# 调试日志:写 /home/xsl/hair/log/hairline_grow.log,每个步骤详细记录
_LOG_DIR = "/home/xsl/hair/log"
# 调试日志:写 <仓库根>/log/hairline_grow.log,每个步骤详细记录(可用 HAIR_LOG_DIR 覆盖)
_LOG_DIR = os.getenv(
"HAIR_LOG_DIR",
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "log"),
)
os.makedirs(_LOG_DIR, exist_ok=True)
logger = logging.getLogger("hairline_grow")
_log_fh = logging.FileHandler(os.path.join(_LOG_DIR, "hairline_grow.log"), encoding="utf-8")
@@ -60,6 +64,10 @@ SWAP_URL = os.getenv("SWAP_HAIR_URL", "http://127.0.0.1:8801/api/swapHair/v1")
HAIRGROW_URL = os.getenv("HAIR_GROW_URL", "http://127.0.0.1:8801/api/hairGrow/v1")
SWAP_TIMEOUT = float(os.getenv("SWAP_HAIR_TIMEOUT", "300"))
# 多频段融合最细层羽化:羽化最细 FEATHER_LAYERS 层(每层核尺寸按尺度放大)。
# 只羽最细1层效果极弱(其拉普拉斯系数幅度小),羽化 3 层才能明显软化发丝边缘锯齿。
FEATHER_LAYERS = 3
DEFAULTS = {
"gen_backend": "swaphair", # swaphair(换发型LoRA) | hairgrow(区域生发inpaint)
"is_hr": False,
@@ -94,11 +102,26 @@ def _png_b64(bgr_or_gray):
def _gray_b64(gray_float):
"""0~1 的浮图 → 灰度 PNG data URI。"""
"""0~1 的浮图 → 灰度 PNG data URI。"""
g = np.clip(gray_float * 255.0, 0, 255).astype(np.uint8)
return _png_b64(g)
def _red_mask_b64(mask_bool, h, w):
"""布尔遮罩 → 纯红 alpha PNG data URI。
遮罩区域 RGBA=(255,0,0,255),其余区域 RGBA=(0,0,0,0)。
供 ComfyUI 重绘接口(/api/v1/redraw)按 alpha 通道识别重绘区。
注意:cv2.imencode 写 PNG 用的是 **BGRA** 顺序(B,G,R,A),所以要得到
浏览器显示的红色 R=255,需赋值 (B=0,G=0,R=255,A=255)。
"""
m = (mask_bool.astype(np.uint8)) * 255 if mask_bool is not None else np.zeros((h, w), np.uint8)
rgba = np.zeros((h, w, 4), np.uint8)
rgba[m > 0] = (0, 0, 255, 255) # BGRA: B=0,G=0,R=255 → PNG 读出为红色 + 不透明
ok, buf = cv2.imencode(".png", rgba)
return "data:image/png;base64," + base64.b64encode(buf.tobytes()).decode() if ok else ""
# ---------------------------------------------------------------------------
# 步骤1:接口9 头发遮罩(复用 head_mask 构件)
# ---------------------------------------------------------------------------
@@ -294,8 +317,48 @@ def _pushed_mask(hair_mask, upper, baseline_pts, push_px, rid="",
def _redraw_band_mask(inner_pts, outer_pts, h, w, rid="", upper=None,
lo_mult=0.5, hi_mult=1.5):
"""重绘带遮罩:由发际线(①-f 内轮廓 inner_pts)沿径向外推方向,取
`lo_mult × push` 与 `hi_mult × push` 两条外推线之间的带状区域作为重绘 mask。
inner_pts / outer_pts 是一一对应的有序点列(outer = inner 径向外推 1.0×push_px),
故每点的 1.0× 位移向量 = outer - inner;下界线 = inner + lo_mult×位移,
上界线 = inner + hi_mult×位移。闭合环 = 下界线(正向)+ 上界线(反向)首尾相接。
lo_mult / hi_mult:外推倍率(相对 hairline_push_cm)。默认 0.5 / 1.5,即带位于
0.5×push ~ 1.5×push 之间(以内轮廓为 0×、原外推线为 1.0×)。
upper:①-a baseline 以上区域布尔掩码。传入时把重绘带与它求交集,只保留 baseline
以上的部分(两侧鬓角落到 baseline 以下的段会被截掉)。
返回 band_bool。
"""
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
if len(inner_pts) < 2 or len(outer_pts) < 2:
return np.zeros((h, w), dtype=bool)
inner_f = np.asarray(inner_pts, dtype=np.float32)
outer_f = np.asarray(outer_pts, dtype=np.float32)
disp = outer_f - inner_f # 每点 1.0×push 的径向位移向量
lo_line = inner_f + float(lo_mult) * disp # 下界外推线(lo_mult×push
hi_line = inner_f + float(hi_mult) * disp # 上界外推线(hi_mult×push
# 闭合多边形:下界线正向 + 上界线反向,端点自然相连
ring = np.vstack([lo_line.astype(np.int32), hi_line[::-1].astype(np.int32)])
band_u8 = np.zeros((h, w), dtype=np.uint8)
cv2.fillPoly(band_u8, [ring], 255)
band = band_u8 > 0
raw_px = int(band.sum())
# ①-a baseline 截断:只保留 baseline 以上的重绘带
if upper is not None:
band = band & upper
lg(f"_redraw_band_mask: 内轮廓点={len(inner_pts)} lo_mult={lo_mult} hi_mult={hi_mult} "
f"band像素(截断前)={raw_px} band像素(截断后)={int(band.sum())}")
return band
def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm,
hairline_push_cm=0.0, hairline_edge="column", rid=""):
hairline_push_cm=0.0, hairline_edge="column", rid="", render_viz=True,
hair_mask=None):
"""算出布尔遮罩 + 可视化。
seg_model: bisenet | segformer。
@@ -303,6 +366,10 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
hairline_push_cm: 仅 pushed 模式——发际线往头发方向外推的厘米数(进入现有头发)。
hairline_edge: 仅 pushed 模式——发际线提取方式 column(逐列最低点) | contour(形态学轮廓)。
rid: 调用方的 request id,用于日志关联。
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
@@ -316,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) # 闭合区域:头发+额头,底=基线
@@ -334,7 +404,9 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
if mask_type == "pushed":
push_px = int(round(max(0.0, hairline_push_cm) * px_per_cm))
# 圆心 = 151 点(眉心)完整坐标,内侧判定与径向外推共用
center = baseline_pts[5] if len(baseline_pts) > 5 else None
# 按值查 151 在 BASELINE_IDX 中的位置,避免列表变动后索引错位(曾硬编码 [5])
_idx151 = BASELINE_IDX.index(151) if 151 in BASELINE_IDX else -1
center = baseline_pts[_idx151] if _idx151 >= 0 else None
# 下颌截断线:下巴关键点 152 的 y(内轮廓两侧向下画到这里为止)
try:
chin_y = int(round(landmarks.landmark[152].y * h))
@@ -361,48 +433,55 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
# 遮罩计算过程可视化:
# eroded/closed 走 top_fill→closed/eroded 流程;
# pushed 走 baseline→头发分割→发际线→外推 流程,与 top_fill/closed 无关,故置空。
# render_viz=False(接口2/12 路径)时跳过 overlay JPG 编码,只保留数据字段。
viz = {
"erode_px": r,
"hair_pixels": int(hair_mask.sum()),
"closed_pixels": int(closed.sum()),
"mask_pixels": int(mask_bool.sum()),
# 1. 发际线分割线(baseline):151 中心点标红,其余点标绿,黄线含左右延长线
"baseline_overlay_base64": _jpg_b64(_draw_baseline(image_bgr, baseline_pts, w)),
"baseline_overlay_base64": _jpg_b64(_draw_baseline(image_bgr, baseline_pts, w)) if render_viz else "",
# 2. 分割线以上区域(upper 半区):青色叠加
"upper_overlay_base64": _jpg_b64(_overlay(image_bgr, upper, (0, 255, 255))),
"upper_overlay_base64": _jpg_b64(_overlay(image_bgr, upper, (0, 255, 255))) if render_viz else "",
# 3. 头发分割原始结果(hair_mask):绿色叠加在原图上
"hair_seg_overlay_base64": _jpg_b64(_overlay(image_bgr, hair_mask, (0, 255, 0))),
"hair_seg_overlay_base64": _jpg_b64(_overlay(image_bgr, hair_mask, (0, 255, 0))) if render_viz else "",
# 4. top_fill / closed —— 仅 eroded/closed 流程用;pushed 流程无关,留空
"top_fill_overlay_base64": "" if mask_type == "pushed"
"top_fill_overlay_base64": "" if (mask_type == "pushed" or not render_viz)
else _jpg_b64(_overlay(image_bgr, top_fill, (255, 0, 0))),
"closed_overlay_base64": "" if mask_type == "pushed"
"closed_overlay_base64": "" if (mask_type == "pushed" or not render_viz)
else _jpg_b64(_overlay(image_bgr, closed, (255, 0, 255))),
# 5. pushed 模式专有(发际线提取/外推)—— 非 pushed 留空
"hairline_overlay_base64": "",
"pushed_overlay_base64": "",
# —— 最终遮罩 ——
"mask_overlay_base64": _jpg_b64(_overlay(image_bgr, mask_bool, (0, 0, 255))),
"mask_base64": _png_b64((mask_bool.astype(np.uint8)) * 255),
"mask_overlay_base64": _jpg_b64(_overlay(image_bgr, mask_bool, (0, 0, 255))) if render_viz else "",
"mask_base64": _png_b64((mask_bool.astype(np.uint8)) * 255) if render_viz else "",
}
# pushed 模式:补充内轮廓提取 + 外推线可视化
if pushed_info is not None:
inner_pts, outer_pts, push_px = pushed_info
# ①-f 提取内轮廓:绿=头发内轮廓线(额头弧+两侧到下颌),黄=baseline 折线
hl_img = _draw_baseline(image_bgr, baseline_pts, w) # 画 baseline(黄线+关键点)
hl_img = _draw_polyline(hl_img, inner_pts, (0, 255, 0), 3)
viz["hairline_overlay_base64"] = _jpg_b64(hl_img)
# ①-g 外推:圆心红点(151) + 内轮廓(绿)+ 外推线(青)+ 遮罩(红半透明)
ps_img = _draw_polyline(image_bgr.copy(), inner_pts, (0, 255, 0), 2)
ps_img = _draw_polyline(ps_img, outer_pts, (0, 255, 255), 3)
# 画圆心(151 点)红点,标示径向外推的中心
if baseline_pts is not None and len(baseline_pts) > 5:
cx151, cy151 = baseline_pts[5]
cv2.circle(ps_img, (cx151, cy151), 6, (0, 0, 255), -1, cv2.LINE_AA)
cv2.putText(ps_img, "151", (cx151 + 8, cy151 - 8),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1, cv2.LINE_AA)
ps_img = _overlay(ps_img, mask_bool, (0, 0, 255), 0.3)
viz["pushed_overlay_base64"] = _jpg_b64(ps_img)
if render_viz:
# ①-f 提取内轮廓:绿=头发内轮廓线(额头弧+两侧到下颌),黄=baseline 折线
hl_img = _draw_baseline(image_bgr, baseline_pts, w) # 画 baseline(黄线+关键点)
hl_img = _draw_polyline(hl_img, inner_pts, (0, 255, 0), 3)
viz["hairline_overlay_base64"] = _jpg_b64(hl_img)
# ①-g 外推:圆心红点(151) + 内轮廓(绿)+ 外推线(青)+ 遮罩(红半透明)
ps_img = _draw_polyline(image_bgr.copy(), inner_pts, (0, 255, 0), 2)
ps_img = _draw_polyline(ps_img, outer_pts, (0, 255, 255), 3)
# 画圆心(151 点)红点,标示径向外推的中心(_idx151 上方已按值查到)
if center is not None:
cx151, cy151 = center
cv2.circle(ps_img, (cx151, cy151), 6, (0, 0, 255), -1, cv2.LINE_AA)
cv2.putText(ps_img, "151", (cx151 + 8, cy151 - 8),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1, cv2.LINE_AA)
ps_img = _overlay(ps_img, mask_bool, (0, 0, 255), 0.3)
viz["pushed_overlay_base64"] = _jpg_b64(ps_img)
viz["push_px"] = push_px
# 重绘带用原始数据:内轮廓点 + 外推点(供 _redraw_band_mask 连端点成带)
viz["_inner_pts"] = inner_pts
viz["_outer_pts"] = outer_pts
# baseline 以上区域,供重绘带按 ①-a baseline 截断(只留上面)
viz["_upper_mask"] = upper
# 记录 viz 各字段是否非空(长度),便于排查前端取不到图的问题
viz_summary = {k: (len(v) if isinstance(v, str) and v else 0)
for k, v in viz.items() if k.endswith("_base64")}
@@ -410,15 +489,32 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
return mask_bool, viz
def _segment_hair(image_bgr, seg_model, landmarks, w, h):
"""对任意图(如 hard_paste 重绘结果)重跑头发分割,返回 bool 掩码。
与 compute_mask 内部用的同一个 seg_model 逻辑(bisenet 需 landmarks
segformer 不需要),保证第1步(原图头发)与第2步(重绘后头发)分割口径一致。
"""
if seg_model == "bisenet":
return _bisenet_hair_mask(image_bgr, landmarks, w, h)
elif seg_model == "segformer":
return _segformer_hair_mask(image_bgr)
else:
raise ValueError(f"未知 seg_model: {seg_model}")
# ---------------------------------------------------------------------------
# 步骤2:调 change_hair 换发型
# ---------------------------------------------------------------------------
def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength):
def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength,
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0):
"""调 change_hair /api/swapHair/v1,返回与输入同分辨率同对齐的换发型结果(BGR)。
ext_mask_bool 非 None 时作为 ext_mask 传入(swap_mode=ext_mask)。
denoising_strengthwebui img2img 重绘强度(越大生发越激进),透传给换发型。
inpainting_fill / mask_blur / mask_dilate_scale:服务端重绘参数(透传给 change_hair
默认值=服务端原始硬编码值,未传时行为不变)。详见 change_hair 文档。
"""
import requests
@@ -430,6 +526,9 @@ def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength)
"user_img_path": "data:image/jpeg;base64," + base64.b64encode(ibuf.tobytes()).decode(),
"output_format": "base64",
"denoising_strength": float(denoising_strength),
"inpainting_fill": int(inpainting_fill),
"mask_blur": int(mask_blur),
"mask_dilate_scale": float(mask_dilate_scale),
}
if ext_mask_bool is not None:
mbuf = cv2.imencode(".png", (ext_mask_bool.astype(np.uint8)) * 255)[1]
@@ -500,25 +599,60 @@ def _call_hairgrow(image_bgr, mask_bool, strength):
return result
_REPAINT_WORKFLOW = os.path.join(os.path.dirname(os.path.dirname(__file__)), "hair_repaint.json")
def _call_comfyui(image_bgr, mask_bool, prompt=None):
"""调远端 ComfyUI 的 Flux-2 inpaint 工作流(hair_repaint.json),返回与输入同分辨率的 BGR。
与 swapHair 的区别:ComfyUI 把「原图 VAE 编码作 reference latent + ColorMatch」双重保色,
天生不易染色;提示词自由可调(中文)。mask 经 RGBA alpha 通道传入(透明=重绘区)。
ComfyUI 不在线时抛 SwapError(由调用方捕获降级)。prompt=None 用工作流内置默认提示词。
"""
import io
from hairline.mask import compose_comfy_rgba
from hairline.comfyui import COMFYUI_URL, run as comfyui_run, ping
if not ping():
raise SwapError(f"ComfyUI 不可达({COMFYUI_URL}),redraw Flux-2 路跳过")
mask_u8 = (mask_bool.astype(np.uint8)) * 255
rgba_img = compose_comfy_rgba(image_bgr, mask_u8) # alpha=255-mask:透明=重绘区
buf = io.BytesIO()
rgba_img.save(buf, format="PNG")
png_bytes = comfyui_run(buf.getvalue(), prompt=prompt, workflow_path=_REPAINT_WORKFLOW)
result = cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR)
if result is None:
raise SwapError("ComfyUI 结果解码失败")
if result.shape[:2] != image_bgr.shape[:2]:
result = cv2.resize(result, (image_bgr.shape[1], image_bgr.shape[0]),
interpolation=cv2.INTER_LANCZOS4)
return result
# ---------------------------------------------------------------------------
# 步骤3+4:按遮罩贴回 + 接缝融合
# ---------------------------------------------------------------------------
def _color_match_to_orig(swap_result, orig, mask_bool):
def _color_match_to_orig(swap_result, orig, mask_bool, strength=1.0):
"""在 mask_bool 区域内做 Reinhard 颜色迁移:逐通道把 swap_result 的均值/方差对齐 orig。
strength 控制迁移强度:1.0=完全对齐到 orig(原行为),<1.0 只迁移部分,
防止 Reinhard 在某些图上过度改色(如把生成发色整体拉向皮肤色)。
遮罩外保持 swap_result 原样(不会越界污染)。返回 uint8 BGR。
"""
m = mask_bool.astype(bool)
out = swap_result.astype(np.float32).copy()
src_f = swap_result.astype(np.float32)
out = src_f.copy()
if m.sum() < 30:
return swap_result.copy()
strength = float(min(max(strength, 0.0), 1.0))
for c in range(3):
src_pix = swap_result[..., c][m].astype(np.float32)
dst_pix = orig[..., c][m].astype(np.float32)
s_mean, s_std = src_pix.mean(), src_pix.std() + 1e-6
d_mean, d_std = dst_pix.mean(), dst_pix.std() + 1e-6
out[..., c] = (out[..., c] - s_mean) * (d_std / s_std) + d_mean
aligned = (out[..., c] - s_mean) * (d_std / s_std) + d_mean
out[..., c] = src_f[..., c] * (1.0 - strength) + aligned * strength
return np.clip(out, 0, 255).astype(np.uint8)
@@ -553,10 +687,17 @@ def _multiband_alpha(mask_bool, edge_erode_px):
return m
def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px):
def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px,
feather_px=1, transition_band_px=-1):
"""多频段(拉普拉斯金字塔)融合:低频用宽窗抹色差,高频用窄窗保发丝。
levels:金字塔层数(2~6),越大则低频色差在越宽范围被抹平。
feather_px:最细若干层掩码轻羽化像素(0=不羽化,保持硬二值)。羽化最细 FEATHER_LAYERS
层(核尺寸按层尺度放大),消除发丝边缘 1px 硬切锯齿;粗层仍保持二值(否则粗层会
把整图混色)。注意:这是消除锯齿的微调,幅度有限(边界 Δ 约 1~3/255),
不要指望它做大范围过渡——那是 mb_levels/transition_band_px 的事。
transition_band_pxkeep-region 外缘边距。-1=自动按层数 2**n(旧行为);
>=0 则用绝对像素,使过渡带宽度与金字塔层数解耦。
返回 uint8 BGR。
"""
m = _multiband_alpha(mask_bool, edge_erode_px)
@@ -601,6 +742,21 @@ def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px):
lb = lap_pyr(swap_result, n)
ma = mask_pyr(m, n)
# 最细层(reversed 后末元素 = 全分辨率原始二值掩码)及其下若干层轻羽化,
# 消除发丝边缘 1px 硬切锯齿。注意:多频段融合中各层都贡献边界过渡,但最细层的
# 拉普拉斯系数幅度最小,只羽化它效果很弱(实测边界 Δ 仅 ~0.25/255)。因此对最细
# FEATHER_LAYERS 层都做按尺度放大的羽化(越细的层核越大),才能明显软化边缘。
# 粗层(低频)仍保持二值,否则会把整图混色,违反多频段融合的二值掩码前提。
fp = int(max(0, feather_px))
if fp > 0:
for li in range(1, FEATHER_LAYERS + 1):
idx = -li
if abs(idx) > len(ma):
break
scale = 2 ** (li - 1)
ksz = fp * 2 * scale + 1
ma[idx] = cv2.GaussianBlur(ma[idx], (ksz, ksz), sigmaX=fp * scale / 2.0)
merged = []
for a, b, mk in zip(la, lb, ma):
m3 = mk[:, :, None]
@@ -618,33 +774,68 @@ def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px):
# 这条带正是 mb_levels 要控制的东西。若像旧实现那样用原始硬二值遮罩钳回,
# 过渡带会被整条抹掉(实测 levels 2↔6 边界差恒为 0),mb_levels 形同虚设。
# 故按层数膨胀出一个外缘 keep 区:keep 内允许过渡,keep 外才强制还原原图。
margin = 2 ** n # n=2→4px … n=6→64px,与粗层掩码的自然扩散宽度匹配
if transition_band_px is not None and transition_band_px >= 0:
margin = int(transition_band_px) # 与金字塔层数解耦,用绝对像素
else:
margin = 2 ** n # n=2→4px … n=6→64px,与粗层掩码的自然扩散宽度匹配
margin = max(0, margin)
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * margin + 1, 2 * margin + 1))
keep = cv2.dilate(mask_bool.astype(np.uint8), k).astype(bool)
out[~keep] = orig[~keep]
return out
def _seamless_clone(orig, swap_result, mask_bool, edge_erode_px):
"""泊松无缝克隆(cv2.seamlessClone NORMAL_CLONE):梯度域调和整体色调。
返回调色后的整帧 uint8 BGR;掩码过小(<10px)时返回原图。
供 seamless 分支与 two_stage 两段式融合的第一段复用。
"""
m = mask_bool.astype(np.uint8)
if edge_erode_px > 0:
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge_erode_px + 1,) * 2)
m = cv2.erode(m, k)
if m.sum() < 10:
return orig.copy()
ys, xs = np.where(m > 0)
center = (int((xs.min() + xs.max()) / 2), int((ys.min() + ys.max()) / 2))
return cv2.seamlessClone(swap_result, orig, m * 255, center, cv2.NORMAL_CLONE)
def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erode_px,
color_match=False, mb_levels=5):
"""把 swap_result 按遮罩贴回 orig,返回 (final_bgr, alpha_float or None)。"""
color_match=False, mb_levels=5, color_match_strength=1.0,
mb_feather_px=1, transition_band_px=-1):
"""把 swap_result 按遮罩贴回 orig,返回 (final_bgr, alpha_float or None)。
blend_method:
- multiband : 多频段金字塔融合(默认)
- seamless : 泊松无缝克隆(梯度域调色,自带色彩调和,故跳过 color_match
- two_stage : 先 seamless 统一整体色调,再 multiband 贴发丝细节(大色差场景)
- feather/alpha_gradient : 单层 alpha 过渡
"""
# seamless / two_stage 自带梯度域色彩调和,不叠 Reinhard 颜色迁移
if blend_method == "seamless":
m = mask_bool.astype(np.uint8)
if edge_erode_px > 0:
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge_erode_px + 1,) * 2)
m = cv2.erode(m, k)
if m.sum() < 10:
return orig.copy(), None
ys, xs = np.where(m > 0)
center = (int((xs.min() + xs.max()) / 2), int((ys.min() + ys.max()) / 2))
final = cv2.seamlessClone(swap_result, orig, m * 255, center, cv2.NORMAL_CLONE)
final = _seamless_clone(orig, swap_result, mask_bool, edge_erode_px)
return final, None
# 颜色校正前置(seamless 自带色彩调和,已在上面提前返回;其余分支在此生效)
src = _color_match_to_orig(swap_result, orig, mask_bool) if color_match else swap_result
if blend_method == "two_stage":
# 第一段:seamless 把整体色调拉平(生成图色调对齐到原图)
harmonized = _seamless_clone(orig, swap_result, mask_bool, edge_erode_px)
# 第二段:对调色后的结果再做 multiband 贴发丝细节(不加 color_match,避免重复改色)
final = _multiband_blend(orig, harmonized, mask_bool, mb_levels, edge_erode_px,
feather_px=mb_feather_px,
transition_band_px=transition_band_px)
alpha = (_multiband_alpha(mask_bool, edge_erode_px).astype(np.float32)) / 255.0
return final, alpha
# multiband / feather / alpha_gradient:先做 Reinhard 颜色迁移消除整体色差
src = (_color_match_to_orig(swap_result, orig, mask_bool, color_match_strength)
if color_match else swap_result)
if blend_method == "multiband":
final = _multiband_blend(orig, src, mask_bool, mb_levels, edge_erode_px)
final = _multiband_blend(orig, src, mask_bool, mb_levels, edge_erode_px,
feather_px=mb_feather_px,
transition_band_px=transition_band_px)
# 可视化用:用多频段的二值掩码做一层 alpha 标记(展示实际合成区)
alpha = (_multiband_alpha(mask_bool, edge_erode_px).astype(np.float32)) / 255.0
return final, alpha
@@ -659,26 +850,28 @@ def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erod
# 主入口
# ---------------------------------------------------------------------------
def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segformer",
erode_cm=0.6, swap_mode="ext_mask",
edge_erode_px=3,
denoising_strength=0.6, gen_backend="swaphair",
hairgrow_strength=0.75, mb_levels=5,
hairline_push_cm=1.0, hairline_edge="column", rid=None):
"""接口11 完整管线。返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError
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,
hair_mask=None):
"""接口11 共享核心:遮罩(pushed)→生成→硬贴回→接缝融合,产出 ④ final
遮罩算法固定为 pushed(发际线外推),融合算法固定为 multiband(多频段金字塔),
不再支持其他选项。rid: 调用方的 request id,用于日志关联。为 None 时自动生成。
不做任何重绘。返回中间产物 dict(供接口11 构造响应、接口12 取 final+重绘带用):
final / swap_result / hard_paste / alpha / mask_bool / mask_viz /
px_per_cm / t_mask / t_swap / t_blend / h / w
未检出人脸抛 NoFaceError。
"""
mask_type = "pushed" # 固定:只支持 pushed 遮罩算法
blend_method = "multiband" # 固定:只支持 multiband 融合
if rid is None:
rid = uuid4().hex[:8]
logger.info("[%s] ===== generate_hairline_grow 开始 =====", rid)
logger.info("[%s] 参数(固定 mask=pushed blend=multiband): erode_cm=%s hairline_push_cm=%s "
"hairline_edge=%r mb_levels=%s seg=%s gen_backend=%s swap_mode=%s",
logger.info("[%s] _grow_core 参数(固定 mask=pushed): erode_cm=%s hairline_push_cm=%s "
"hairline_edge=%r mb_levels=%s seg=%s gen_backend=%s swap_mode=%s blend=%s "
"color_match=%s cm_strength=%s mb_feather_px=%s transition_band_px=%s "
"inpainting_fill=%s mask_blur=%s mask_dilate_scale=%s",
rid, erode_cm, hairline_push_cm, hairline_edge, mb_levels,
seg_model, gen_backend, swap_mode)
seg_model, gen_backend, swap_mode, blend_method, color_match,
color_match_strength, mb_feather_px, transition_band_px,
inpainting_fill, mask_blur, mask_dilate_scale)
h, w = image_bgr.shape[:2]
landmarks = detector.detect(image_bgr)
if landmarks is None:
@@ -691,7 +884,8 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
t0 = time.time()
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)
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, rid=rid,
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()))
@@ -701,27 +895,80 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
swap_result = _call_hairgrow(image_bgr, mask_bool, hairgrow_strength)
else:
ext_mask = mask_bool if swap_mode == "ext_mask" else None
swap_result = _call_swap(image_bgr, hairline_id, is_hr, ext_mask, denoising_strength)
swap_result = _call_swap(image_bgr, hairline_id, is_hr, ext_mask, denoising_strength,
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
mask_dilate_scale=mask_dilate_scale)
t_swap = time.time() - t0
# 步骤3:严格按遮罩硬贴回(无融合,用于对比)
hard_paste = image_bgr.copy()
hard_paste[mask_bool] = swap_result[mask_bool]
# 步骤4:接缝融合(固定 multiband
# 步骤4:接缝融合(默认 multiband→ ④ final
t0 = time.time()
final, alpha = _composite(
image_bgr, swap_result, mask_bool, blend_method, 0, edge_erode_px,
color_match=False, mb_levels=mb_levels)
color_match=color_match, mb_levels=mb_levels,
color_match_strength=color_match_strength,
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px)
t_blend = time.time() - t0
return {
"final": final, "swap_result": swap_result, "hard_paste": hard_paste,
"alpha": alpha, "mask_bool": mask_bool, "mask_viz": mask_viz,
"px_per_cm": px_per_cm, "t_mask": t_mask, "t_swap": t_swap, "t_blend": t_blend,
"h": h, "w": w,
}
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segformer",
erode_cm=0.6, swap_mode="ext_mask",
edge_erode_px=3,
denoising_strength=0.6, gen_backend="swaphair",
hairgrow_strength=0.75, mb_levels=5,
hairline_push_cm=1.0, hairline_edge="column",
blend_method="multiband", color_match=True,
color_match_strength=1.0, mb_feather_px=1,
transition_band_px=-1,
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
rid=None):
"""接口11 完整管线(**不含重绘**,重绘见接口12 generate_hairline_redraw)。
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
遮罩算法固定为 pushed(发际线外推)。
融合算法 blend_method 默认 multiband(多频段金字塔),可选 seamless(泊松)/
two_stage(泊松→多频段两段式)/feather(羽化)/alpha_gradient(距离变换)。
color_match 默认开启 Reinhard 颜色迁移消除整体色差(对 multiband/feather 有效)。
inpainting_fill/mask_blur/mask_dilate_scale:透传 change_hair 服务端换发型重绘参数。
rid: 调用方的 request id,用于日志关联。为 None 时自动生成。
"""
if rid is None:
rid = uuid4().hex[:8]
logger.info("[%s] ===== generate_hairline_grow 开始 =====", rid)
core = _grow_core(
image_bgr, hairline_id, is_hr=is_hr, seg_model=seg_model, erode_cm=erode_cm,
swap_mode=swap_mode, edge_erode_px=edge_erode_px, denoising_strength=denoising_strength,
gen_backend=gen_backend, hairgrow_strength=hairgrow_strength, mb_levels=mb_levels,
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, blend_method=blend_method,
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)
mask_viz = core["mask_viz"]
alpha = core["alpha"]
w, h = core["w"], core["h"]
data = {
"hairline_id": hairline_id,
"gen_backend": gen_backend,
"hairgrow_strength": round(float(hairgrow_strength), 3),
"is_hr": is_hr,
"seg_model": seg_model,
"mask_type": mask_type,
"mask_type": "pushed",
"erode_cm": round(float(erode_cm), 2),
"swap_mode": swap_mode,
"blend_method": blend_method,
@@ -730,16 +977,23 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
"hairline_push_cm": round(float(hairline_push_cm), 2),
"hairline_edge": hairline_edge,
"denoising_strength": round(float(denoising_strength), 3),
"px_per_cm": round(float(px_per_cm), 4),
"color_match": bool(color_match),
"color_match_strength": round(float(color_match_strength), 3),
"mb_feather_px": int(mb_feather_px),
"transition_band_px": int(transition_band_px),
"inpainting_fill": int(inpainting_fill),
"mask_blur": int(mask_blur),
"mask_dilate_scale": round(float(mask_dilate_scale), 3),
"px_per_cm": round(float(core["px_per_cm"]), 4),
"erode_px": mask_viz["erode_px"],
"hair_pixels": mask_viz["hair_pixels"],
"closed_pixels": mask_viz["closed_pixels"],
"mask_pixels": mask_viz["mask_pixels"],
"image_size": {"width": w, "height": h},
"timings_ms": {
"mask": int(t_mask * 1000),
"swap": int(t_swap * 1000),
"blend": int(t_blend * 1000),
"mask": int(core["t_mask"] * 1000),
"swap": int(core["t_swap"] * 1000),
"blend": int(core["t_blend"] * 1000),
},
"steps": {
"input_base64": _jpg_b64(image_bgr),
@@ -755,16 +1009,135 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
# 最终遮罩
"mask_overlay_base64": mask_viz["mask_overlay_base64"],
"mask_base64": mask_viz["mask_base64"],
"swap_raw_base64": _jpg_b64(swap_result),
"hard_paste_base64": _jpg_b64(hard_paste),
"swap_raw_base64": _jpg_b64(core["swap_result"]),
"hard_paste_base64": _jpg_b64(core["hard_paste"]),
"alpha_base64": _gray_b64(alpha) if alpha is not None else mask_viz["mask_base64"],
"final_base64": _jpg_b64(final),
"final_base64": _jpg_b64(core["final"]),
},
"_rid": rid, # 调试用:返回本次请求的日志关联 id
"_rid": rid,
}
# 记录 steps 各图字段是否非空,供排查前端取图问题
steps_summary = {k: (len(v) if isinstance(v, str) and v else 0)
for k, v in data["steps"].items() if k.endswith("_base64")}
logger.info("[%s] 返回 steps 字段长度: %s", rid, steps_summary)
logger.info("[%s] ===== generate_hairline_grow 完成 =====", rid)
return data
def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="segformer",
erode_cm=0.6, swap_mode="ext_mask",
edge_erode_px=3,
denoising_strength=0.6, gen_backend="swaphair",
hairgrow_strength=0.75, mb_levels=5,
hairline_push_cm=1.0, hairline_edge="column",
blend_method="multiband", color_match=True,
color_match_strength=1.0, mb_feather_px=1,
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,
hair_mask=None):
"""接口12 发际线带重绘。内部先跑接口11 核心拿到 ④ final,再取 ⑤-① 发际线重绘带
(外推↔内推之间、经 baseline 截断只留上部)作遮罩。
**本接口不再做 Flux-2 重绘**:只产出 `final`(接缝融合基底)+ 纯红遮罩
`redraw_band_mask`RGBA,遮罩区=(255,0,0,255)、其余全透明),重绘交给后端
ComfyUI 重绘接口(/api/v1/redraw)完成。旧的 `redraw_full` / `redraw_band`
字段保留为空,仅作结构兼容。
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
comfyui_prompt:保留入参,但本接口不再使用(重绘提示词由外部服务自行决定)。
beauty_alpha:保留入参,但本接口不再使用(美颜由外部服务控制)。
band_lo_mult / band_hi_mult:重绘带外推倍率(相对 hairline_push_cm),带位于
lo×push ~ hi×push 之间(内轮廓=0×、原外推线=1.0×),默认 0.5 / 1.5。
其余参数含义与接口11 相同(用于内部生成 final 与重绘带)。
"""
if rid is None:
rid = uuid4().hex[:8]
logger.info("[%s] ===== generate_hairline_redraw 开始 =====", rid)
core = _grow_core(
image_bgr, hairline_id, is_hr=is_hr, seg_model=seg_model, erode_cm=erode_cm,
swap_mode=swap_mode, edge_erode_px=edge_erode_px, denoising_strength=denoising_strength,
gen_backend=gen_backend, hairgrow_strength=hairgrow_strength, mb_levels=mb_levels,
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, blend_method=blend_method,
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,
hair_mask=hair_mask)
final = core["final"]
mask_viz = core["mask_viz"]
w, h = core["w"], core["h"]
px_per_cm = core["px_per_cm"]
# ① 算重绘带(⑤-①):发际线(内轮廓)↔外推发际线成带,经 baseline 截断只留上部
t0 = time.time()
inner_pts = mask_viz.get("_inner_pts")
outer_pts = mask_viz.get("_outer_pts")
upper_mask = mask_viz.get("_upper_mask")
push_px = int(round(max(0.0, hairline_push_cm) * px_per_cm))
redraw_band_overlay_b64 = ""
redraw_band_mask_b64 = "" # 纯红 alpha PNG(遮罩区=(255,0,0,255),其余全透明)
redraw_info = {"enabled": False}
band_mask = None
try:
band_mask = _redraw_band_mask(inner_pts, outer_pts, h, w, rid=rid, upper=upper_mask,
lo_mult=band_lo_mult, hi_mult=band_hi_mult)
if band_mask.sum() < 30:
raise RuntimeError("重绘带像素过少,可能内轮廓/外推线缺失")
logger.info("[%s] 重绘带 push_px=%d lo_mult=%s hi_mult=%s band_pixels=%d",
rid, push_px, band_lo_mult, band_hi_mult, int(band_mask.sum()))
redraw_band_overlay_b64 = _jpg_b64(_overlay(final, band_mask, (255, 0, 255)))
# 纯红遮罩 PNG(供外部重绘服务按 alpha 识别重绘区)
redraw_band_mask_b64 = _red_mask_b64(band_mask, h, w)
redraw_info = {"enabled": True, "band_pixels": int(band_mask.sum()), "push_px": push_px,
"band_lo_mult": float(band_lo_mult), "band_hi_mult": float(band_hi_mult)}
except Exception as ex: # noqa: BLE001
logger.exception("[%s] 重绘带计算失败,整个重绘跳过", rid)
redraw_info = {"enabled": False, "error": f"band: {ex}"}
# ② Flux-2 重绘已下线:本接口现在只产出 final(接缝融合基底)+ 纯红重绘带遮罩,
# 重绘交给后端 ComfyUI 重绘接口(/api/v1/redraw)完成。
# 下面保留 redraw_full_b64 / redraw_band_b64 为空,保持返回结构兼容(旧字段)。
redraw_full_b64 = ""
redraw_band_b64 = ""
t_redraw = time.time() - t0
data = {
"hairline_id": hairline_id,
"blend_method": blend_method,
"hairline_push_cm": round(float(hairline_push_cm), 2),
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
"beauty_alpha": beauty_alpha,
"px_per_cm": round(float(px_per_cm), 4),
"mask_pixels": mask_viz["mask_pixels"],
"image_size": {"width": w, "height": h},
"timings_ms": {
"mask": int(core["t_mask"] * 1000),
"swap": int(core["t_swap"] * 1000),
"blend": int(core["t_blend"] * 1000),
"redraw": int(t_redraw * 1000),
},
"steps": {
"input_base64": _jpg_b64(image_bgr),
# 接口11 的 ④ final —— 作为本接口的重绘输入基底
"final_base64": _jpg_b64(final),
# ⑤-① 发际线重绘带(紫,已按 baseline 截断只留上部)
"redraw_band_overlay_base64": redraw_band_overlay_b64,
# ⑤-② 发际线重绘带遮罩(纯红 alpha PNG,遮罩区=(255,0,0,255)
"redraw_band_mask_base64": redraw_band_mask_b64,
# A:ComfyUI 整帧重绘+美颜(已下线,保留空字段兼容旧前端)
"redraw_full_base64": redraw_full_b64,
# B:加发只在发际线带、美颜保留全脸(已下线,保留空字段兼容旧前端)
"redraw_band_base64": redraw_band_b64,
# 兼容旧字段:指向 A(整帧版)
"redraw_c_base64": redraw_full_b64,
},
"redraw": redraw_info,
"_rid": rid,
}
steps_summary = {k: (len(v) if isinstance(v, str) and v else 0)
for k, v in data["steps"].items() if k.endswith("_base64")}
logger.info("[%s] 返回 steps 字段长度: %s", rid, steps_summary)
logger.info("[%s] ===== generate_hairline_redraw 完成 =====", rid)
return data
+5 -3
View File
@@ -22,9 +22,11 @@ import numpy as np
from face_analysis.detector import detector
from face_analysis.calibration import estimate_scale_factor, normalized_to_pixel
# 底部分割线关键点(图像上从左到右:左端 162 → 中心 151 → 右端 389
# 162/389 为左右最外侧端点(向图片左右边缘水平延长);中间含 71/301 等点构成弧线
BASELINE_IDX = [162, 71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301, 389]
# 底部分割线关键点(图像上从左到右,眉骨弧线 → 中心 151 → 右侧对称
# 左端 104 → 中心 151 → 右端 333;首末点向图片左右边缘水平延长
# BASELINE_IDX = [104, 69, 108, 151, 337, 299, 333]
# BASELINE_IDX = [34, 139, 71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301, 368, 264]
BASELINE_IDX = [71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301]
CENTER_IDX = 151 # 内缩方向的目标点(额头中心)
ERODE_CM = 1.2 # 外缘内缩距离(厘米,默认;可由入参覆盖)
SEGFORMER_HAIR = 13 # jonathandinu/face-parsing 中 hair 类索引
+113 -47
View File
@@ -12,9 +12,9 @@ from face_analysis.calibration import (
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
)
from face_analysis.face_mesh_landmarks import (
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
GLABELLA_9, NOSE_BOTTOM, CHIN_TIP,
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
LEFT_CHEEK, RIGHT_CHEEK,
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
)
from face_analysis.hair_segmenter import locate_hairline_by_segmentation
@@ -24,10 +24,8 @@ _TOP_RATIO = 0.22 / 0.28 # 顶庭 ÷ 中庭(≈ 0.786)
def _brow_center(lm, w, h):
"""眉心 = 索引 9 / 151 中点"""
g9 = normalized_to_pixel(lm[GLABELLA_9], w, h)
g151 = normalized_to_pixel(lm[GLABELLA_151], w, h)
return (g9[0] + g151[0]) / 2, (g9[1] + g151[1]) / 2
"""眉心 = 索引 9(眉间上点)"""
return normalized_to_pixel(lm[GLABELLA_9], w, h)
def estimate_vertical_landmarks(landmarks, image_width, image_height):
@@ -144,22 +142,47 @@ def measure_seven_eyes(landmarks, image_width, image_height):
}
def pt_or_none(vertical, name):
"""vertical dict 的点 → {"x","y"},值为 None 时返回 None。"""
v = vertical.get(name)
if v is None:
return None
return {"x": int(round(v[0])), "y": int(round(v[1]))}
class MeasureResult:
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose):
# 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。
# hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线
# 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。
HAIRLINE_DISCARD_TOP_CM = 0.7
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose,
landmarks=None, image_width=None, image_height=None):
self.vertical = vertical
self.eyes = eyes
self.px_per_cm = px_per_cm
self.hairline_source = hairline_source
self.head_pose = head_pose # (yaw, pitch, roll) 或 None
# 原始 mediapipe 点集 + 图像尺寸,供 to_response 输出 21/251 号定位点
self.landmarks = landmarks
self.w = image_width
self.h = image_height
# 各庭厘米
self.top_cm = vertical["top_court_px"] / px_per_cm
self.upper_cm = vertical["upper_court_px"] / px_per_cm
self.middle_cm = vertical["middle_court_px"] / px_per_cm
self.lower_cm = vertical["lower_court_px"] / px_per_cm
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
# 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。
# 弃用时 hairline_source 改为 "discarded"face_total 只算中庭+下庭。
self.hairline_discarded = self.top_cm < self.HAIRLINE_DISCARD_TOP_CM
if self.hairline_discarded:
self.hairline_source = "discarded"
self.face_total_cm = self.middle_cm + self.lower_cm
else:
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
# 七眼厘米
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
@@ -167,46 +190,88 @@ class MeasureResult:
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
def to_response(self):
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
fw_px = self.eyes["face_width_px"]
def pt(name):
x, y = self.vertical[name]
return {"x": int(round(x)), "y": int(round(y))}
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": round(self.top_cm, 2),
"upper_court_cm": round(self.upper_cm, 2),
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
# 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭;
# landmarks.hair_top/hairline 置 null。否则按四庭正常输出。
if self.hairline_discarded:
base_px = (self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": None,
"upper_court_cm": None,
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": None,
"upper_court": None,
"middle_court": round(self.vertical["middle_court_px"] / base_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / base_px, 3),
},
},
},
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / fw_px, 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / fw_px, 3),
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
},
},
"landmarks": {
"hair_top": pt("hair_top"),
"hairline": pt("hairline"),
"brow_center": pt("brow_center"),
"nose_bottom": pt("nose_bottom"),
"chin_tip": pt("chin_tip"),
},
"hairline_source": self.hairline_source,
}
"landmarks": {
"hair_top": None,
"hairline": None,
"brow_center": pt_or_none(self.vertical, "brow_center"),
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
},
"hairline_source": self.hairline_source,
}
else:
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": round(self.top_cm, 2),
"upper_court_cm": round(self.upper_cm, 2),
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
},
},
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
},
"landmarks": {
"hair_top": pt_or_none(self.vertical, "hair_top"),
"hairline": pt_or_none(self.vertical, "hairline"),
"brow_center": pt_or_none(self.vertical, "brow_center"),
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
},
"hairline_source": self.hairline_source,
}
# left/right_positionmediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。
# landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。
if self.landmarks is not None and self.w and self.h:
lm = _lm_list(self.landmarks)
def _pt_lm(idx):
px, py = normalized_to_pixel(lm[idx], self.w, self.h)
return {"x": int(round(px)), "y": int(round(py))}
data["left_position"] = _pt_lm(LEFT_POSITION)
data["right_position"] = _pt_lm(RIGHT_POSITION)
if self.head_pose is not None:
yaw, pitch, roll = self.head_pose
data["head_pose"] = {
@@ -220,7 +285,8 @@ def measure_face(landmarks, hair_mask, image_width, image_height, head_pose=None
vertical, source = decide_vertical(landmarks, image_width, image_height, hair_mask)
eyes = measure_seven_eyes(landmarks, image_width, image_height)
px_per_cm = estimate_scale_factor(landmarks, image_width, image_height)
return MeasureResult(vertical, eyes, px_per_cm, source, head_pose)
return MeasureResult(vertical, eyes, px_per_cm, source, head_pose,
landmarks, image_width, image_height)
if __name__ == "__main__":
+10 -1
View File
@@ -50,12 +50,21 @@ def estimate_head_pose(landmarks, image_width, image_height):
[0, 0, 1]], dtype=np.float64)
dist = np.zeros((4, 1)) # 假设无畸变
success, rvec, _tvec = cv2.solvePnP(
success, rvec, tvec = cv2.solvePnP(
_MODEL_POINTS, image_points, cam_matrix, dist,
flags=cv2.SOLVEPNP_ITERATIVE,
)
if not success:
return None
# ITERATIVE 偶发收敛到相机后方的翻转解(tz<0),此时 roll 落在 ±180° 附近,
# 会把真正的正面照误判为 1003。改用 SQPNP 重解正深度解。
if float(tvec[2, 0]) < 0:
ok2, rvec2, tvec2 = cv2.solvePnP(
_MODEL_POINTS, image_points, cam_matrix, dist,
flags=cv2.SOLVEPNP_SQPNP,
)
if ok2 and float(tvec2[2, 0]) > 0:
rvec = rvec2
rot, _ = cv2.Rodrigues(rvec)
# 在「相机坐标系」(x右 y下 z内) 下抽取 Tait-Bryan 欧拉角,物理含义对齐:
# yaw = 绕 Y(竖轴)转 → 左右扭头
+339
View File
@@ -0,0 +1,339 @@
INFO: Started server process [26440]
INFO: Waiting for application startup.
2026-07-01 23:35:15 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://127.0.0.1:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
2026-07-01 23:35:15 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:16 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
2026-07-01 23:35:16 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
2026-07-01 23:35:16 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
2026-07-01 23:35:16 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:35872 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:35:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:35:27 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/422b7786adc4486989d7f8770df40693.png (21864 bytes)
INFO: 127.0.0.1:57220 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
2026-07-01 23:35:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:57232 - "GET /static/annotations/422b7786adc4486989d7f8770df40693.png HTTP/1.1" 200 OK
INFO: 127.0.0.1:57240 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:35:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:44470 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:52:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:6017 - "GET / HTTP/1.1" 200 OK
INFO: 111.192.98.24:6017 - "GET /favicon.ico HTTP/1.1" 404 Not Found
2026-07-01 23:53:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:39090 - "GET /docs HTTP/1.1" 200 OK
INFO: 127.0.0.1:39092 - "GET / HTTP/1.1" 200 OK
2026-07-01 23:55:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:56172 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56174 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56184 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56196 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56202 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56206 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56212 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56214 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56218 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56230 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56242 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56256 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56272 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56278 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56282 - "GET /static/integration.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56284 - "GET /static/integration.html HTTP/1.1" 200 OK
2026-07-01 23:55:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:6434 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
2026-07-01 23:56:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:08 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:56:08 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/8a70c12c19ef4868af555ef84eaeafae.png (35965 bytes)
INFO: 111.192.98.24:6435 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
2026-07-01 23:56:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: Shutting down
INFO: Waiting for application shutdown.
2026-07-01 23:58:08 [INFO] gateway: 网关关闭中...
2026-07-01 23:58:08 [INFO] gateway: 清理任务已停止
2026-07-01 23:58:08 [INFO] gateway.pool: 健康检查循环已停止
2026-07-01 23:58:08 [INFO] gateway.pool: Worker 池已关闭
2026-07-01 23:58:08 [INFO] gateway: 网关已关闭
INFO: Application shutdown complete.
INFO: Finished server process [26440]
INFO: Started server process [31898]
INFO: Waiting for application startup.
2026-07-01 23:58:10 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://117.50.213.111:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
2026-07-01 23:58:10 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:10 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
2026-07-01 23:58:10 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
2026-07-01 23:58:10 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
2026-07-01 23:58:10 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:33732 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:58:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:28 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:58:28 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/478efab4566a46c3af0065ad0ffafb67.png (21864 bytes)
INFO: 127.0.0.1:32982 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
INFO: 117.50.213.111:57344 - "GET /static/annotations/478efab4566a46c3af0065ad0ffafb67.png HTTP/1.1" 200 OK
2026-07-01 23:58:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:54 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:58:54 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png (35965 bytes)
INFO: 111.192.98.24:7030 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
INFO: 111.192.98.24:7030 - "GET /static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png HTTP/1.1" 200 OK
2026-07-01 23:58:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:7031 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
2026-07-01 23:59:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg (105970 bytes)
2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg (113320 bytes)
INFO: 111.192.98.24:7062 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
INFO: 111.192.98.24:7062 - "GET /static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:7166 - "GET /static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg HTTP/1.1" 200 OK
2026-07-01 23:59:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:48 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg (105641 bytes)
2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg (111495 bytes)
INFO: 111.192.98.24:5164 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
INFO: 111.192.98.24:5164 - "GET /static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5188 - "GET /static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg HTTP/1.1" 200 OK
2026-07-01 23:59:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:5213 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
2026-07-02 00:00:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:16 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow-b "HTTP/1.1 200 OK"
2026-07-02 00:00:16 [INFO] gateway.forward: base64→URL: hair_growth_image_base64 → http://117.50.213.111:8080/static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg (152092 bytes)
INFO: 111.192.98.24:5212 - "POST /api/v1/hair/grow-b HTTP/1.1" 200 OK
INFO: 111.192.98.24:5212 - "GET /static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg HTTP/1.1" 200 OK
2026-07-02 00:00:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:5332 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
2026-07-02 00:00:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:39 [ERROR] gateway: 接口4 豆包调用失败
Traceback (most recent call last):
File "/home/ubuntu/hair/gateway/app.py", line 298, in face_features
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/starlette/concurrency.py", line 37, in run_in_threadpool
return await anyio.to_thread.run_sync(func)
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "/home/ubuntu/hair/face_features.py", line 98, in analyze_features
resp = get_client().chat.completions.create(
File "/home/ubuntu/hair/face_features.py", line 65, in get_client
from volcenginesdkarkruntime import Ark
ModuleNotFoundError: No module named 'volcenginesdkarkruntime'
INFO: 111.192.98.24:5331 - "POST /api/v1/face/features HTTP/1.1" 200 OK
2026-07-02 00:00:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:5436 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
2026-07-02 00:00:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:00 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hairline/generate "HTTP/1.1 200 OK"
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/4516a9491a1c41f4af048020f6709870.jpg (105674 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg (105970 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg (105735 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/316d8352062848e4b28337ef233d820e.jpg (105641 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg (105705 bytes)
INFO: 111.192.98.24:5435 - "POST /api/v1/hairline/generate HTTP/1.1" 200 OK
INFO: 111.192.98.24:5435 - "GET /static/annotations/4516a9491a1c41f4af048020f6709870.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5466 - "GET /static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5467 - "GET /static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5469 - "GET /static/annotations/316d8352062848e4b28337ef233d820e.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5471 - "GET /static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg HTTP/1.1" 200 OK
2026-07-02 00:01:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: Shutting down
INFO: Waiting for application shutdown.
2026-07-02 00:02:59 [INFO] gateway: 网关关闭中...
2026-07-02 00:02:59 [INFO] gateway: 清理任务已停止
2026-07-02 00:02:59 [INFO] gateway.pool: 健康检查循环已停止
2026-07-02 00:02:59 [INFO] gateway.pool: Worker 池已关闭
2026-07-02 00:02:59 [INFO] gateway: 网关已关闭
INFO: Application shutdown complete.
INFO: Finished server process [31898]
+8 -9
View File
@@ -1,16 +1,15 @@
[Unit]
Description=Hair Worker (GPU) - 四庭七眼测量 接口1
After=network.target
Description=hair GPU worker FastAPI (0.0.0.0:8187)
After=network-online.target comfyui.service change_hair-hair.service
Wants=comfyui.service change_hair-hair.service
[Service]
Type=simple
User=xsl
WorkingDirectory=/home/xsl/hair
# 鉴权密码:优先 worker_config.json;也可在此用环境变量覆盖
# Environment=WORKER_ACCEPT_PASSWORDS=your-strong-secret
ExecStart=/home/xsl/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
Restart=always
RestartSec=3
User=ubuntu
WorkingDirectory=/home/ubuntu/hair
ExecStart=/home/ubuntu/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+450
View File
@@ -0,0 +1,450 @@
{
"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": 808990860769642
},
"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-1784045785080.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": "遮罩填充漏洞"
}
},
"36": {
"inputs": {
"masks": [
"33",
0
]
},
"class_type": "Convert Masks to Images",
"_meta": {
"title": "遮罩到图像"
}
},
"37": {
"inputs": {
"method": "intensity",
"image": [
"39",
0
]
},
"class_type": "Image To Mask",
"_meta": {
"title": "图像到遮罩"
}
},
"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": "图像与遮罩预览"
}
},
"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_kzrpg_00019_.png&type=temp&subfolder=&rand=0.8964945384546902"
},
{
"name": "B",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_kzrpg_00020_.png&type=temp&subfolder=&rand=0.6762414189274947"
}
]
},
"image_a": [
"62",
0
],
"image_b": [
"26",
0
]
},
"class_type": "Image Comparer (rgthree)",
"_meta": {
"title": "图像对比"
}
},
"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"
}
}
}
+36 -6
View File
@@ -1,8 +1,9 @@
"""ComfyUI 客户端:用 add_hair.json / add_hair2.json 工作流跑生发图(Flux-2 inpaint)。
worker 不跑 Flux,只把「划线图 + 遮罩」的 RGBA 上传到本机 ComfyUI(默认 8188)
worker 不跑 Flux,只把「划线图 + 遮罩」的 RGBA 上传到远端 ComfyUI
(默认 http://10.60.74.221:8188,可用环境变量 COMFYUI_URL 覆盖),
替换工作流节点 26 的输入图、随机 seed,提交 /prompt,轮询 /history,取回 /view 输出。
ComfyUI 开启了 HTTP Basic Authuser `admin` + 密码),所有请求都带凭据。
ComfyUI 开启了 HTTP Basic Authuser `admin` + 密码),所有请求都带凭据。
支持多工作流:run() 可通过 workflow_path 指定不同工作流 JSON,自动检测 SaveImage 输出节点。
"""
@@ -10,6 +11,7 @@ from __future__ import annotations
import copy
import json
import logging
import os
import random
import time
@@ -84,11 +86,13 @@ def _get_output_node(workflow_path: str | None = None) -> str:
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
workflow_path: str | None = None) -> bytes:
workflow_path: str | None = None, front: bool = False) -> bytes:
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。
frontTrue 时任务插到 ComfyUI 队列最前(server 端 "front" 字段,队列号取负)。
接口2 对时延敏感用 True,避免排在接口3/5 的批量任务后面;其余接口保持 False。
"""
path = workflow_path or _WORKFLOW_DEFAULT
output_node = _get_output_node(path)
@@ -103,14 +107,40 @@ 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)
if prompt is not None:
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
# 3. 提交
r = cli.post("/prompt", json={"prompt": wf, "client_id": client_id})
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
try:
import os as _os
_diag = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
"log", "comfyui_last_submit")
_os.makedirs(_diag, exist_ok=True)
with open(_os.path.join(_diag, "workflow.json"), "w", encoding="utf-8") as _f:
json.dump(wf, _f, ensure_ascii=False, indent=2)
with open(_os.path.join(_diag, "input.png"), "wb") as _f:
_f.write(rgba_png_bytes)
with open(_os.path.join(_diag, "prompt.txt"), "w", encoding="utf-8") as _f:
_f.write(prompt if prompt is not None else "(None=用工作流内置默认)")
except Exception: # noqa: BLE001
pass
# 3. 提交(front=True 时插队到队列最前)
payload = {"prompt": wf, "client_id": client_id}
if front:
payload["front"] = True
r = cli.post("/prompt", json=payload)
r.raise_for_status()
prompt_id = r.json()["prompt_id"]
@@ -129,7 +159,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}")
+47
View File
@@ -186,6 +186,53 @@ def smooth_hairline_corner_aware(
return out
def clamp_hairline_to_silhouette(
hairline_norm: np.ndarray,
parse_map: np.ndarray,
margin_px: float = 2.0,
) -> np.ndarray:
"""把发际线点的 y 钳制在 (skinhair) silhouette 上沿之下(不含 margin 以上)。
根因(见 issue:男性 ellipse 发际线贴到头部外面):`sample_hairline` 对射线
未命中 hair 像素的锚点会 fallback 成「锚点 + 固定 0.18 归一化偏移」,与头部实际
大小/位置无关 —— 短发/剃光头场景下这个偏移量常常把点顶到头部轮廓外面的背景里,
在有效/失效锚点交界处形成尖角,被贴图上的不透明像素蒙到就会露出戳出头部的线条。
本函数在几何检测之后追加一步「安全网」:对每个点按其 x 所在列,取 silhouette
SegFormer skinhair 类,近似头部实际轮廓)上沿 y,若点比这个上沿还高(y 更
小),直接钳制到 上沿 + margin_px —— 保证曲线永远不会跑到头部轮廓外面的背景。
"""
h, w = parse_map.shape
cols_with_head, top_y = _head_top_y_per_column(parse_map, use_full_hair=True)
if cols_with_head.size == 0:
return hairline_norm
out = hairline_norm.copy()
for i in range(out.shape[0]):
x_px = float(out[i, 0]) * w
idx = int(np.searchsorted(cols_with_head, x_px))
idx = min(max(idx, 0), cols_with_head.size - 1)
sil_y = float(top_y[idx]) + margin_px
y_px = float(out[i, 1]) * h
if y_px < sil_y:
out[i, 1] = sil_y / h
return out
def sample_hairline_clamped(
landmarks_norm: np.ndarray,
parse_map: np.ndarray,
fallback_extrapolation: float = 0.18,
) -> tuple[np.ndarray, np.ndarray]:
"""策略 A(baseline + 头部轮廓钳制):与默认 `sample_hairline` 完全一致的检测,
额外用 `clamp_hairline_to_silhouette` 兜底 —— 检测失效 fallback 出的点不再可能
跑到头部外面的背景,而是贴着头部实际轮廓顶部。改动小、风险低,只在检测失效/
fallback 越界时才生效,正常长发照片的结果与 baseline 完全一致。
"""
hairline, valid = sample_hairline(landmarks_norm, parse_map, fallback_extrapolation)
hairline = clamp_hairline_to_silhouette(hairline, parse_map)
return hairline, valid
# ---------------------------------------------------------------------------
# Alternative hairline sampling strategies.
#
+78
View File
@@ -0,0 +1,78 @@
"""直接调 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,
front: bool = False) -> bytes:
"""直接调 ComfyUI 重绘 — 替代 local_test /api/generate。
Args:
image_bytes: 人物图片字节(JPG/PNG
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
timeout: ComfyUI 超时秒数
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
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, front=front)
+267 -28
View File
@@ -14,21 +14,76 @@ from . import constants as C
from . import comfyui
from .face_landmarks import FaceLandmarker
from .face_parsing import FaceParser
from .hairline_2d import sample_hairline, smooth_hairline
from .hairline_2d import (
smooth_hairline, sample_hairline_clamped,
)
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve
from .marker_detect import detect_marker_hairline, path_to_curve_mask
import base64
import io
import logging
logger = logging.getLogger("hair.worker")
# 接口2 女性发型 key → change_hair hair_idchang_*)映射:换发型+Flux-2 整帧重绘用。
# 与接口12 final 的 5 型一一对应。
_FEMALE_KEY_TO_CHANG = {
"ellipse": "chang_tuoyuan", # 椭圆
"flower": "chang_huaban", # 花瓣
"heart": "chang_xinxing", # 心形
"straight": "chang_zhixian", # 直线
"wave": "chang_bolang", # 波浪
}
_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 档画质更好但部分大图会踩 12s 线,
# 默认压到 896 兜底(ComfyUI ~4s,女性总耗时 9~11s);追画质可设 REDRAW_MAX_SIDE=1024。
_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "896"))
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
传 final 图 + 纯红遮罩 PNG,返回重绘后的 PNG bytes。
失败抛异常(调用方负责 try/except 跳过)。
"""
from .redraw import run_redraw
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)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前,避免排在接口3/5 的批量任务后面
out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout,
prompt=_REDRAW_PROMPT, front=True)
if scale < 1.0 and out:
out = _upscale_png_to(out, orig_w, orig_h)
return out
# 发际线贴图档位:middle=默认(hairline_texture/)high/low 各自独立文件夹。
_TEXTURE_DIRS = {
"middle": _TEXTURE_DIR,
@@ -36,9 +91,8 @@ _TEXTURE_DIRS = {
"low": os.path.join(_REPO, "hairline_texture_low"),
}
# ⚠️ 本 worker 是 RTX 5090(sm_120)torch 2.2.2(cu121) 只编到 sm_90CUDA 跑算子会报
# "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
@@ -100,13 +154,19 @@ def extract_502(image_bgr: np.ndarray):
def extract_context(image_bgr: np.ndarray):
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。"""
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。
发际线几何检测固定用 `sample_hairline_clamped`(射线检测 + 头部轮廓钳制):
短发/剃光头照片(如 man_test.jpg)中间锚点检测失效时,纯射线检测的固定 fallback
偏移会把点顶到头部轮廓外面的背景,产生"发际线贴到头部外面"的视觉 bug;钳制兜底后
fallback 点不会再跑出头部轮廓,正常长发照片结果与旧行为一致。
"""
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
landmarks = get_landmarker().detect(rgb)
if landmarks is None:
return None
parse_map = get_parser().parse(rgb)
hairline_2d, valid = sample_hairline(landmarks, parse_map)
hairline_2d, valid = sample_hairline_clamped(landmarks, parse_map)
hairline_2d = smooth_hairline(hairline_2d, valid)
hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
middle_3d = build_middle_row(landmarks, hairline_3d)
@@ -171,9 +231,14 @@ 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")
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
compose_comfy_rgba(img_s, msk_s).save(buf, format="PNG", compress_level=1)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt,
workflow_path=workflow_path, front=True)
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)
@@ -191,9 +256,14 @@ 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")
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
grown_png = comfyui.run(buf.getvalue(), prompt=prompt,
workflow_path=workflow_path, front=True)
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)
@@ -202,6 +272,105 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
return results
def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
redraw_defaults: dict):
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results+ 换发型重绘图。
grown 图来源(新流程):对每个选中发型把 female key 映射到 change_hair 的 chang_* hair_id
调 face_analysis.hairline_grow.generate_hairline_redraw= 接口12 final 管线,参数用
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]
else:
items = list(enumerate(textures, start=1))
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)
grown_png = None
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
if chang_id is None:
logger.warning("接口2 换发型:female key=%s 无对应 chang_id,跳过生发图", key)
else:
try:
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 ""
mask_b64 = steps.get("redraw_band_mask_base64") or ""
if not final_b64 or not mask_b64:
logger.warning("接口2 换发型:type=%s final/遮罩缺失(final=%d mask=%d",
key, len(final_b64), len(mask_b64))
else:
# 去掉 data URI 前缀
if final_b64.startswith("data:"):
final_b64 = final_b64.split(",", 1)[1]
if mask_b64.startswith("data:"):
mask_b64 = mask_b64.split(",", 1)[1]
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 单张失败不拖垮整请求
logger.warning("接口2 换发型图失败 type=%s%s", key, e)
results.append({"hairline_type": key, "order": order,
"overlay": overlay, "grown_png": grown_png})
return results
def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
use_mask: bool, prompt: str | None):
"""对单个发际线做生发(ComfyUI)。黑模板固定取 hairline_texture_black/middle),
@@ -218,7 +387,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)
@@ -227,15 +396,19 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
hair_styles: list[int], use_mask: bool = True,
prompt: str | None = None):
prompt: str | None = None,
generate_grow_image: bool = True):
"""接口5:对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。
入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。
每个选中发型返回三档叠图(middle/high/low,RGBA 透明层只含发际线曲线)与一张生发图;
三档贴图同名,生发黑模板固定取自 hairline_texture_black/middle),故生发目标固定 middle 档。
use_mask/prompt:同接口2 的生发参数。
generate_grow_image(默认 True):是否生成生发图(ComfyUI,最耗时)。False 时跳过生发,
各发型 grown_png 恒为 None,可大幅降低耗时(仅留三档发际线叠图与中心点)。
Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}((H,W,4) RGBA 透明层),grown_png}],
"best_center":(x,y)};无人脸 None。best_center 取首个选中发型的 middle 档
"best_centers":{"middle":(x,y),"high":(x,y),"low":(x,y)}};无人脸 None
best_centers 取首个选中发型三档各自的发际线中点。
"""
if gender not in ("male", "female"):
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
@@ -254,31 +427,84 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
tex_by_level = {lv: get_texture_map(lv)[gender] for lv in _TEXTURE_DIRS}
# use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用
# generate_grow_image=False:完全跳过生发(最耗时),grown_png 恒为 None
shared_grown = None
if not use_mask:
if generate_grow_image and not use_mask:
shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt)
images, best_center = [], None
def _center_of(overlay):
"""从某档发际线透明叠图取面部中轴处的发际线中点 (x,y),无像素返回 None。"""
ys, xs = np.where(overlay[:, :, 3] > 40)
if not xs.size:
return None
near = np.abs(xs - face_cx) <= max(2, int(w * 0.02))
col_ys = ys[near] if near.any() else ys[np.argsort(np.abs(xs - face_cx))[:20]]
return (int(round(face_cx)), int(round(float(col_ys.mean()))))
images, best_centers = [], None
for s in hair_styles: # s = 1-indexed 发型序号
key, mid_path = tex_by_level["middle"][s - 1]
overlays = {}
for lv in _TEXTURE_DIRS:
white = load_texture_rgba(tex_by_level[lv][s - 1][1])
overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
# 生发:固定 middle 黑模板
grown_png = shared_grown if not use_mask else \
_grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt)
# 生发:固定 middle 黑模板generate_grow_image=False 时跳过,恒 None
if not generate_grow_image:
grown_png = None
elif not use_mask:
grown_png = shared_grown
else:
grown_png = _grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt)
images.append({"hairline_type": key, "order": s,
"overlays": overlays, "grown_png": grown_png})
# best_center:首个选中发型middle 档发际线中点(面部中轴处的发际线 y)
if best_center is None:
overlay = overlays["middle"] # 复用已渲染的 middle 档透明层
ys, xs = np.where(overlay[:, :, 3] > 40)
if xs.size:
near = np.abs(xs - face_cx) <= max(2, int(w * 0.02))
col_ys = ys[near] if near.any() else ys[np.argsort(np.abs(xs - face_cx))[:20]]
best_center = (int(round(face_cx)), int(round(float(col_ys.mean()))))
return {"images": images, "best_center": best_center}
# best_centers:首个选中发型三档(middle/high/low)发际线中点
if best_centers is None:
best_centers = {lv: _center_of(overlays[lv]) for lv in _TEXTURE_DIRS}
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):
@@ -287,12 +513,23 @@ def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str =
检测路径只用来**建遮罩**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)
@@ -310,8 +547,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") # 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"}
+174
View File
@@ -0,0 +1,174 @@
# 发型补全服务 API 文档
## 服务概述
本服务提供基于 ComfyUI 的发型补全(局部重绘)能力。通过传入人物图片和遮罩图片,调用 ComfyUI 工作流(`0716add-hair.json`)生成补全后的图片。
## 技术栈
- **框架**: Flask
- **依赖**: requests, Pillow, numpy
- **后端**: ComfyUI (http://127.0.0.1:8188)
## 服务地址
- **HTTP**: `http://127.0.0.1:8899`
- **前端页面**: `http://127.0.0.1:8899/`
- **API接口**: `http://127.0.0.1:8899/api/generate`
## 启动方式
### 使用脚本(推荐)
```bash
# 启动服务
cd /home/ubuntu/hair/local_test
./start.sh
# 停止服务
./stop.sh
```
### 直接运行
```bash
cd /home/ubuntu/hair/local_test
/home/ubuntu/ComfyUI/venv/bin/python app.py
```
## API 接口
### POST /api/generate
调用 ComfyUI 工作流,传入图片和遮罩,返回生成结果。
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| image | File | 是 | 人物图片(支持 jpg, png 等常见格式) |
| mask | File | 是 | 遮罩图片(支持 jpg, png,遮罩区域可用红色/白色/alpha 通道标识) |
| prompt | String | 否 | 提示词,默认值:"填充遮罩区域的头发,皮肤加一点磨皮" |
#### 遮罩图片格式说明
服务支持多种遮罩格式,自动提取遮罩区域:
| 格式类型 | 示例 | 遮罩区域标识 |
|----------|------|--------------|
| 红色遮罩 | 红色画笔绘制 | R=255 的像素 |
| 白色遮罩 | 白色画笔绘制 | R=G=B=255 的像素 |
| Alpha 遮罩 | 透明背景 | A=255 的像素 |
服务会取所有通道的最大值作为遮罩强度,因此以上格式均可混用。
**注意**: 遮罩区域表示需要重绘的部分,非遮罩区域保持原图不变。
#### 请求示例(curl
```bash
curl -X POST http://127.0.0.1:8899/api/generate \
-F "image=@/path/to/person.jpg" \
-F "mask=@/path/to/mask.png" \
-F "prompt=填充遮罩区域的头发,皮肤加一点磨皮" \
--output result.png
```
#### 请求示例(Python
```python
import requests
url = "http://127.0.0.1:8899/api/generate"
files = {
"image": open("person.jpg", "rb"),
"mask": open("mask.png", "rb"),
}
data = {
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮"
}
resp = requests.post(url, files=files, data=data, timeout=600)
if resp.status_code == 200:
with open("result.png", "wb") as f:
f.write(resp.content)
else:
print(f"Error: {resp.json()}")
```
#### 响应
**成功 (HTTP 200)**:
返回 PNG 图片二进制数据,Content-Type: `image/png`
**失败 (HTTP 4xx/5xx)**:
返回 JSON 格式错误信息:
```json
{
"error": "错误描述"
}
```
#### 错误码
| 状态码 | 说明 |
|--------|------|
| 500 | 内部错误(文件处理失败、ComfyUI 返回错误等) |
| 503 | 无法连接到 ComfyUI(服务未启动或端口错误) |
| 500 | 超时(工作流执行超过 5 分钟) |
## 工作流说明
服务使用的工作流 `0716add-hair.json` 包含以下处理步骤:
1. **加载模型**: Flux 2 Klein 9B (FP8) + Qwen 3.8B CLIP
2. **图片上传**: 将原图与遮罩合成为 RGBA 格式上传至 ComfyUI
3. **遮罩处理**: 填充孔洞 → 转换为图像 → 缩放 → 转换回遮罩
4. **图像缩放**: 按比例缩放至合适尺寸(最大边长 1024,8 的倍数)
5. **VAE 编码**: 将图像编码为 latent
6. **采样生成**: 使用 Flux 模型 + ReferenceLatent 进行局部重绘
7. **VAE 解码**: 将 latent 解码为图像
8. **颜色匹配**: 使用 ColorMatch 保持颜色一致
9. **保存结果**: 返回生成的图片
## 前置依赖
启动服务前需确保:
1. **ComfyUI 已启动**: `http://127.0.0.1:8188` 可访问
2. **模型文件存在**:
- `models/unet/flux2.0/flux-2-klein-9b-fp8.safetensors`
- `models/vae/flux2-vae.safetensors`
- `models/clip/qwen_3_8b_fp8mixed.safetensors`
3. **虚拟环境已激活**: 使用 `/home/ubuntu/ComfyUI/venv/bin/python`
## 文件结构
```
/home/ubuntu/hair/local_test/
├── app.py # Flask 后端服务
├── index.html # 前端测试页面
├── test_api.py # API 测试脚本
├── README.md # 本文档
├── output/ # 测试结果输出目录
├── 用来重绘.jpg # 测试人物图片
└── 用来重绘.png # 测试遮罩图片
```
## 使用流程
1. 启动 ComfyUI`python main.py --listen`
2. 启动本服务(`python app.py`
3. 调用 API 或访问前端页面上传图片和遮罩
4. 等待生成完成(通常 30-60 秒)
5. 获取返回的 PNG 图片
## 注意事项
- 请求超时时间为 5 分钟,生成复杂图片可能需要较长时间
- 遮罩图片尺寸需与人物图片一致,服务会自动缩放对齐
- 建议使用红色或白色绘制遮罩,确保遮罩强度足够
- 服务会自动对遮罩边缘进行高斯模糊(radius=4),避免硬边
+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""Hair inpainting service - calls ComfyUI workflow with image + mask."""
import io
import json
import time
import random
import logging
import traceback
import requests
from flask import Flask, request, jsonify, send_file
import numpy as np
from PIL import Image, ImageFilter
# 让 PIL 支持 iPhone 的 HEIC/HEIF 照片(浏览器 accept="image/*" 会允许选中它们)。
try:
from pillow_heif import register_heif_opener
register_heif_opener()
_HEIF_OK = True
except Exception:
_HEIF_OK = False
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
log = logging.getLogger("hair")
app = Flask(__name__)
COMFYUI_URL = "http://127.0.0.1:8188"
# 允许浏览器跨域直连本服务(如 hair 项目的测试页)。
# 不引入 flask-cors 依赖,直接在响应头 + OPTIONS 预检里处理。
_CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Expose-Headers": "X-Generate-Time",
}
@app.after_request
def _add_cors(resp):
for k, v in _CORS_HEADERS.items():
resp.headers[k] = v
return resp
@app.route("/api/generate", methods=["OPTIONS"])
def _generate_preflight():
"""CORS 预检:浏览器 POST 前会先发 OPTIONS。"""
return ("", 204)
def build_workflow(image_filename, prompt_text, seed=None):
"""Build ComfyUI API workflow from the 0716add-hair.json structure."""
if seed is None:
seed = random.randint(0, 2**53)
return {
# Loaders
"16": {"class_type": "UNETLoader", "inputs": {
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn_fast"}},
"3": {"class_type": "VAELoader", "inputs": {
"vae_name": "flux2-vae.safetensors"}},
"61": {"class_type": "CLIPLoader", "inputs": {
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
"type": "flux2",
"device": "default"}},
# Input image (with mask in alpha channel)
"26": {"class_type": "LoadImage", "inputs": {
"image": image_filename}},
# Prompt
"60": {"class_type": "JjkText", "inputs": {
"text": prompt_text}},
"22": {"class_type": "CLIPTextEncode", "inputs": {
"clip": ["61", 0],
"text": ["60", 0]}},
# Image size
"31": {"class_type": "easy imageSize", "inputs": {
"image": ["26", 0]}},
# Mask processing: fill holes -> convert to image -> scale -> back to mask
"33": {"class_type": "Mask Fill Holes", "inputs": {
"masks": ["26", 1]}},
"36": {"class_type": "Convert Masks to Images", "inputs": {
"masks": ["33", 0]}},
"39": {"class_type": "ImageScale", "inputs": {
"image": ["36", 0],
"upscale_method": "nearest-exact",
"width": ["31", 0],
"height": ["31", 1],
"crop": "disabled"}},
"37": {"class_type": "Image To Mask", "inputs": {
"image": ["39", 0],
"method": "intensity"}},
# Scale image+mask by aspect ratio
"32": {"class_type": "LayerUtility: ImageScaleByAspectRatio V2", "inputs": {
"image": ["26", 0],
"mask": ["37", 0],
"aspect_ratio": "custom",
"proportional_width": ["31", 0],
"proportional_height": ["31", 1],
"fit": "letterbox",
"method": "lanczos",
"round_to_multiple": "8",
"scale_to_side": "None",
"scale_to_length": 1024,
"background_color": "#000000"}},
# Preview (pass_through=true, just passes the image through)
"44": {"class_type": "ImageAndMaskPreview", "inputs": {
"image": ["32", 0],
"mask": ["32", 1],
"mask_opacity": 1,
"mask_color": "FFFF00",
"pass_through": True}},
# Get size of scaled image
"14": {"class_type": "GetImageSize+", "inputs": {
"image": ["44", 0]}},
# VAE encode the image
"13": {"class_type": "VAEEncode", "inputs": {
"pixels": ["44", 0],
"vae": ["3", 0]}},
# Flux model setup
"2": {"class_type": "ModelSamplingFlux", "inputs": {
"model": ["16", 0],
"max_shift": 1.15,
"base_shift": 0.5,
"width": ["14", 0],
"height": ["14", 1]}},
"19": {"class_type": "FluxGuidance", "inputs": {
"conditioning": ["22", 0],
"guidance": 1}},
"5": {"class_type": "ReferenceLatent", "inputs": {
"conditioning": ["19", 0],
"latent": ["13", 0]}},
# Empty latent for sampling
"7": {"class_type": "EmptySD3LatentImage", "inputs": {
"width": ["14", 0],
"height": ["14", 1],
"batch_size": 1}},
# Scheduler & guider
"1": {"class_type": "BasicScheduler", "inputs": {
"model": ["2", 0],
"scheduler": "simple",
"steps": 4,
"denoise": 1}},
"20": {"class_type": "BasicGuider", "inputs": {
"model": ["2", 0],
"conditioning": ["5", 0]}},
# Noise & sampler
"6": {"class_type": "RandomNoise", "inputs": {
"noise_seed": seed}},
"8": {"class_type": "KSamplerSelect", "inputs": {
"sampler_name": "euler"}},
"9": {"class_type": "SamplerCustomAdvanced", "inputs": {
"noise": ["6", 0],
"guider": ["20", 0],
"sampler": ["8", 0],
"sigmas": ["1", 0],
"latent_image": ["7", 0]}},
# VAE decode
"10": {"class_type": "VAEDecode", "inputs": {
"samples": ["9", 0],
"vae": ["3", 0]}},
# Color match with original image
"62": {"class_type": "ColorMatch", "inputs": {
"image_ref": ["26", 0],
"image_target": ["10", 0],
"method": "mkl",
"strength": 1,
"multithread": True}},
# Save result
"17": {"class_type": "SaveImage", "inputs": {
"images": ["62", 0],
"filename_prefix": "hair_inpaint"}},
}
@app.route("/")
def index():
return send_file("index.html")
@app.route("/api/generate", methods=["POST"])
def generate():
t_start = time.time()
try:
if "image" not in request.files or "mask" not in request.files:
msg = f"缺少上传文件: files={list(request.files.keys())}"
log.warning(msg)
return jsonify({"error": msg}), 400
image_file = request.files["image"]
mask_file = request.files["mask"]
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
log.info(
"收到请求: image=%s mask=%s prompt=%r",
image_file.filename, mask_file.filename, prompt_text,
)
# Load original image as RGB
try:
image = Image.open(image_file).convert("RGB")
except Exception as e:
log.error("无法解码人物图片 %s: %s", image_file.filename, e)
hint = "" if _HEIF_OK else "(当前不支持 HEIC"
return jsonify({
"error": f"无法识别人物图片格式{hint},请改用 JPG/PNG: {e}"
}), 400
# Load mask and extract mask data from ALL channels (R, G, B, A)
# This handles different mask formats:
# - Red mask (R=255 where drawn): user-provided PNG
# - White mask (R=G=B=255 where drawn): frontend canvas
# - Alpha mask (A=255 where drawn): transparent brush
try:
mask_img = Image.open(mask_file).convert("RGBA")
except Exception as e:
log.error("无法解码遮罩图片 %s: %s", mask_file.filename, e)
return jsonify({
"error": f"无法识别遮罩图片格式,请改用 JPG/PNG: {e}"
}), 400
mask_arr = np.array(mask_img)
# Use max of all channels: 255 where any color/alpha is drawn, 0 where empty
mask_data = np.max(mask_arr, axis=2) # (H, W) uint8
# Ensure mask matches image size
mask_data_img = Image.fromarray(mask_data, mode="L")
if mask_data_img.size != image.size:
mask_data_img = mask_data_img.resize(image.size, Image.LANCZOS)
# Apply slight blur for soft edges (similar to ComfyUI's brush)
mask_data_img = mask_data_img.filter(ImageFilter.GaussianBlur(radius=4))
# ComfyUI LoadImage: mask = 1.0 - (alpha/255)
# So alpha=0 -> mask=1.0 (inpaint), alpha=255 -> mask=0.0 (keep)
# We want: drawn area (mask_data=255) -> inpaint -> alpha=0
# undrawn area (mask_data=0) -> keep -> alpha=255
comfyui_alpha = Image.eval(mask_data_img, lambda x: 255 - x)
# Combine into RGBA (split RGB into separate channels first)
r, g, b = image.split()
rgba = Image.merge("RGBA", (r, g, b, comfyui_alpha))
# Upload to ComfyUI
img_bytes = io.BytesIO()
rgba.save(img_bytes, format="PNG")
img_bytes.seek(0)
upload_resp = requests.post(
f"{COMFYUI_URL}/upload/image",
files={"image": ("hair_input.png", img_bytes, "image/png")},
timeout=30,
)
upload_data = upload_resp.json()
if "name" not in upload_data:
log.error("ComfyUI 上传图片失败: %s", upload_data)
return jsonify({"error": f"Upload failed: {upload_data}"}), 500
image_filename = upload_data["name"]
# Build and queue workflow
workflow = build_workflow(image_filename, prompt_text)
prompt_resp = requests.post(
f"{COMFYUI_URL}/prompt",
json={"prompt": workflow},
timeout=30,
)
prompt_data = prompt_resp.json()
if "error" in prompt_data:
log.error(
"ComfyUI /prompt 校验失败: error=%s node_errors=%s",
prompt_data.get("error"), prompt_data.get("node_errors"),
)
return jsonify({"error": json.dumps(prompt_data["error"], ensure_ascii=False)}), 500
prompt_id = prompt_data["prompt_id"]
# Poll for completion (5 min timeout, 0.1s interval)
for _ in range(3000):
time.sleep(0.1)
history_resp = requests.get(
f"{COMFYUI_URL}/history/{prompt_id}", timeout=10
)
history_data = history_resp.json()
if prompt_id in history_data:
status = history_data[prompt_id].get("status", {})
if status.get("status_str") == "error":
log.error(
"ComfyUI 工作流执行失败: %s",
json.dumps(status, ensure_ascii=False),
)
return jsonify({
"error": "Workflow execution failed",
"detail": status.get("messages", status),
}), 500
outputs = history_data[prompt_id].get("outputs", {})
if "17" in outputs: # SaveImage node
image_info = outputs["17"]["images"][0]
filename = image_info["filename"]
subfolder = image_info.get("subfolder", "")
img_type = image_info.get("type", "output")
view_resp = requests.get(
f"{COMFYUI_URL}/view",
params={"filename": filename, "subfolder": subfolder, "type": img_type},
timeout=30,
)
elapsed = time.time() - t_start
log.info("重绘完成,服务端耗时 %.2fs", elapsed)
resp = send_file(
io.BytesIO(view_resp.content), mimetype="image/png"
)
resp.headers["X-Generate-Time"] = f"{elapsed:.2f}"
return resp
return jsonify({"error": "Timeout: workflow did not complete in 5 minutes"}), 500
except requests.ConnectionError:
log.error("无法连接 ComfyUI @ %s", COMFYUI_URL)
return jsonify({"error": "Cannot connect to ComfyUI at " + COMFYUI_URL + ". Is it running?"}), 503
except Exception as e:
log.error("生成失败,未捕获异常:\n%s", traceback.format_exc())
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8899, debug=False)
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Benchmark ComfyUI hair-inpaint workflow across model / dtype / steps."""
import io, time, sys
import requests
import numpy as np
from PIL import Image, ImageFilter
import app as A
COMFY = "http://127.0.0.1:8188"
def prep_and_upload():
image = Image.open("用来重绘.jpg").convert("RGB")
mask_img = Image.open("用来重绘.png").convert("RGBA")
mask_data = np.max(np.array(mask_img), axis=2)
m = Image.fromarray(mask_data, mode="L")
if m.size != image.size:
m = m.resize(image.size, Image.LANCZOS)
m = m.filter(ImageFilter.GaussianBlur(radius=4))
alpha = Image.eval(m, lambda x: 255 - x)
r, g, b = image.split()
rgba = Image.merge("RGBA", (r, g, b, alpha))
buf = io.BytesIO(); rgba.save(buf, format="PNG"); buf.seek(0)
up = requests.post(f"{COMFY}/upload/image",
files={"image": ("hair_input.png", buf, "image/png")}).json()
return up["name"]
def run_once(fname, model, dtype, steps):
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
wf["16"]["inputs"]["unet_name"] = model
wf["16"]["inputs"]["weight_dtype"] = dtype
wf["1"]["inputs"]["steps"] = steps
r = requests.post(f"{COMFY}/prompt", json={"prompt": wf}).json()
if "prompt_id" not in r:
raise RuntimeError(f"submit failed: {str(r)[:300]}")
pid = r["prompt_id"]
deadline = time.time() + 180
while time.time() < deadline:
time.sleep(0.1)
h = requests.get(f"{COMFY}/history/{pid}").json()
if pid not in h:
continue
st = h[pid].get("status", {})
if st.get("status_str") == "error":
for m in st.get("messages", []):
if m[0] == "execution_error":
raise RuntimeError(str(m[1])[:300])
raise RuntimeError("execution error")
if "17" in h[pid].get("outputs", {}):
ts = {mm[0]: mm[1].get("timestamp") for mm in st["messages"]}
return (ts["execution_success"] - ts["execution_start"]) / 1000.0
raise TimeoutError("run exceeded 180s")
CONFIGS = [
("flux2.0/flux-2-klein-9b-fp8.safetensors", "fp8_e4m3fn", 6, "9B fp8 (当前)"),
("flux2.0/flux-2-klein-9b-fp8.safetensors", "fp8_e4m3fn_fast", 6, "9B fp8-fast"),
("flux2.0/flux-2-klein-9b-fp8.safetensors", "fp8_e4m3fn_fast", 4, "9B fp8-fast s4"),
("flux-2-klein-4b-fp8.safetensors", "fp8_e4m3fn", 6, "4B fp8"),
("flux-2-klein-4b-fp8.safetensors", "fp8_e4m3fn_fast", 6, "4B fp8-fast"),
("flux-2-klein-4b-fp8.safetensors", "fp8_e4m3fn_fast", 4, "4B fp8-fast s4"),
]
fname = prep_and_upload()
print("input uploaded:", fname)
print(f"{'配置':<22}{'warmup':>10}{'run1':>10}{'run2':>10}{'best':>10}")
for model, dtype, steps, label in CONFIGS:
times = []
for i in range(3): # 1 warmup + 2 measured
try:
t = run_once(fname, model, dtype, steps)
except Exception as e:
t = float('nan'); print("ERR", label, e)
times.append(t)
best = min(times[1:])
print(f"{label:<22}{times[0]:>9.2f}s{times[1]:>9.2f}s{times[2]:>9.2f}s{best:>9.2f}s")
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Steps sweep + resolution test on the working 9B fp8-fast config."""
import io, time
import requests
import numpy as np
from PIL import Image, ImageFilter
import app as A
COMFY = "http://127.0.0.1:8188"
MODEL = "flux2.0/flux-2-klein-9b-fp8.safetensors"
DTYPE = "fp8_e4m3fn_fast"
def upload(scale=1.0):
image = Image.open("用来重绘.jpg").convert("RGB")
mask_img = Image.open("用来重绘.png").convert("RGBA")
if scale != 1.0:
w, h = image.size
w, h = int(w * scale) // 8 * 8, int(h * scale) // 8 * 8
image = image.resize((w, h), Image.LANCZOS)
mask_data = np.max(np.array(mask_img), axis=2)
m = Image.fromarray(mask_data, mode="L")
if m.size != image.size:
m = m.resize(image.size, Image.LANCZOS)
m = m.filter(ImageFilter.GaussianBlur(radius=4))
alpha = Image.eval(m, lambda x: 255 - x)
r, g, b = image.split()
rgba = Image.merge("RGBA", (r, g, b, alpha))
buf = io.BytesIO(); rgba.save(buf, format="PNG"); buf.seek(0)
up = requests.post(f"{COMFY}/upload/image",
files={"image": ("hair_input.png", buf, "image/png")}).json()
return up["name"], image.size
def run(fname, steps):
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
wf["16"]["inputs"]["unet_name"] = MODEL
wf["16"]["inputs"]["weight_dtype"] = DTYPE
wf["1"]["inputs"]["steps"] = steps
pid = requests.post(f"{COMFY}/prompt", json={"prompt": wf}).json()["prompt_id"]
dl = time.time() + 120
while time.time() < dl:
time.sleep(0.1)
h = requests.get(f"{COMFY}/history/{pid}").json()
if pid in h and "17" in h[pid].get("outputs", {}):
ts = {m[0]: m[1].get("timestamp") for m in h[pid]["status"]["messages"]}
return (ts["execution_success"] - ts["execution_start"]) / 1000.0
return float("nan")
print("=== 步数扫描 (9B fp8-fast, 原分辨率 1024x775) ===", flush=True)
fname, sz = upload(1.0)
run(fname, 6) # warmup
res = {}
for s in [2, 3, 4, 6, 8]:
t = min(run(fname, s), run(fname, s))
res[s] = t
print(f" steps={s}: {t:.2f}s", flush=True)
# derive per-step cost & fixed overhead via two points
per = (res[8] - res[2]) / 6
fixed = res[2] - per * 2
print(f" -> 每步 ~{per:.3f}s, 固定开销(VAE/编码/colormatch/加载) ~{fixed:.2f}s", flush=True)
print("\n=== 分辨率影响 (steps=4) ===", flush=True)
for scale in [1.0, 0.75, 0.6]:
fn, s2 = upload(scale)
run(fn, 4) # warmup
t = min(run(fn, 4), run(fn, 4))
print(f" {s2[0]}x{s2[1]} ({s2[0]*s2[1]/1e6:.2f}MP): {t:.2f}s", flush=True)
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Generate result images at different step counts for quality comparison."""
import io, time
import requests
import numpy as np
from PIL import Image, ImageFilter
import app as A
COMFY = "http://127.0.0.1:8188"
MODEL = "flux2.0/flux-2-klein-9b-fp8.safetensors"
DTYPE = "fp8_e4m3fn_fast"
OUT = "output"
image = Image.open("用来重绘.jpg").convert("RGB")
mask_img = Image.open("用来重绘.png").convert("RGBA")
mask_data = np.max(np.array(mask_img), axis=2)
m = Image.fromarray(mask_data, mode="L")
if m.size != image.size:
m = m.resize(image.size, Image.LANCZOS)
m = m.filter(ImageFilter.GaussianBlur(radius=4))
alpha = Image.eval(m, lambda x: 255 - x)
r, g, b = image.split()
rgba = Image.merge("RGBA", (r, g, b, alpha))
buf = io.BytesIO(); rgba.save(buf, format="PNG"); buf.seek(0)
fname = requests.post(f"{COMFY}/upload/image",
files={"image": ("hair_input.png", buf, "image/png")}).json()["name"]
# fixed seed for fair comparison
SEED = 123456789
imgs = []
labels = []
for steps in [2, 3, 4, 6]:
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", seed=SEED)
wf["16"]["inputs"]["unet_name"] = MODEL
wf["16"]["inputs"]["weight_dtype"] = DTYPE
wf["1"]["inputs"]["steps"] = steps
pid = requests.post(f"{COMFY}/prompt", json={"prompt": wf}).json()["prompt_id"]
t0 = time.time()
while True:
time.sleep(0.1)
h = requests.get(f"{COMFY}/history/{pid}").json()
if pid in h and "17" in h[pid].get("outputs", {}):
ts = {mm[0]: mm[1].get("timestamp") for mm in h[pid]["status"]["messages"]}
dur = (ts["execution_success"] - ts["execution_start"]) / 1000.0
info = h[pid]["outputs"]["17"]["images"][0]
data = requests.get(f"{COMFY}/view", params={
"filename": info["filename"], "subfolder": info.get("subfolder", ""),
"type": info.get("type", "output")}).content
im = Image.open(io.BytesIO(data)).convert("RGB")
imgs.append(im)
labels.append(f"steps={steps} {dur:.2f}s")
print(f"steps={steps}: {dur:.2f}s", flush=True)
break
# build side-by-side contact sheet
from PIL import ImageDraw
h0 = imgs[0].height
w0 = imgs[0].width
pad = 10
bar = 28
sheet = Image.new("RGB", (w0 * len(imgs) + pad * (len(imgs) + 1),
h0 + bar + pad * 2), (26, 26, 46))
d = ImageDraw.Draw(sheet)
for i, (im, lb) in enumerate(zip(imgs, labels)):
x = pad + i * (w0 + pad)
sheet.paste(im, (x, bar + pad))
d.text((x + 4, 6), lb, fill=(233, 69, 96))
sheet.save(f"{OUT}/compare_steps.png")
print("saved:", f"{OUT}/compare_steps.png", flush=True)
+192
View File
@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>发型补全工具</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; padding: 20px; }
h1 { text-align: center; margin-bottom: 20px; color: #e94560; font-size: 28px; }
.container { max-width: 1400px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.panel { background: #16213e; border-radius: 12px; padding: 20px; }
.panel h2 { margin-bottom: 16px; font-size: 18px; color: #e94560; }
.controls { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; align-items: center; }
.controls label { font-size: 14px; color: #aaa; }
input[type="file"] { color: #ddd; }
input[type="text"] { flex: 1; min-width: 200px; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; background: #0f3460; color: #eee; font-size: 14px; }
button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.2s; }
.btn-upload { background: #0f3460; color: #eee; border: 1px solid #e94560; }
.btn-upload:hover { background: #1a4a7a; }
.btn-generate { background: #e94560; color: #fff; font-size: 16px; padding: 12px 36px; }
.btn-generate:hover { background: #c73650; }
.btn-generate:disabled { background: #555; cursor: not-allowed; }
.image-wrapper { display: flex; gap: 12px; flex-wrap: wrap; border: 2px dashed #444; border-radius: 8px; padding: 12px; min-height: 300px; background: #0f3460; }
.image-item { flex: 1; min-width: 200px; }
.image-item img { max-width: 100%; border-radius: 6px; }
.image-item h4 { font-size: 12px; color: #aaa; margin-bottom: 6px; }
.placeholder { color: #666; font-size: 16px; text-align: center; padding: 60px 20px; width: 100%; }
.result-area { display: flex; gap: 16px; flex-wrap: wrap; }
.result-area img { max-width: 100%; border-radius: 8px; }
.result-item { flex: 1; min-width: 250px; }
.result-item h3 { font-size: 14px; color: #aaa; margin-bottom: 8px; text-align: center; }
.loading { text-align: center; padding: 40px; color: #e94560; font-size: 18px; }
.loading .spinner { display: inline-block; width: 40px; height: 40px; border: 4px solid #333; border-top-color: #e94560; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 12px; }
@keyframes spin { to { transform: rotate(360deg); } }
.error { color: #ff6b6b; padding: 16px; background: #2a1a1a; border-radius: 8px; margin-top: 12px; }
</style>
</head>
<body>
<h1>💇 发型补全工具</h1>
<div class="container">
<!-- Left: Input -->
<div class="panel">
<h2>1. 上传图片 & 遮罩</h2>
<div class="controls">
<label>人物图片:</label>
<input type="file" id="imageInput" accept="image/*" class="btn-upload">
</div>
<div class="controls">
<label>遮罩图片:</label>
<input type="file" id="maskInput" accept="image/*" class="btn-upload">
</div>
<div class="image-wrapper" id="imageWrapper">
<div class="placeholder" id="placeholder">请上传人物图片和遮罩图片</div>
</div>
<div class="controls" style="margin-top:16px">
<label>提示词:</label>
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜">
</div>
<div style="text-align:center; margin-top:16px">
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
</div>
</div>
<!-- Right: Result -->
<div class="panel">
<h2>2. 对比结果</h2>
<div id="resultArea">
<div class="placeholder">生成结果将显示在这里</div>
</div>
</div>
</div>
<script>
const imageInput = document.getElementById('imageInput');
const maskInput = document.getElementById('maskInput');
const imageWrapper = document.getElementById('imageWrapper');
const placeholder = document.getElementById('placeholder');
const promptInput = document.getElementById('promptInput');
const generateBtn = document.getElementById('generateBtn');
const resultArea = document.getElementById('resultArea');
let originalImage = null;
let maskImage = null;
imageInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.onload = () => {
originalImage = { img: img, file: file };
updatePreview();
checkReady();
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
});
maskInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.onload = () => {
maskImage = { img: img, file: file };
updatePreview();
checkReady();
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
});
function updatePreview() {
placeholder.style.display = 'none';
let html = '';
if (originalImage) {
html += `<div class="image-item"><h4>人物图片</h4><img src="${originalImage.img.src}" alt="原图"></div>`;
}
if (maskImage) {
html += `<div class="image-item"><h4>遮罩图片</h4><img src="${maskImage.img.src}" alt="遮罩"></div>`;
}
imageWrapper.innerHTML = html;
}
function checkReady() {
generateBtn.disabled = !(originalImage && maskImage);
}
generateBtn.addEventListener('click', async () => {
if (!originalImage || !maskImage) return;
generateBtn.disabled = true;
generateBtn.textContent = '⏳ 生成中...';
const startTime = performance.now();
resultArea.innerHTML = '<div class="loading"><div class="spinner"></div><br>正在调用 ComfyUI 生成,请耐心等待...<div id="liveTimer" style="margin-top:8px;font-size:15px;color:#aaa">已用时 0.0s</div></div>';
const liveTimer = document.getElementById('liveTimer');
const timerId = setInterval(() => {
if (liveTimer) liveTimer.textContent = '已用时 ' + ((performance.now() - startTime) / 1000).toFixed(1) + 's';
}, 100);
try {
const formData = new FormData();
formData.append('image', originalImage.file, 'original.' + originalImage.file.name.split('.').pop());
formData.append('mask', maskImage.file, 'mask.' + maskImage.file.name.split('.').pop());
formData.append('prompt', promptInput.value);
const resp = await fetch('/api/generate', { method: 'POST', body: formData });
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || 'Generation failed');
}
const resultBlob = await resp.blob();
const resultUrl = URL.createObjectURL(resultBlob);
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
// 服务端纯推理耗时(若返回该响应头)
const serverTime = resp.headers.get('X-Generate-Time');
const serverInfo = serverTime ? `,服务端推理 ${parseFloat(serverTime).toFixed(1)}s` : '';
resultArea.innerHTML = `
<div style="text-align:center;margin-bottom:12px;color:#4ade80;font-size:16px;font-weight:600">
⏱️ 本次重绘耗时 ${elapsed}s${serverInfo}
</div>
<div class="result-area">
<div class="result-item">
<h3>原图</h3>
<img src="${originalImage.img.src}" alt="原图">
</div>
<div class="result-item">
<h3>生成结果</h3>
<img src="${resultUrl}" alt="生成结果">
</div>
</div>
`;
} catch (err) {
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
resultArea.innerHTML = `<div class="error">❌ ${err.message}<br><span style="color:#aaa;font-size:13px">(耗时 ${elapsed}s</span></div>`;
} finally {
clearInterval(timerId);
generateBtn.disabled = false;
generateBtn.textContent = '🚀 生成';
}
});
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# 启动发型补全服务
cd "$(dirname "$0")"
PID_FILE="hair_service.pid"
LOG_FILE="hair_service.log"
# 检查是否已在运行
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
echo "服务已在运行 (PID: $PID)"
exit 0
else
echo "清理无效的 PID 文件..."
rm "$PID_FILE"
fi
fi
# 检查 ComfyUI 是否运行
if ! curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8188/ >/dev/null 2>&1; then
echo "警告: ComfyUI 未运行 (http://127.0.0.1:8188)"
echo "请先启动 ComfyUI: python /home/ubuntu/ComfyUI/main.py --listen"
fi
# 启动服务
echo "启动发型补全服务..."
/home/ubuntu/ComfyUI/venv/bin/python app.py >> "$LOG_FILE" 2>&1 &
PID=$!
echo "$PID" > "$PID_FILE"
# 等待启动
sleep 2
if curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8899/ | grep -q "200"; then
echo "服务启动成功!"
echo "本机: http://127.0.0.1:8899"
# 打印局域网 IP,方便其他机器访问(app.py 已绑定 0.0.0.0
LAN_IPS=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -v '^$' || true)
if [ -n "$LAN_IPS" ]; then
echo "外网/局域网访问:"
for ip in $LAN_IPS; do
echo " http://${ip}:8899"
done
fi
echo "PID: $PID"
echo "日志: $LOG_FILE"
else
echo "服务启动失败,请检查日志: $LOG_FILE"
rm "$PID_FILE"
exit 1
fi
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# 停止发型补全服务
cd "$(dirname "$0")"
PID_FILE="hair_service.pid"
if [ ! -f "$PID_FILE" ]; then
echo "服务未运行"
exit 0
fi
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
echo "正在停止服务 (PID: $PID)..."
kill "$PID"
# 等待进程退出
for i in {1..10}; do
if ! kill -0 "$PID" 2>/dev/null; then
echo "服务已停止"
rm "$PID_FILE"
exit 0
fi
sleep 1
done
# 强制终止
echo "强制终止进程..."
kill -9 "$PID"
rm "$PID_FILE"
echo "服务已停止"
else
echo "进程已不存在,清理 PID 文件..."
rm "$PID_FILE"
fi
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Test script: send image+mask to the local service and save result."""
import requests
import sys
import os
SERVICE_URL = "http://127.0.0.1:8899"
IMAGE_PATH = "/home/ubuntu/hair/local_test/用来重绘.jpg"
MASK_PATH = "/home/ubuntu/hair/local_test/用来重绘.png"
OUTPUT_DIR = "/home/ubuntu/hair/local_test/output"
os.makedirs(OUTPUT_DIR, exist_ok=True)
print(f"Sending image: {IMAGE_PATH}")
print(f"Sending mask: {MASK_PATH}")
with open(IMAGE_PATH, "rb") as f:
img_data = f.read()
with open(MASK_PATH, "rb") as f:
mask_data = f.read()
resp = requests.post(
f"{SERVICE_URL}/api/generate",
files={
"image": ("original.jpg", img_data, "image/jpeg"),
"mask": ("mask.png", mask_data, "image/png"),
},
data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"},
timeout=600,
)
print(f"Status: {resp.status_code}")
print(f"Content-Type: {resp.headers.get('Content-Type')}")
if resp.status_code == 200 and "image" in resp.headers.get("Content-Type", ""):
out_path = os.path.join(OUTPUT_DIR, "result.png")
with open(out_path, "wb") as f:
f.write(resp.content)
print(f"SUCCESS! Result saved to: {out_path}")
else:
print(f"FAILED! Response: {resp.text[:2000]}")
sys.exit(1)
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

-156
View File
@@ -1,156 +0,0 @@
"""批量调用接口12/api/v1/hairline/grow_v2)生成对比素材。
20 张女生照片 × 5 种发际线发型(仅非高清)= 100 张输出。
并发 2,失败的跳过并记录原因。结果图落盘到 static/report_hairline_v2/img/
元数据落盘 static/report_hairline_v2/results.json,供生成报告用。
用法: python scripts/batch_grow_v2.py
"""
import base64
import json
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import httpx
API = "http://127.0.0.1:8187/api/v1/hairline/grow_v2"
TOKEN = "dev-shared-secret-2026"
CONCURRENCY = 2
INPUT_DIR = "/home/xsl/hair/image/test"
OUT_DIR = "/home/xsl/hair/static/report_hairline_v2"
IMG_DIR = os.path.join(OUT_DIR, "img")
ORIG_DIR = os.path.join(OUT_DIR, "orig")
# 5 种发际线发型(= change_hair hair_id
HAIRSTYLES = [
("chang_zhixian", "直线"),
("chang_tuoyuan", "椭圆"),
("chang_bolang", "波浪"),
("chang_xinxing", "心形"),
("chang_huaban", "花瓣"),
]
HR_OPTIONS = [(False, "nohr")] # 仅非高清
def list_inputs():
files = sorted(f for f in os.listdir(INPUT_DIR) if f.lower().endswith((".jpg", ".png")))
return files
def one_call(stem, face_file, hair_id, hair_cn, is_hr, hr_tag):
"""调用一次接口,落盘结果图。返回结果 dict。"""
src = os.path.join(INPUT_DIR, face_file)
out_name = f"{stem}__{hair_id}__{hr_tag}.jpg"
out_path = os.path.join(IMG_DIR, out_name)
# 断点续跑:已存在的图直接跳过,不重复调用
if os.path.exists(out_path) and os.path.getsize(out_path) > 1024:
return {
"stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn,
"is_hr": is_hr, "hr_tag": hr_tag, "ok": True,
"out": f"img/{out_name}", "size": None,
"ms": 0, "error": None, "skipped": True,
}
t0 = time.time()
try:
with open(src, "rb") as fh:
files = {"image_file": (face_file, fh.read(), "image/jpeg")}
data = {"hairline_id": hair_id, "is_hr": str(is_hr).lower()}
with httpx.Client(timeout=180.0) as c:
resp = c.post(API, headers={"X-Internal-Token": TOKEN}, files=files, data=data)
j = resp.json()
if j.get("code") != 0 or not j.get("data"):
raise RuntimeError(f"code={j.get('code')} msg={j.get('message')}")
b64 = j["data"]["final_base64"].split(",", 1)[1]
raw = base64.b64decode(b64)
with open(out_path, "wb") as fh:
fh.write(raw)
return {
"stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn,
"is_hr": is_hr, "hr_tag": hr_tag, "ok": True,
"out": f"img/{out_name}", "size": j["data"].get("image_size"),
"ms": int((time.time() - t0) * 1000), "error": None,
}
except Exception as ex: # noqa: BLE001
return {
"stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn,
"is_hr": is_hr, "hr_tag": hr_tag, "ok": False,
"out": None, "size": None, "ms": int((time.time() - t0) * 1000),
"error": str(ex)[:200],
}
def main():
os.makedirs(IMG_DIR, exist_ok=True)
os.makedirs(ORIG_DIR, exist_ok=True)
faces = list_inputs()
print(f"输入 {len(faces)} 张脸 × {len(HAIRSTYLES)} 发型 × {len(HR_OPTIONS)} = "
f"{len(faces)*len(HAIRSTYLES)*len(HR_OPTIONS)} 次调用,并发 {CONCURRENCY}")
# 1. 先把原图拷一份到 orig/(报告要用)
import shutil
for f in faces:
stem = os.path.splitext(f)[0]
dst = os.path.join(ORIG_DIR, f"{stem}.jpg")
if not os.path.exists(dst):
shutil.copy2(os.path.join(INPUT_DIR, f), dst)
# 2. 构造全部任务
tasks = []
for f in faces:
stem = os.path.splitext(f)[0]
for hair_id, hair_cn in HAIRSTYLES:
for is_hr, hr_tag in HR_OPTIONS:
tasks.append((stem, f, hair_id, hair_cn, is_hr, hr_tag))
results = []
done = 0
total = len(tasks)
t_start = time.time()
with ThreadPoolExecutor(max_workers=CONCURRENCY) as ex:
futs = {ex.submit(one_call, *t): t for t in tasks}
for fut in as_completed(futs):
r = fut.result()
results.append(r)
done += 1
status = "OK " if r["ok"] else "FAIL"
if r.get("skipped"):
print(f"[{done}/{total}] SKIP {r['stem']} {r['hair_cn']} {r['hr_tag']}")
elif r["ok"]:
print(f"[{done}/{total}] {status} {r['stem']} {r['hair_cn']} {r['hr_tag']} "
f"({r['ms']}ms)", flush=True)
else:
print(f"[{done}/{total}] {status} {r['stem']} {r['hair_cn']} {r['hr_tag']} "
f"-> {r['error']}")
elapsed = time.time() - t_start
ok = sum(1 for r in results if r["ok"])
fail = len(results) - ok
# 按稳定顺序排序,报告好看
order = {s: i for i, s in enumerate(HAIRSTYLES)}
hr_order = {True: 0, False: 1}
face_order = {os.path.splitext(f)[0]: i for i, f in enumerate(faces)}
results.sort(key=lambda r: (face_order.get(r["stem"], 0),
order.get((r["hair_id"], r["hair_cn"]), 0),
hr_order.get(r["is_hr"], 0)))
meta = {
"total": total, "ok": ok, "fail": fail,
"elapsed_sec": round(elapsed, 1), "concurrency": CONCURRENCY,
"hairstyles": [{"id": h, "cn": c} for h, c in HAIRSTYLES],
"hr_options": [{"is_hr": is_hr, "tag": tag} for is_hr, tag in HR_OPTIONS],
"faces": [os.path.splitext(f)[0] for f in faces],
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
out = {"meta": meta, "results": results}
with open(os.path.join(OUT_DIR, "results.json"), "w", encoding="utf-8") as fh:
json.dump(out, fh, ensure_ascii=False, indent=2)
print(f"\n完成:{ok}/{total} 成功,{fail} 失败,耗时 {elapsed:.1f}s")
print(f"结果图 -> {IMG_DIR}")
print(f"元数据 -> {OUT_DIR}/results.json")
if __name__ == "__main__":
main()
-220
View File
@@ -1,220 +0,0 @@
"""根据 results.json 生成发际线生发对比报告 HTML。
报告布局(原图 vs 结果对比):
- 顶部:总览统计(成功/失败数、耗时、参数)
- 按脸分组,每张脸一个区块:
- 左:原图
- 右:5 发型 × {高清, 非高清} 网格(共 10 张),失败的格子标注原因
- 失败用例汇总表
输出:static/report_hairline_v2/index.html
"""
import html
import json
import os
from urllib.parse import quote
OUT_DIR = "/home/xsl/hair/static/report_hairline_v2"
RESULTS = os.path.join(OUT_DIR, "results.json")
TARGET = os.path.join(OUT_DIR, "index.html")
def url(path):
"""把相对路径里的中文做 URL 编码,分隔符 '/' 保留。
Starlette StaticFiles 对未编码中文路径返回 400,编码后所有浏览器稳定可加载。
"""
return "/".join(quote(seg) for seg in path.split("/"))
def main():
with open(RESULTS, encoding="utf-8") as fh:
data = json.load(fh)
meta = data["meta"]
results = data["results"]
hairstyles = meta["hairstyles"] # [{id, cn}]
faces = meta["faces"] # [stem, ...]
hr_opts = meta["hr_options"] # [{is_hr, tag}]
# 索引:(stem, hair_id, hr_tag) -> result
idx = {}
for r in results:
idx[(r["stem"], r["hair_id"], r["hr_tag"])] = r
# 统计
ok = sum(1 for r in results if r["ok"])
fail = len(results) - ok
parts = []
parts.append(f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>发际线生发对比报告 · 接口12 grow_v2</title>
<style>
:root {{
--bg:#0f1115; --card:#1a1d24; --border:#2a2f3a; --txt:#e6e6e6;
--muted:#8a93a3; --accent:#6ea8fe; --ok:#4ade80; --fail:#f87171;
}}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:var(--bg); color:var(--txt);
font-family:-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
line-height:1.5; }}
header {{ padding:24px 32px; border-bottom:1px solid var(--border); }}
header h1 {{ margin:0 0 8px; font-size:22px; }}
header .sub {{ color:var(--muted); font-size:14px; }}
.stats {{ display:flex; gap:16px; flex-wrap:wrap; margin-top:16px; }}
.stat {{ background:var(--card); border:1px solid var(--border); border-radius:8px;
padding:12px 16px; min-width:120px; }}
.stat .n {{ font-size:24px; font-weight:600; }}
.stat .l {{ font-size:12px; color:var(--muted); }}
.stat.ok .n {{ color:var(--ok); }}
.stat.fail .n {{ color:var(--fail); }}
main {{ padding:24px 32px; }}
.face-block {{ background:var(--card); border:1px solid var(--border);
border-radius:12px; padding:20px; margin-bottom:24px; }}
.face-head {{ display:flex; align-items:center; gap:12px; margin-bottom:16px; }}
.face-head h2 {{ margin:0; font-size:18px; }}
.face-head .orig-thumb {{ width:64px; height:64px; object-fit:cover;
border-radius:8px; border:1px solid var(--border); }}
.grid {{ display:grid; grid-template-columns:repeat({len(hairstyles)+1}, 1fr);
gap:10px; align-items:start; }}
.col-hdr {{ font-size:12px; color:var(--muted); text-align:center; padding:6px 4px;
border-bottom:1px solid var(--border); }}
.col-hdr.orig {{ color:var(--accent); }}
.cell {{ position:relative; }}
.cell img {{ width:100%; border-radius:6px; display:block;
border:1px solid var(--border); }}
.cell .cap {{ font-size:11px; color:var(--muted); margin-top:4px; text-align:center; }}
.cell.fail .failbox {{ aspect-ratio:3/4; background:#2a1518; border:1px solid #5c2a30;
border-radius:6px; display:flex; align-items:center;
justify-content:center; padding:8px; text-align:center;
font-size:11px; color:var(--fail); }}
.tag {{ display:inline-block; font-size:11px; padding:1px 6px; border-radius:4px;
background:#243044; color:var(--accent); margin-left:6px; }}
.tag.no {{ background:#3a3526; color:#e0c97a; }}
.legend {{ font-size:13px; color:var(--muted); margin-bottom:16px; }}
.fail-table {{ width:100%; border-collapse:collapse; font-size:13px; margin-top:8px; }}
.fail-table th, .fail-table td {{ border:1px solid var(--border); padding:6px 10px; text-align:left; }}
.fail-table th {{ background:#1f232c; color:var(--muted); }}
.anchor {{ display:block; height:0; overflow:hidden; }}
footer {{ padding:24px 32px; color:var(--muted); font-size:12px; border-top:1px solid var(--border); }}
/* 点击放大 */
img.zoomable {{ cursor:zoom-in; transition:opacity .12s; }}
img.zoomable:hover {{ opacity:.85; }}
#lightbox {{ position:fixed; inset:0; background:rgba(0,0,0,.92); display:none;
align-items:center; justify-content:center; z-index:9999; padding:24px;
cursor:zoom-out; }}
#lightbox.open {{ display:flex; }}
#lightbox img {{ max-width:100%; max-height:100%; object-fit:contain;
border-radius:8px; box-shadow:0 8px 40px rgba(0,0,0,.6); }}
#lightbox .lb-cap {{ position:absolute; bottom:16px; left:0; right:0; text-align:center;
color:var(--muted); font-size:13px; }}
</style>
</head>
<body>
<header>
<h1>发际线生发对比报告 <span class="tag">接口12 · grow_v2</span></h1>
<div class="sub">固定:pushed 遮罩 + multiband 融合 · mb_levels=5 · erode_cm=0.6 · 非高清</div>
<div class="sub">生成时间:{html.escape(meta['generated_at'])} · 并发 {meta['concurrency']} · 耗时 {meta['elapsed_sec']}s</div>
<div class="stats">
<div class="stat"><div class="n">{meta['total']}</div><div class="l">总调用</div></div>
<div class="stat ok"><div class="n">{ok}</div><div class="l">成功</div></div>
<div class="stat fail"><div class="n">{fail}</div><div class="l">失败</div></div>
<div class="stat"><div class="n">{len(faces)}</div><div class="l">人脸数</div></div>
<div class="stat"><div class="n">{len(hairstyles)}</div><div class="l">发型数</div></div>
</div>
</header>
<main>
""")
# 图例
parts.append('<div class="legend">每张脸:第一列为原图,其余 5 列为发际线生发结果(非高清)。点击任意图片可放大。</div>')
# 每张脸一个区块:原图 + 5 发型横向并排
for stem in faces:
orig_rel = f"orig/{stem}.jpg"
parts.append('<div class="face-block">')
parts.append(f' <div class="face-head"><h2>{html.escape(stem)}</h2></div>')
# 网格:第一列原图,其余 5 列发型结果
parts.append('<div class="grid">')
# 表头
parts.append('<div class="col-hdr orig">原图</div>')
for h in hairstyles:
parts.append(f'<div class="col-hdr">{html.escape(h["cn"])}<br><span style="opacity:.6">{html.escape(h["id"])}</span></div>')
# 第一列:原图大图(可点击放大)
parts.append(
f'<div class="cell"><img class="zoomable" src="{url(orig_rel)}" '
f'data-full="{url(orig_rel)}" loading="lazy" alt="原图 {html.escape(stem)}">'
f'<div class="cap">原图</div></div>')
# 5 发型结果(hr_opts 现在只有 nohr 一档,直接取)
hr_tag = hr_opts[0]["tag"] if hr_opts else "nohr"
for h in hairstyles:
r = idx.get((stem, h["id"], hr_tag))
if r and r["ok"]:
ms = r["ms"]
parts.append(
f'<div class="cell"><img class="zoomable" src="{url(r["out"])}" '
f'data-full="{url(r["out"])}" loading="lazy" '
f'alt="{html.escape(stem)} {html.escape(h["cn"])}">'
f'<div class="cap">{ms}ms</div></div>')
else:
err = (r or {}).get("error", "未执行")
parts.append(
f'<div class="cell fail"><div class="failbox">{html.escape(err)}</div></div>')
parts.append('</div>') # grid
parts.append('</div>') # face-block
# 失败汇总
fails = [r for r in results if not r["ok"]]
if fails:
parts.append('<div class="face-block">')
parts.append(f'<h2>失败用例({len(fails)}</h2>')
parts.append('<table class="fail-table"><thead><tr>'
'<th>人脸</th><th>发型</th><th>耗时(ms)</th><th>原因</th>'
'</tr></thead><tbody>')
for r in fails:
parts.append(
f'<tr><td>{html.escape(r["stem"])}</td>'
f'<td>{html.escape(r["hair_cn"])}</td>'
f'<td>{r["ms"]}</td>'
f'<td>{html.escape(r["error"] or "")}</td></tr>')
parts.append('</tbody></table></div>')
parts.append(f"""
<footer>
接口:<code>POST /api/v1/hairline/grow_v2</code> · 固定 pushed 遮罩 + multiband 融合 ·
数据源 results.json · 点击任意图片可放大查看
</footer>
</main>
<div id="lightbox"><img><div class="lb-cap"></div></div>
<script>
(function(){{
var lb=document.getElementById('lightbox'),lbImg=lb.querySelector('img'),
lbCap=lb.querySelector('.lb-cap');
function open(src,cap){{
lbImg.src=src; lbCap.textContent=cap||''; lb.classList.add('open');
}}
function close(){{ lb.classList.remove('open'); lbImg.src=''; }}
document.addEventListener('click',function(e){{
var t=e.target.closest('img.zoomable');
if(t){{ open(t.dataset.full||t.src, t.alt||''); }}
else if(e.target===lb||e.target===lbImg){{ close(); }}
}});
document.addEventListener('keydown',function(e){{
if(e.key==='Escape') close();
}});
}})();
</script>
</body>
</html>""")
with open(TARGET, "w", encoding="utf-8") as fh:
fh.write("".join(parts))
print(f"报告已生成:{TARGET}")
print(f"成功 {ok}/{meta['total']},失败 {fail}")
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
// 上传图片自动降采样:当总像素 > MAX_PIXELS 时,等比例缩小到 <= TARGET_PIXELS。
// 用法:在提交前 file = await window.downscaleImageFile(file);
(function () {
var MAX_PIXELS = 1536000; // 触发阈值:超过则缩小
var TARGET_PIXELS = 786432; // 缩小后总像素上限
function loadImage(url) {
return new Promise(function (resolve, reject) {
var img = new Image();
img.onload = function () { resolve(img); };
img.onerror = reject;
img.src = url;
});
}
async function downscaleImageFile(file) {
// 非图片、GIF、SVG 不处理,原样返回
if (!file || !file.type || file.type.indexOf('image/') !== 0) return file;
if (file.type === 'image/gif' || file.type === 'image/svg+xml') return file;
var url = URL.createObjectURL(file);
try {
var img = await loadImage(url);
var w = img.naturalWidth, h = img.naturalHeight;
var total = w * h;
if (!total || total <= MAX_PIXELS) return file; // 未超阈值,原图直接用
var scale = Math.sqrt(TARGET_PIXELS / total);
var nw = Math.max(1, Math.round(w * scale));
var nh = Math.max(1, Math.round(h * scale));
var canvas = document.createElement('canvas');
canvas.width = nw;
canvas.height = nh;
var ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, nw, nh);
var outType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
var quality = outType === 'image/jpeg' ? 0.92 : undefined;
var blob = await new Promise(function (res) { canvas.toBlob(res, outType, quality); });
if (!blob) return file;
var base = (file.name || 'image').replace(/\.(jpe?g|png|webp|bmp)$/i, '');
var name = base + (outType === 'image/png' ? '.png' : '.jpg');
return new File([blob], name, { type: outType, lastModified: Date.now() });
} catch (e) {
console.warn('downscaleImageFile 失败,使用原图', e);
return file;
} finally {
URL.revokeObjectURL(url);
}
}
window.downscaleImageFile = downscaleImageFile;
/**
* 将接口返回的图片字段规范成 <img src> 可用值。
* 同时兼容:
* - 网关改写后的 *_urlhttp/https/相对路径)
* - worker 直连的 *_base64(原始 base64,或已带 data:image/...;base64, 前缀)
* @param {string|null|undefined} url
* @param {string|null|undefined} b64
* @param {string} [mimeHint='image/jpeg'] 原始 base64 时的 MIME 提示(会按魔数再嗅探)
* @returns {string}
*/
function resolveImgSrc(url, b64, mimeHint) {
var v = url || b64 || '';
if (!v || typeof v !== 'string') return '';
v = v.trim();
if (!v) return '';
// 已是可用 URL / data URI / blob / 协议相对 URL
if (/^(https?:|blob:|data:|\/\/)/i.test(v)) return v;
// 同站相对路径(仅匹配明确的 /static/...;勿把 JPEG base64 的 /9j/ 前缀当路径)
if (/^\/static\//.test(v)) return v;
// 原始 base64:去掉可能残留的 data URI 前缀后再拼,避免双重前缀导致无法显示
var raw = v.replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/i, '');
if (!raw) return '';
var mime = mimeHint || 'image/jpeg';
if (raw.indexOf('iVBOR') === 0) mime = 'image/png';
else if (raw.indexOf('/9j/') === 0) mime = 'image/jpeg';
else if (raw.indexOf('R0lGOD') === 0) mime = 'image/gif';
else if (raw.indexOf('UklGR') === 0) mime = 'image/webp';
return 'data:' + mime + ';base64,' + raw;
}
window.resolveImgSrc = resolveImgSrc;
})();
+21 -43
View File
@@ -57,7 +57,6 @@
<a href="#if4">接口4</a>
<a href="#if5">接口5</a>
<a href="#if6">接口6</a>
<a href="#if7">接口7</a>
<a href="#errors">错误码</a>
<a href="#test">在线测试</a>
</div>
@@ -109,6 +108,8 @@
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割,可信度高)/ <code>"estimated"</code>(比例估算,可信度低)</td></tr>
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度),接近 0 表示正面照</td></tr>
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
</table>
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端把标注图叠加到原图上即可呈现测量效果(标注图白色线条 #FFFFFF,透明底)。</p>
@@ -133,7 +134,7 @@ const { code, data } = await res.json();
<div class="card" id="if2">
<h2>2. C端生发 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/hair/grow</code></h2>
<div class="card-body">
<p class="desc">上传正面照 + 性别 + 发型序号 → 返回指定发际线类型的预览图与生发图。</p>
<p class="desc">上传正面照 + 性别 + 发型序号 → 返回指定发际线类型的发际线曲线透明 PNG + 生发图。</p>
<p><strong>入参</strong></p>
<table>
@@ -189,17 +190,17 @@ const { code, data } = await res.json();
<div class="card" id="if4">
<h2>4. 用户特征分析 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/face/features</code></h2>
<div class="card-body">
<p class="desc">上传照片 → 火山方舟豆包视觉模型分析 → 返回几十项面部特征(脸型/眉形/肤色/四季色彩…)。</p>
<p class="desc">上传照片 → 火山方舟豆包视觉模型分析 → 返回固定 6 项面部特征(脸型/眉形/面部年龄/动静类型/性别/基因风格)。</p>
<p><strong>入参</strong>image_file / image_url / image_base64 三选一。无其他参数。</p>
<p style="margin-top:12px"><strong>data 字段</strong></p>
<table>
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
<tr><td><code>features</code></td><td>string</td><td><strong>JSON 字符串</strong>(不是对象!客户端需 <code>JSON.parse()</code></td></tr>
<tr><td><code>features</code></td><td>string</td><td><strong>JSON 字符串</strong>(不是对象!客户端需 <code>JSON.parse()</code>。解析后得到<strong>固定 6 个英文字段</strong></td></tr>
</table>
<p style="margin-top:8px"><strong>features 英文优先字段</strong>其余中文字段同时返回,共~42个):</p>
<p style="margin-top:8px"><strong>features 字段</strong>固定返回 6 个):</p>
<table>
<tr><th>字段</th><th>说明</th><th>字段</th><th>说明</th></tr>
<tr><td>face_shape</td><td>脸型</td><td>eyebrow_shape</td><td>眉形</td></tr>
@@ -214,7 +215,7 @@ const { code, data } = await res.json();
const { code, data } = await res.json();
const features = JSON.parse(data.features); // ← 注意:data.features 是字符串!
console.log(features.face_shape); // "鹅蛋脸"
console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保留)</pre>
console.log(features.gene_style); // "自然型"</pre>
</div>
</div>
@@ -230,13 +231,16 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
<tr><td>image_file / image_url / image_base64</td><td></td><td>三选一</td><td>用户正面照</td></tr>
<tr><td>gender</td><td>string</td><td>✅ 必填</td><td><code>"male"</code> / <code>"female"</code></td></tr>
<tr><td>hair_style</td><td>string</td><td>✅ 必填</td><td>发型序号,逗号分隔多选(如 <code>1,2,3</code>)。缺失/越界返回 1007</td></tr>
<tr><td>generate_grow_image</td><td>bool</td><td></td><td>是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 <code>true</code>。传 <code>false</code> 时跳过生发,各发型 <code>grown_image_url</code> 恒为 <code>null</code>,仅返回三档发际线叠图与中心点,大幅降低耗时</td></tr>
</table>
<p style="margin-top:12px"><strong>data 字段</strong></p>
<table>
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
<tr><td><code>hairline_images[]</code></td><td>object[]</td><td>选中发型列表,每项含 <code>hairline_type</code><code>image_middle_url</code>/<code>image_high_url</code>/<code>image_low_url</code> 三档<strong>透明 PNG 叠图</strong>(仅曲线,需叠加原图)、<code>grown_image_url</code> 生发图(完整人像,失败为 null)、<code>order</code></td></tr>
<tr><td><code>best_hairline_center_point</code></td><td>object</td><td>首个选中发型 middle 档发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr>
<tr><td><code>hairline_images[]</code></td><td>object[]</td><td>选中发型列表,每项含 <code>hairline_type</code><code>image_middle_url</code>/<code>image_high_url</code>/<code>image_low_url</code> 三档<strong>透明 PNG 叠图</strong>(仅曲线,需叠加原图)、<code>grown_image_url</code> 生发图(完整人像,失败<code>generate_grow_image=false</code>为 null)、<code>order</code></td></tr>
<tr><td><code>best_hairline_center_point</code></td><td>object \| null</td><td>首个选中发型 <strong>middle 档</strong>发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr>
<tr><td><code>high_hairline_center_point</code></td><td>object \| null</td><td>同上,<strong>high 档</strong>发际线中点(发际线偏高,y 更小)</td></tr>
<tr><td><code>low_hairline_center_point</code></td><td>object \| null</td><td>同上,<strong>low 档</strong>发际线中点(发际线偏低,y 更大)</td></tr>
<tr><td><code>face_measure</code></td><td>object \| null</td><td>复用<a href="#if1">接口1</a><strong>四庭七眼测量数值</strong>(不含标注图)。独立流程,测量失败时为 <code>null</code>,不影响发际线主结果。结构见下表</td></tr>
</table>
@@ -249,6 +253,8 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割)/ <code>"estimated"</code>(比例估算)</td></tr>
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度)</td></tr>
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
</table>
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端无需额外请求接口1 即可拿到四庭七眼测量数值;<code>face_measure</code><code>null</code> 时(角度过大/无人脸等)仅隐藏测量区块,发际线结果照常展示。</p>
@@ -278,43 +284,14 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
<tr><td><code>annotated_image_url</code></td><td>string</td><td>标注 PNG URL(仅标注图层,透明底)</td></tr>
<tr><td><code>face_total_height_cm</code></td><td>number</td><td>面部总高度(cm= 三庭之和</td></tr>
<tr><td><code>four_courts</code></td><td>object</td><td>三庭:upper/middle/lower,各含 _cm 和 ratios<strong>无 top_court</strong></td></tr>
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:eye_width/face_width/inter_eye_distance</td></tr>
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <strong><code>eye2</code>~<code>eye6</code></strong>(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段宽度 cm;<strong>无 eye1/eye7</strong></td></tr>
<tr><td><code>landmarks</code></td><td>object</td><td>4 个关键点:hairline/brow_center/nose_bottom/chin_tip<strong>无 hair_top</strong></td></tr>
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
</table>
</div>
</div>
<!-- ======== 接口7 ======== -->
<div class="card" id="if7">
<h2>7. C端生发 v2 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/hair/grow-v2</code> &nbsp;<span class="badge warn">v2</span></h2>
<div class="card-body">
<p class="desc">功能与<a href="#if2">接口2</a>完全一致,仅 ComfyUI 工作流不同——使用 <code>add_hair2.json</code> 替代 <code>add_hair.json</code>Flux-2 Klein 9b)。</p>
<p><strong>入参</strong></p>
<table>
<tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>
<tr><td>image_file / image_url / image_base64</td><td></td><td>三选一</td><td>用户正面照</td></tr>
<tr><td>gender</td><td>string</td><td>✅ 必填</td><td><code>"male"</code> / <code>"female"</code></td></tr>
<tr><td>hair_style</td><td>int</td><td>✅ 必填</td><td>发型序号。female: 1~5male: 1~4</td></tr>
</table>
<p style="margin-top:12px"><strong>data.results[] 元素</strong>(同接口2</p>
<table>
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
<tr><td><code>image_url</code></td><td>string</td><td>发际线叠加预览图</td></tr>
<tr><td><code>grown_image_url</code></td><td>string</td><td>生发后效果图 ⚠ 可空</td></tr>
<tr><td><code>hairline_type</code></td><td>string</td><td>发际线类型 key</td></tr>
<tr><td><code>order</code></td><td>int</td><td>排序</td></tr>
</table>
<p style="margin-top:8px;font-size:12px;color:#64748b">
Female 5 种:ellipse/flower/heart/straight/wave &nbsp;|&nbsp;
Male 4 种:ellipse/m/straight/inverse_arc<br>
⚠ 工作流: add_hair2.jsonFlux-2 Klein 9b),输入节点 26,输出节点 75。
</p>
</div>
</div>
<!-- ======== 错误码 ======== -->
<div class="card" id="errors">
<h2>⚠ 错误码</h2>
@@ -324,12 +301,14 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
<tr><td>1001</td><td>无法识别人像</td><td>未检测到人脸</td></tr>
<tr><td>1003</td><td>角度问题,非正面照</td><td>非正面 / 角度过大</td></tr>
<tr><td>1004</td><td>gender 必填且只能为 male / female</td><td>接口2/5 的 <code>gender</code> 缺失或非法</td></tr>
<tr><td>1005</td><td>检测到多张人脸</td><td>仅支持单人</td></tr>
<tr><td>1007</td><td>图片参数错误 / 后端不可用</td><td>参数传错 / 服务繁忙请稍后重试</td></tr>
<tr><td>1008</td><td>图片格式不支持</td><td>非 JPG/PNG / base64 解码失败</td></tr>
<tr><td>1009</td><td>未授权</td><td>缺少或错误的 <code>X-Internal-Token</code><code>/api/*</code> 路径鉴权)</td></tr>
</table>
<p style="font-size:12px;color:#94a3b8;margin-top:8px">1004 已废弃(接口2 不再自动判性别,改由客户端传 gender 参数)</p>
<p style="font-size:12px;color:#94a3b8;margin-top:8px">注:1004 仍在使用(接口2/5 的 gender 校验);接口7(grow-v2)已弃用,请改用接口2</p>
</div>
</div>
@@ -342,10 +321,9 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
<tr><td>1. 四庭七眼</td><td><a href="/static/test_interface1.html" class="link">/static/test_interface1.html</a></td><td>上传照片 → 原图+标注叠加,底图/标注开关,指标卡片</td></tr>
<tr><td>2. C端生发</td><td><a href="/static/test_interface2.html" class="link">/static/test_interface2.html</a></td><td>上传+性别 → 方案一覧(原图/叠加/生发),双图对比</td></tr>
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 42项面部特征表格 + 原始JSON</td></tr>
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 6项面部特征 + 原始JSON</td></tr>
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
<tr><td>6. 四庭七眼 v2</td><td><a href="/static/test_interface6.html" class="link">/static/test_interface6.html</a></td><td>同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线</td></tr>
<tr><td>7. C端生发 v2</td><td><a href="/static/test_interface7.html" class="link">/static/test_interface7.html</a></td><td>同接口2,使用 add_hair2.json 工作流(Flux-2 Klein 9b</td></tr>
</table>
<p style="font-size:12px;color:#94a3b8;margin-top:12px">
完整 API 文档:<a href="/docs" class="link">/docs</a>Swagger UI
+12 -3
View File
@@ -62,6 +62,7 @@
@media (max-width: 768px) { .results { flex-direction: column; } }
.hidden { display: none !important; }
</style>
<script src="/static/img_downscale.js?v=2"></script>
</head>
<body>
<div class="container">
@@ -127,8 +128,9 @@ function setStatus(text, type) {
async function submitTest() {
const fileInput = $('imageFile');
const file = fileInput.files[0];
let file = fileInput.files[0];
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
file = await window.downscaleImageFile(file);
const _reqStart = performance.now();
@@ -148,7 +150,7 @@ async function submitTest() {
form.append('image_file', file);
try {
const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', body: form });
const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
const json = await resp.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
@@ -157,7 +159,10 @@ async function submitTest() {
if (json.code === 0) {
setStatus('✅ 请求成功 (' + _elapsed + 's) — request_id: ' + json.request_id, 'success');
showOverlay(json.data.annotated_image_url);
// 兼容本地直连 worker*_base64)与网关(*_url
const _d = json.data || {};
const annoUrl = resolveImgSrc(_d.annotated_image_url, _d.annotated_image_base64, 'image/png');
showOverlay(annoUrl);
renderMetrics(json.data);
$('metricsBar').classList.remove('hidden');
} else {
@@ -182,6 +187,10 @@ function showOverlay(annoUrl) {
setTimeout(() => showOverlay(annoUrl), 200);
return;
}
if (!annoUrl) {
$('imgPanel').innerHTML = '<span class="placeholder">后端未返回标注图(annotated_image_base64 为空)</span>';
return;
}
const checked = $('showAnno').checked ? '' : 'display:none';
const opacity = ($('annoOpacity').value / 100).toFixed(2);
+4 -2
View File
@@ -50,6 +50,7 @@
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
.lightbox img { max-width: 95%; max-height: 95%; }
</style>
<script src="/static/img_downscale.js"></script>
</head>
<body>
<div class="container">
@@ -197,8 +198,9 @@ function renderMetrics(data) {
}
async function submitTest() {
const file = $('imageFile').files[0];
let file = $('imageFile').files[0];
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
file = await window.downscaleImageFile(file);
const t0 = performance.now();
const btn = $('submitBtn');
@@ -212,7 +214,7 @@ async function submitTest() {
form.append('dilate_cm', $('dilateCm').value || '2.0');
try {
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', body: form });
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
const json = await resp.json();
const dt = ((performance.now() - t0) / 1000).toFixed(2);
+3 -1
View File
@@ -55,6 +55,7 @@
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
.lightbox img { max-width: 95%; max-height: 95%; }
</style>
<script src="/static/img_downscale.js"></script>
</head>
<body>
<div class="container">
@@ -292,8 +293,9 @@ function renderResult(d) {
}
async function submitTest() {
const file = $('imageFile').files[0];
let file = $('imageFile').files[0];
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
file = await window.downscaleImageFile(file);
const t0 = performance.now();
const btn = $('submitBtn');
btn.disabled = true; btn.textContent = '⏳ 请求中...';
+159 -58
View File
@@ -34,8 +34,12 @@
.status.success { background: #d1fae5; color: #065f46; display: block; }
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; }
.step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; display:flex; align-items:center; gap:8px; }
.step .cap .badge { flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; min-width:24px; height:24px; padding:0 6px; border-radius:6px; background:#2563eb; color:#fff; font-size:13px; font-weight:700; }
.step .cap .ttext { flex:1; }
.step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; }
.step .desc { font-size: 12px; line-height: 1.7; color: #4b5563; padding: 10px 12px; background: #f9fafb; border-bottom: 1px solid #f0f0f0; }
.step .desc b { color:#1f2937; }
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
.step .noimg { padding: 30px; text-align: center; color: #ccc; font-size: 13px; }
.big img { max-height: 520px; object-fit: contain; }
@@ -49,13 +53,14 @@
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
.lightbox img { max-width: 95%; max-height: 95%; }
</style>
<script src="/static/img_downscale.js"></script>
</head>
<body>
<div class="container">
<h1>接口11 调试页 <span style="font-size:13px;color:#888">(带前后端日志)</span></h1>
<div class="subtitle">
独立调试页,每一步都记录日志。提交后展开"前端日志"和"后端日志",点按钮可下载。<br>
重点排查:遮罩是否走 pushed、①-f/①-g 是否有图、最终遮罩是否正确。遮罩固定 pushed、融合固定 multiband
重点排查:遮罩是否走 pushed、①-f/①-g 是否有图、最终遮罩是否正确。遮罩固定 pushed;默认 blend=two_stage、redraw 开、发际线波浪
</div>
<div class="card">
@@ -71,45 +76,74 @@
<div class="pf">
<label>发际线类型 ID</label>
<select id="hairlineId">
<option value="chang_bolang" selected>chang_bolang(波浪)</option>
<option value="chang_zhixian">chang_zhixian(直线)</option>
<option value="chang_tuoyuan">chang_tuoyuan(椭圆)</option>
<option value="chang_bolang">chang_bolang(波浪)</option>
<option value="chang_xinxing">chang_xinxing(心形)</option>
<option value="chang_huaban">chang_huaban(花瓣)</option>
</select>
</div>
<div class="pf">
<label>seg_model <span class="desc">分割模型</span></label>
<select id="segModel">
<option value="segformer">segformer</option>
<option value="bisenet">bisenet</option>
</select>
</div>
<div class="pf">
<label>erode_cm <span class="desc">baseline参考内缩(cm)</span></label>
<div class="row"><input type="number" id="erodeCm" min="0" max="5" step="0.1" value="0.6"></div>
</div>
<div class="pf">
<label>hairline_push_cm <span class="desc">发际线外推(cm)</span></label>
<div class="row"><input type="number" id="hairlinePushCm" min="0" max="3" step="0.1" value="1.0"></div>
</div>
<div class="pf">
<label>hairline_edge <span class="desc">发际线提取</span></label>
<select id="hairlineEdge">
<option value="column">column(逐列最低点)</option>
<option value="contour">contour(形态学轮廓)</option>
</select>
<div class="row"><input type="number" id="hairlinePushCm" min="0" max="3" step="0.1" value="0.8"></div>
</div>
<div class="pf">
<label>mb_levels <span class="desc">多频段金字塔层数</span></label>
<div class="row"><input type="number" id="mbLevels" min="2" max="6" step="1" value="5"></div>
</div>
<div class="pf">
<label>edge_erode_px <span class="desc">贴图前遮罩内缩(px)</span></label>
<label>blend_method <span class="desc">接缝融合方法</span></label>
<select id="blendMethod">
<option value="two_stage" selected>two_stage(泊松→多频段,大色差)</option>
<option value="multiband">multiband(多频段金字塔)</option>
<option value="seamless">seamless(泊松无缝克隆)</option>
<option value="feather">feather(高斯羽化)</option>
<option value="alpha_gradient">alpha_gradient(距离变换)</option>
</select>
</div>
<div class="pf">
<label>color_match <span class="desc">融合前颜色迁移(消除色差)</span></label>
<div class="row"><input type="checkbox" id="colorMatch" style="width:18px;height:18px"><span class="desc" id="cmNote">multiband/feather 生效;seamless/two_stage 自带调色</span></div>
</div>
<div class="pf">
<label>color_match_strength <span class="desc">颜色迁移强度(0~1)</span></label>
<div class="row"><input type="number" id="cmStrength" min="0" max="1" step="0.05" value="0.4"></div>
</div>
<!-- 多频带融合参数 -->
<div class="pf">
<label>edge_erode_px <span class="desc">融合·贴图前遮罩内缩(px)</span></label>
<div class="row"><input type="number" id="edgeErodePx" min="0" max="40" step="1" value="3"></div>
</div>
<div class="pf">
<label>mb_feather_px <span class="desc">融合·最细层掩码羽化(px)</span></label>
<div class="row"><input type="number" id="mbFeatherPx" min="0" max="5" step="1" value="1"></div>
</div>
<div class="pf">
<label>transition_band_px <span class="desc">融合·过渡带边距(-1=自动)</span></label>
<div class="row"><input type="number" id="transitionBandPx" min="-1" max="128" step="1" value="-1"></div>
</div>
<!-- change_hair 换发型参数 -->
<div class="pf">
<label>inpainting_fill <span class="desc">换发型·重绘填充(治染绿)</span></label>
<select id="inpaintingFill">
<option value="1">1=填充噪声(默认/原始)</option>
<option value="0">0=保留原图(治染绿)</option>
<option value="2">2=纯色填充</option>
<option value="3">3=潜变量噪声</option>
</select>
</div>
<div class="pf">
<label>mask_blur <span class="desc">换发型·重绘遮罩边缘模糊(px)</span></label>
<div class="row"><input type="number" id="maskBlur" min="0" max="64" step="1" value="11"></div>
</div>
<div class="pf">
<label>mask_dilate_scale <span class="desc">换发型·重绘遮罩膨胀缩放</span></label>
<div class="row"><input type="number" id="maskDilateScale" min="0" max="4" step="0.1" value="1.0"></div>
</div>
</div>
<div style="font-size:12px;color:#888;margin-top:8px">遮罩算法固定 pushed、融合固定 multiband,无需选择</div>
<div style="font-size:12px;color:#888;margin-top:8px">遮罩算法固定 pushed。默认 blend=two_stage;多频带融合参数与 change_hair 换发型参数已在上方可调,其余隐藏参数按原默认值随请求提交。<b>本接口不含重绘</b>,发际线带重绘见 <a href="/static/test_interface12.html">接口12 测试页</a></div>
</div>
<div class="status hidden" id="statusBar"></div>
@@ -118,8 +152,8 @@
<div class="card">
<h2 style="margin-top:0">🎯 最终结果</h2>
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr))">
<div class="step big"><div class="cap">输入原图</div><img id="finalInput"></div>
<div class="step big"><div class="cap">最终结果</div><img id="finalOut"></div>
<div class="step big"><div class="cap"><span class="badge" style="background:#9ca3af">输入</span><span class="ttext">输入原图</span></div><img id="finalInput"></div>
<div class="step big"><div class="cap"><span class="badge"></span><span class="ttext">最终结果<small>接口11 最终输出 = ⑩接缝融合结果</small></span></div><img id="finalOut"></div>
</div>
</div>
@@ -163,19 +197,55 @@ function flog(msg, level) {
function clearLog() { FE_LOGS.length = 0; document.getElementById('logPanel').innerHTML = ''; }
const STEPS = [
{ key: 'baseline_overlay', title: '①-a 发际线分割线', sub: '黄线=baseline' },
{ key: 'upper_overlay', title: '①-b 上半区', sub: '青=baseline以上' },
{ key: 'hair_seg_overlay', title: '①-c 头发分割', sub: '绿=头发像素' },
{ key: 'top_fill_overlay', title: '①-d 填充到基线', sub: '蓝=top_fill(仅eroded/closed' },
{ key: 'closed_overlay', title: '①-e 闭合区域', sub: '紫=closed(仅eroded/closed' },
{ key: 'hairline_overlay', title: '①-f 头发内轮廓线', sub: '绿=头发内轮廓(额头弧+两侧到下颌),黄=baseline折线(仅pushed' },
{ key: 'pushed_overlay', title: '①-g 外推发际线', sub: '青=外推线(进头发push_cm),红=遮罩,绿=内轮廓(仅pushed' },
{ key: 'mask_overlay', title: '① 最终遮罩(叠加)', sub: '红=最终遮罩区' },
{ key: 'mask', title: '① 纯遮罩', sub: '白=贴回区' },
{ key: 'swap_raw', title: '② 生成全帧', sub: '换发型结果' },
{ key: 'hard_paste', title: '③ 严格贴回', sub: '遮罩内=生成,外=原图' },
{ key: 'alpha', title: '④ 融合权重', sub: '白=用生成图' },
{ key: 'final', title: '④ 接缝融合(最终)', sub: '最终输出' },
{ no: '①', key: 'baseline_overlay', title: '发际线分割线 baseline', sub: '黄线=baseline(眉峰水平连线)',
desc: '用 468 点人脸关键点定位两侧眉峰,连成一条水平线作为「上半区」的底界。'
+ '这条线把画面分成上下两半:以上是额头+头发(要处理),以下是五官(保持不动)。'
+ '它是后续所有遮罩、外推、贴回的坐标基准线。图中的黄线就是 baseline,红点为 151 号中心点(径向外推的圆心)。' },
{ no: '②', key: 'upper_overlay', title: '上半区 upper', sub: '青色=baseline 以上区域',
desc: '把 baseline 以上到画面顶部的整片区域标为「上半区」。后续的头发分割、发际线外推、'
+ '最终遮罩都只会在这个上半区内计算,确保下半张脸(眉、眼、鼻、嘴)永远不被改动。'
+ '青色覆盖的就是上半区范围。' },
{ no: '③', key: 'hair_seg_overlay', title: '头发分割', sub: '绿色=头发像素',
desc: '用语义分割模型(默认 SegFormer,可选 BiSeNet)逐像素判断哪些是「头发」。'
+ '绿色覆盖的就是被识别为头发的像素。这一步的目的是找到现有头发的边界,'
+ '为下一步「发际线内轮廓」提供输入——发际线就长在头发区域的内边缘上。' },
{ no: '④', key: 'hairline_overlay', title: '头发内轮廓线', sub: '绿=内轮廓(额头弧+两侧),黄=baseline',
desc: '从头发分割结果里提取出「头发的内轮廓」:即头发与皮肤交界的那条线。'
+ '它包含额头弧线(发际线本体)和两侧向下的鬓角轮廓。'
+ '提取方式 hairline_edge=column 时按逐列(竖向)找头发最低点连成线;=contour 时用形态学轮廓。'
+ '绿色折线就是提取出的内轮廓,黄色折线是 ① 的 baseline。这条内轮廓是外推发际线的起点。' },
{ no: '⑤', key: 'pushed_overlay', title: '外推发际线 + 遮罩', sub: '青=外推线,红=遮罩,绿=内轮廓,红点=圆心',
desc: '把 ④ 的发际线内轮廓「往头发方向(向头顶)推进 hairline_push_cm 厘米」得到一条新的外推线(青色)。'
+ '外推方式:以眉心(151 点)为圆心做径向外推,推过的这段就是「要新长出头发的区域」。'
+ '然后用【外推线(上界)到 baseline(下界)】之间的闭合区域作为最终遮罩(红色半透明)。'
+ 'push_cm 越大,新发际线越靠上、生发区越大(默认 0.8cm)。' },
{ no: '⑥', key: 'mask_overlay', title: '最终遮罩(叠加图)', sub: '红色=要重绘/贴回的区域',
desc: '把 ⑤ 算出的遮罩叠回原图看效果。红色区域 = 需要被新生成的头发覆盖的位置(遮罩内),'
+ '红色以外 = 保持原样不动(遮罩外)。这张图用来直观确认遮罩范围是否合理——'
+ '理想情况是红色正好覆盖额头该生发的区域,不越界到眉毛或脸颊。' },
{ no: '⑦', key: 'mask', title: '纯遮罩', sub: '白色=贴回区,黑色=保留区',
desc: '同一张遮罩的纯黑白版本(无原图背景)。白色=贴回区,黑色=保留区。'
+ '这张纯遮罩会作为 ext_mask 传给换发型服务,让 webui 精确地只在这个区域内重绘画头发。'
+ '它的好处是不受背景图干扰,便于检查遮罩形状是否干净(无噪点、无破洞)。' },
{ no: '⑧', key: 'swap_raw', title: '换发型生成', sub: 'change_hair 换该发际线类型后的整帧图',
desc: '调用 change_hair 换发型服务(POST :8801/api/swapHair/v1),传入原图 + 发际线类型 IDhairline_id)。'
+ '服务用对应发型的 LoRA 模型(webui img2imgdenoising_strength 控制生发强度)生成一张'
+ '「同一个人、换成该发际线类型发型」的完整图。注意:这张图是整帧都变了,'
+ '下一步会严格按遮罩只取额头那块,其余丢掉,保证五官不动。inpainting_fill/mask_blur 等参数控制这里的重绘方式。' },
{ no: '⑨', key: 'hard_paste', title: '严格贴回(无融合)', sub: '遮罩内=生成图,遮罩外=原图',
desc: '把 ⑧ 的生成图按 ⑥/⑦ 的遮罩「硬贴」回原图:遮罩内用生成图,遮罩外完全保留原图。'
+ '这是没有做任何接缝处理的版本,因此遮罩边缘通常能看到明显的接缝/色差。'
+ '它的作用是让你对比看出「融合前后的差别」——边缘接缝要靠下一步的融合来消除。' },
{ no: '⑩', key: 'alpha', title: '融合权重 alpha', sub: '白=用生成图,黑=保留原图,灰=过渡',
desc: '决定每个像素最终取多少比例的生成图。纯白(=1)完全用生成图,纯黑(=0)完全保留原图,'
+ '灰色是两者按比例过渡。blend_method=multiband/two_stage 时是多层金字塔权重(过渡带较宽、自然);'
+ '=feather/alpha_gradient 时是单层羽化(硬边缘软过渡)。这张图用来理解融合是怎么"渐变"地把新头发融进去的。' },
{ no: '⑪', key: 'final', title: '接缝融合(最终输出)', sub: '接口11 最终结果',
desc: '按 ⑩ 的权重,把生成图和原图加权融合,得到无接缝的最终图。这就是接口11 的最终返回结果。'
+ 'blend_method 选择融合算法:multiband=多频段金字塔(分频段融合,大色差场景用 two_stage 先泊松调色再多频段);'
+ 'seamless=泊松无缝克隆(梯度域自动调色);feather/alpha_gradient=简单羽化。'
+ 'color_match=true 时融合前还做一次 Reinhard 颜色迁移消除整体色差(seamless/two_stage 自带调色故跳过)。'
+ '本接口不含重绘,需要重绘(美颜/补发丝)见接口12。' },
];
function $(id) { return document.getElementById(id); }
@@ -183,51 +253,69 @@ function setStatus(text, type) { const b = $('statusBar'); b.textContent = text;
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
function stepCard(title, sub, src) {
function stepCard(st, src) {
const div = document.createElement('div');
div.className = 'step';
const hasImg = src && src.length > 50;
const img = hasImg ? '<img src="' + src + '" onclick="zoom(this.src)">'
: '<div class="noimg">无图(后端返回空)</div>';
div.innerHTML = '<div class="cap">' + title + '<small>' + (sub||'') + (hasImg ? ' ('+src.length+'字符)' : '') + '</small></div>' + img;
const badge = st.no ? '<span class="badge">' + st.no + '</span>' : '';
const ttext = '<span class="ttext">' + (st.title||'') + '<small>' + (st.sub||'') + (hasImg ? ' ('+src.length+'字符)' : '') + '</small></span>';
const desc = st.desc ? '<div class="desc">' + st.desc + '</div>' : '';
div.innerHTML = '<div class="cap">' + badge + ttext + '</div>' + desc + img;
return div;
}
function renderResult(d) {
flog('renderResult 开始(固定 pushed + multiband', 'info');
flog('renderResult 开始 (mask=pushed, blend=' + (d.blend_method || '?') + ')', 'info');
const s = d.steps || {};
$('finalInput').src = pick(s, 'input') || '';
$('finalInput').onclick = function(){ zoom(this.src); };
$('finalOut').src = pick(s, 'final') || '';
$('finalOut').onclick = function(){ zoom(this.src); };
const grid = $('stepsGrid'); grid.innerHTML = '';
// 固定 pushed:只展示 pushed 相关步骤(不展示 top_fill/closed
// 固定 pushed:只展示 pushed 相关步骤(不展示 top_fill/closed;重绘已移到接口12
const showKeys = new Set(['baseline_overlay','upper_overlay','hair_seg_overlay',
'hairline_overlay','pushed_overlay','mask_overlay','mask','swap_raw','hard_paste','alpha','final']);
$('stepsInfo').textContent = '(固定 pushed + multiband)';
$('stepsInfo').textContent = '(mask=pushed, blend=' + (d.blend_method || '?') + ')';
STEPS.filter(st => showKeys.has(st.key)).forEach(st => {
const src = pick(s, st.key);
const hasImg = src && src.length > 50;
flog(' 渲染 ' + st.key + ': ' + (hasImg ? '有图(' + src.length + '字符)' : '无图'), hasImg ? 'info' : 'warn');
grid.appendChild(stepCard(st.title, st.sub, src));
grid.appendChild(stepCard(st, src));
});
flog('renderResult 完成', 'info');
}
async function submitTest() {
const file = $('imageFile').files[0];
let file = $('imageFile').files[0];
if (!file) { setStatus('请先选择图片', 'error'); return; }
file = await window.downscaleImageFile(file);
// 页面隐藏但仍提交的固定默认值
const HIDDEN = {
seg_model: 'segformer',
hairline_edge: 'column',
is_hr: 'false',
};
flog('===== 提交测试 =====', 'info');
// 记录前端实际读取的每个参数值(关键诊断)
flog('前端读取 hairline_push_cm=' + ($('hairlinePushCm').value || '(默认)'), 'info');
flog('前端读取 hairline_edge=' + ($('hairlineEdge').value || '(默认)'), 'info');
flog('前端读取 hairline_id=' + $('hairlineId').value, 'info');
flog('前端读取 seg_model=' + $('segModel').value, 'info');
flog('前端读取 hairline_push_cm=' + ($('hairlinePushCm').value || '(默认)'), 'info');
flog('前端读取 mb_levels=' + ($('mbLevels').value || '(默认)'), 'info');
flog('前端读取 blend_method=' + $('blendMethod').value, 'info');
flog('前端读取 color_match=' + $('colorMatch').checked, 'info');
flog('前端读取 color_match_strength=' + ($('cmStrength').value || '(默认)'), 'info');
flog('前端读取 [融合] edge_erode_px=' + ($('edgeErodePx').value || '(默认)')
+ ' mb_feather_px=' + ($('mbFeatherPx').value || '(默认)')
+ ' transition_band_px=' + ($('transitionBandPx').value || '(默认)'), 'info');
flog('前端读取 [换发型] inpainting_fill=' + $('inpaintingFill').value
+ ' mask_blur=' + ($('maskBlur').value || '(默认)')
+ ' mask_dilate_scale=' + ($('maskDilateScale').value || '(默认)'), 'info');
flog('隐藏参数固定: ' + JSON.stringify(HIDDEN), 'info');
const btn = $('submitBtn');
btn.disabled = true; btn.textContent = '⏳ 请求中...';
setStatus('正在请求(固定 pushed + multiband...', 'info');
setStatus('正在请求 (mask=pushed, blend=' + $('blendMethod').value + ')...', 'info');
$('resultsArea').classList.remove('hidden');
const form = new FormData();
@@ -235,15 +323,22 @@ async function submitTest() {
form.append('hairline_id', $('hairlineId').value);
form.append('gen_backend', 'swaphair');
form.append('hairgrow_strength', '0.75');
form.append('is_hr', 'false');
form.append('seg_model', $('segModel').value);
form.append('erode_cm', $('erodeCm').value || '0.6');
form.append('is_hr', HIDDEN.is_hr);
form.append('seg_model', HIDDEN.seg_model);
form.append('swap_mode', 'ext_mask');
form.append('edge_erode_px', $('edgeErodePx').value || '3');
form.append('denoising_strength', '0.6');
form.append('mb_levels', $('mbLevels').value || '5');
form.append('hairline_push_cm', $('hairlinePushCm').value || '1.0');
form.append('hairline_edge', $('hairlineEdge').value);
form.append('hairline_push_cm', $('hairlinePushCm').value || '0.8');
form.append('hairline_edge', HIDDEN.hairline_edge);
form.append('blend_method', $('blendMethod').value);
form.append('color_match', $('colorMatch').checked ? 'true' : 'false');
form.append('color_match_strength', $('cmStrength').value || '0.4');
form.append('mb_feather_px', $('mbFeatherPx').value || '1');
form.append('transition_band_px', $('transitionBandPx').value || '-1');
form.append('inpainting_fill', $('inpaintingFill').value || '1');
form.append('mask_blur', $('maskBlur').value || '11');
form.append('mask_dilate_scale', $('maskDilateScale').value || '1.0');
// 记录发出去的 form 字段
const sentFields = {};
@@ -266,6 +361,8 @@ async function submitTest() {
const d = json.data;
flog('后端返回 mask_type=' + d.mask_type + ' blend=' + d.blend_method + ' mask_pixels=' + d.mask_pixels, 'info');
flog('后端返回 hairline_push_cm=' + d.hairline_push_cm + ' hairline_edge=' + d.hairline_edge, 'info');
flog('后端返回 color_match=' + d.color_match + ' cm_strength=' + d.color_match_strength + ' mb_feather_px=' + d.mb_feather_px + ' transition_band_px=' + d.transition_band_px, 'info');
flog('后端返回 inpainting_fill=' + d.inpainting_fill + ' mask_blur=' + d.mask_blur + ' mask_dilate_scale=' + d.mask_dilate_scale, 'info');
flog('后端 _rid=' + d._rid, 'info');
// 详细记录 steps 每个字段长度
const s = d.steps || {};
@@ -312,11 +409,15 @@ async function downloadBackendLog() {
}
}
// 步骤序号 → 对应英文 key 的映射,供按序号定位步骤
const STEP_BY_NO = {};
STEPS.forEach(s => { if (s.no) STEP_BY_NO[s.no] = s.key; });
// 参数联动(遮罩/融合已固定,无下拉联动)
$('imageFile').addEventListener('change', function(){ if(this.files.length) flog('选择图片: ' + this.files[0].name, 'info'); });
flog('调试页加载完成', 'info');
flog('遮罩固定 pushed、融合固定 multiband,选好图片直接提交', 'info');
flog('调试页加载完成(接口11,不含重绘)', 'info');
flog('默认: hairline=chang_bolang, push=0.8, blend=two_stage, cm_strength=0.4;多频带融合(edge_erode/mb_feather/transition)与 change_hair 换发型(inpainting_fill/mask_blur/mask_dilate)参数已在页面可调。重绘见接口12 测试页', 'info');
</script>
</body>
</html>
+424
View File
@@ -0,0 +1,424 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口12 — 发际线带重绘(Flux-2)测试页</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1240px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 4px; }
h2 { font-size: 16px; margin: 20px 0 12px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 16px; line-height: 1.6; }
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
input[type=file] { flex: 1; min-width: 200px; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.btn { padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; font-weight: 600; }
.btn-primary { background: #7c3aed; color: #fff; }
.btn-primary:disabled { background: #c4b5fd; cursor: not-allowed; }
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
.btn-green { background: #059669; color: #fff; }
.btn-orange { background: #ea580c; color: #fff; }
.params { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-top: 16px; }
.pf { display: flex; flex-direction: column; gap: 4px; }
.pf label { font-size: 13px; font-weight: 600; }
.pf .desc { font-size: 11px; color: #9ca3af; font-weight: 400; }
.pf select, .pf input[type=number] { padding: 8px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; }
.pf .row { display: flex; gap: 8px; align-items: center; }
.pf .row input[type=number] { width: 80px; }
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; font-family: monospace; }
.status.info { background: #ede9fe; color: #5b21b6; display: block; }
.status.error { background: #fee2e2; color: #991b1b; display: block; }
.status.success { background: #d1fae5; color: #065f46; display: block; }
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; }
.step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; }
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
.step .noimg { padding: 30px; text-align: center; color: #ccc; font-size: 13px; }
.big img { max-height: 520px; object-fit: contain; }
.log-panel { background: #1e1e1e; color: #d4d4d4; padding: 14px; border-radius: 8px;
font: 12px/1.6 Consolas, Monaco, monospace; white-space: pre-wrap; word-break: break-all;
max-height: 400px; overflow: auto; }
.log-panel .ts { color: #569cd6; }
.log-panel .lvl-info { color: #4ec9b0; }
.log-panel .lvl-warn { color: #dcdcaa; }
.log-panel .lvl-err { color: #f48771; }
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
.lightbox img { max-width: 95%; max-height: 95%; }
.hidden { display: none; }
</style>
<script src="/static/img_downscale.js"></script>
</head>
<body>
<div class="container">
<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>后端重绘接口(/api/v1/redraw</b>完成重绘。⚠️ 需 ComfyUI(:8188) 在跑。
对照仅生成不重绘:<a href="/static/test_interface11_debug.html">接口11 调试页</a>
</div>
<div class="card">
<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>🧪 重绘</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="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
</div>
<div class="upload-row" style="margin-top:10px">
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
<input type="text" id="token" value="dev-shared-secret-2026" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
</div>
<div class="params">
<div class="pf">
<label>发际线类型 ID</label>
<select id="hairlineId">
<option value="chang_bolang" selected>chang_bolang(波浪)</option>
<option value="chang_zhixian">chang_zhixian(直线)</option>
<option value="chang_tuoyuan">chang_tuoyuan(椭圆)</option>
<option value="chang_xinxing">chang_xinxing(心形)</option>
<option value="chang_huaban">chang_huaban(花瓣)</option>
</select>
</div>
<div class="pf">
<label>hairline_push_cm <span class="desc">发际线外推(cm),也决定重绘带宽度</span></label>
<div class="row"><input type="number" id="hairlinePushCm" min="0" max="3" step="0.1" value="0.8"></div>
</div>
<div class="pf">
<label>mb_levels <span class="desc">多频段金字塔层数</span></label>
<div class="row"><input type="number" id="mbLevels" min="2" max="6" step="1" value="5"></div>
</div>
<div class="pf">
<label>blend_method <span class="desc">接缝融合方法</span></label>
<select id="blendMethod">
<option value="two_stage" selected>two_stage(泊松→多频段,大色差)</option>
<option value="multiband">multiband(多频段金字塔)</option>
<option value="seamless">seamless(泊松无缝克隆)</option>
<option value="feather">feather(高斯羽化)</option>
<option value="alpha_gradient">alpha_gradient(距离变换)</option>
</select>
</div>
<div class="pf">
<label>color_match <span class="desc">融合前颜色迁移(消除色差)</span></label>
<div class="row"><input type="checkbox" id="colorMatch" style="width:18px;height:18px"><span class="desc">multiband/feather 生效;seamless/two_stage 自带调色</span></div>
</div>
<div class="pf">
<label>color_match_strength <span class="desc">颜色迁移强度(0~1)</span></label>
<div class="row"><input type="number" id="cmStrength" min="0" max="1" step="0.05" value="0.4"></div>
</div>
<div class="pf">
<label>beauty_alpha <span class="desc">B版 band外美颜强度(0~1)</span></label>
<div class="row"><input type="number" id="beautyAlpha" min="0" max="1" step="0.05" value="0.6"></div>
</div>
<div class="pf">
<label>band_lo_mult <span class="desc">重绘带外推倍率下限(×push)</span></label>
<div class="row"><input type="number" id="bandLoMult" min="0" max="3" step="0.1" value="0.5"></div>
</div>
<div class="pf">
<label>band_hi_mult <span class="desc">重绘带外推倍率上限(×push)</span></label>
<div class="row"><input type="number" id="bandHiMult" min="0" max="3" step="0.1" value="1.5"></div>
</div>
</div>
<div class="pf" style="margin-top:14px">
<label>comfyui_prompt <span class="desc">已下线(后端不再做 Flux-2 重绘);重绘提示词改用上方「重绘提示词」输入框</span></label>
<textarea id="comfyuiPrompt" rows="2" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:13px;font-family:inherit;resize:vertical" placeholder="已下线,留空即可"></textarea>
</div>
<div style="font-size:12px;color:#888;margin-top:8px">遮罩固定 pushed;后端只产出 final+纯红遮罩(不做重绘)。隐藏参数(seg_model/hairline_edge/is_hr/edge_erode_px/mb_feather_px/transition_band_px/inpainting_fill/mask_blur/mask_dilate_scale)按默认值随请求提交。beauty_alpha/comfyui_prompt 已不再生效。</div>
</div>
<div class="status hidden" id="statusBar"></div>
<div id="resultsArea" class="hidden">
<div class="card">
<h2 style="margin-top:0">🎯 final + 纯红遮罩 → 重绘结果</h2>
<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">🧪 重绘结果 <small>final + 遮罩 → ComfyUI(0716add-hair)</small></div><img id="localResult"></div>
</div>
<div style="font-size:12px;color:#888;margin-top:10px">流程:后端产出 final(接缝融合基底)+ 纯红遮罩 PNG,再调后端 /api/v1/redraw 完成发际线补发。</div>
</div>
<div class="card">
<h2 style="margin-top:0">🪜 分步可视化 <span style="font-size:12px;color:#888" id="stepsInfo"></span></h2>
<div class="steps" id="stepsGrid"></div>
</div>
</div>
<div class="card">
<h2 style="margin-top:0; display:flex; justify-content:space-between; align-items:center">
<span>📋 前端日志</span>
<div>
<button class="btn btn-green" style="padding:6px 14px;font-size:13px" onclick="downloadFrontendLog()">下载前端日志</button>
<button class="btn btn-orange" style="padding:6px 14px;font-size:13px" onclick="downloadBackendLog()">下载后端日志</button>
<button class="btn btn-outline" style="padding:6px 14px;font-size:13px" onclick="clearLog()">清空</button>
</div>
</h2>
<div class="log-panel" id="logPanel"></div>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg" alt=""></div>
<script>
const API_BASE = window.location.origin;
const ENDPOINT = '/api/v1/hairline/grow_v2';
const LOG_API = '/api/v1/debug/hairline_log';
const FE_LOGS = [];
function flog(msg, level) {
const ts = new Date().toLocaleTimeString('zh-CN', {hour12:false}) + '.' + String(Date.now()%1000).padStart(3,'0');
const line = { ts, level: level||'info', msg };
FE_LOGS.push(line);
const panel = document.getElementById('logPanel');
const cls = level === 'warn' ? 'lvl-warn' : level === 'error' ? 'lvl-err' : 'lvl-info';
panel.innerHTML += '<span class="ts">[' + ts + ']</span> <span class="' + cls + '">' + line.msg.replace(/</g,'&lt;') + '</span>\n';
panel.scrollTop = panel.scrollHeight;
}
function clearLog() { FE_LOGS.length = 0; document.getElementById('logPanel').innerHTML = ''; }
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)、其余全透明;交给后端重绘' },
];
// 缓存最近一次后端返回的 final(JPG data URI)和纯红遮罩(PNG data URI),供重绘使用
let _finalDataUri = '';
let _maskDataUri = '';
function $(id) { return document.getElementById(id); }
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
function stepCard(title, sub, src) {
const div = document.createElement('div');
div.className = 'step';
const hasImg = src && src.length > 50;
const img = hasImg ? '<img src="' + src + '" onclick="zoom(this.src)">'
: '<div class="noimg">无图(后端返回空)</div>';
div.innerHTML = '<div class="cap">' + title + '<small>' + (sub||'') + (hasImg ? ' ('+src.length+'字符)' : '') + '</small></div>' + img;
return div;
}
function renderResult(d) {
flog('renderResult 开始 (blend=' + (d.blend_method || '?') + ')', 'info');
const s = d.steps || {};
const finalSrc = pick(s, 'final') || '';
const maskSrc = pick(s, 'redraw_band_mask') || '';
_finalDataUri = finalSrc;
_maskDataUri = maskSrc;
$('finalBase').src = finalSrc;
$('finalBase').onclick = function(){ zoom(this.src); };
$('maskPng').src = maskSrc;
$('maskPng').onclick = function(){ zoom(this.src); };
const grid = $('stepsGrid'); grid.innerHTML = '';
$('stepsInfo').textContent = '(mask=pushed, blend=' + (d.blend_method || '?') + ')';
if (d.redraw && d.redraw.enabled) {
flog('重绘带 band_pixels=' + d.redraw.band_pixels + ' push_px=' + d.redraw.push_px, 'info');
} else if (d.redraw && d.redraw.error) {
flog('重绘带计算失败: ' + d.redraw.error, 'warn');
}
STEPS.forEach(st => {
const src = pick(s, st.key);
const hasImg = src && src.length > 50;
flog(' 渲染 ' + st.key + ': ' + (hasImg ? '有图(' + src.length + '字符)' : '无图'), hasImg ? 'info' : 'warn');
grid.appendChild(stepCard(st.title, st.sub, src));
});
// final + 遮罩 都有 → 允许调后端重绘
const canRedraw = !!(finalSrc && maskSrc);
$('redrawBtn').disabled = !canRedraw;
flog('renderResult 完成 canRedraw=' + canRedraw, 'info');
}
async function submitTest() {
let file = $('imageFile').files[0];
if (!file) { setStatus('请先选择图片', 'error'); return; }
file = await window.downscaleImageFile(file);
// 页面隐藏但仍提交的固定默认值
const HIDDEN = {
seg_model: 'segformer',
hairline_edge: 'column',
is_hr: 'false',
edge_erode_px: '3',
mb_feather_px: '1',
transition_band_px: '-1',
inpainting_fill: '1',
mask_blur: '11',
mask_dilate_scale: '1.0',
};
flog('===== 提交重绘(接口12 =====', 'info');
flog('前端读取 hairline_id=' + $('hairlineId').value, 'info');
flog('前端读取 hairline_push_cm=' + ($('hairlinePushCm').value || '(默认)'), 'info');
flog('前端读取 mb_levels=' + ($('mbLevels').value || '(默认)'), 'info');
flog('前端读取 blend_method=' + $('blendMethod').value, 'info');
flog('前端读取 color_match=' + $('colorMatch').checked, 'info');
flog('前端读取 color_match_strength=' + ($('cmStrength').value || '(默认)'), 'info');
flog('前端读取 beauty_alpha=' + ($('beautyAlpha').value || '(默认)'), 'info');
flog('前端读取 重绘带倍率 lo=' + ($('bandLoMult').value || '0.5') + ' hi=' + ($('bandHiMult').value || '1.5'), 'info');
flog('前端读取 comfyui_prompt=' + ($('comfyuiPrompt').value.trim() || '(默认)'), 'info');
flog('隐藏参数固定: ' + JSON.stringify(HIDDEN), 'info');
const btn = $('submitBtn');
btn.disabled = true; btn.textContent = '⏳ 生成中...';
setStatus('正在请求(内部跑接口11 生成 final + 纯红遮罩,约 10~15s...', 'info');
$('resultsArea').classList.remove('hidden');
const form = new FormData();
form.append('image_file', file);
form.append('hairline_id', $('hairlineId').value);
form.append('gen_backend', 'swaphair');
form.append('hairgrow_strength', '0.75');
form.append('is_hr', HIDDEN.is_hr);
form.append('seg_model', HIDDEN.seg_model);
form.append('erode_cm', '0.6');
form.append('swap_mode', 'ext_mask');
form.append('edge_erode_px', HIDDEN.edge_erode_px);
form.append('denoising_strength', '0.6');
form.append('mb_levels', $('mbLevels').value || '5');
form.append('hairline_push_cm', $('hairlinePushCm').value || '0.8');
form.append('hairline_edge', HIDDEN.hairline_edge);
form.append('blend_method', $('blendMethod').value);
form.append('color_match', $('colorMatch').checked ? 'true' : 'false');
form.append('color_match_strength', $('cmStrength').value || '0.4');
form.append('beauty_alpha', $('beautyAlpha').value || '0.6');
form.append('band_lo_mult', $('bandLoMult').value || '0.5');
form.append('band_hi_mult', $('bandHiMult').value || '1.5');
form.append('mb_feather_px', HIDDEN.mb_feather_px);
form.append('transition_band_px', HIDDEN.transition_band_px);
form.append('inpainting_fill', HIDDEN.inpainting_fill);
form.append('mask_blur', HIDDEN.mask_blur);
form.append('mask_dilate_scale', HIDDEN.mask_dilate_scale);
const cp = $('comfyuiPrompt').value.trim();
if (cp) form.append('comfyui_prompt', cp);
const sentFields = {};
form.forEach((v, k) => { sentFields[k] = (k === 'image_file') ? '[文件]' : v; });
flog('实际发送的 FormData: ' + JSON.stringify(sentFields), 'info');
const t0 = performance.now();
try {
const headers = {};
const tok = $('token').value.trim();
if (tok) headers['X-Internal-Token'] = tok;
flog('fetch POST ' + ENDPOINT, 'info');
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers, body: form });
flog('收到响应 http=' + resp.status, resp.ok ? 'info' : 'error');
const json = await resp.json();
const dt = ((performance.now() - t0) / 1000).toFixed(2);
flog('JSON 解析完成 code=' + json.code + ' 耗时=' + dt + 's', json.code === 0 ? 'info' : 'error');
if (json.code === 0) {
const d = json.data;
flog('后端返回 blend=' + d.blend_method + ' hairline_push_cm=' + d.hairline_push_cm + ' mask_pixels=' + d.mask_pixels, 'info');
flog('后端返回 _rid=' + d._rid, 'info');
const s = d.steps || {};
Object.keys(s).filter(k => k.endsWith('_base64')).forEach(k => {
const len = s[k] ? s[k].length : 0;
flog(' steps.' + k + ' = ' + (len > 0 ? len + '字符' : '空'), len > 0 ? 'info' : 'warn');
});
const hasFinal = !!(pick(s, 'final'));
const hasMask = !!(pick(s, 'redraw_band_mask'));
setStatus((hasFinal && hasMask ? '✅ 已生成 final + 纯红遮罩' : '⚠️ 已返回(final/遮罩缺失,见日志)') + ' (' + dt + 's) _rid=' + d._rid, (hasFinal && hasMask) ? 'success' : 'error');
renderResult(d);
} else {
setStatus('❌ 业务错误 code=' + json.code + '' + json.message, 'error');
flog('业务错误: ' + json.message, 'error');
}
} catch (err) {
setStatus('❌ 网络错误: ' + err.message, 'error');
flog('网络错误: ' + err.message, 'error');
} finally {
btn.disabled = false; btn.textContent = '🎨 生成 final+遮罩';
}
}
// data URI → Blob,用于把后端返回的 final/遮罩图作为文件 POST 给后端重绘
function dataUriToBlob(dataUri) {
const [meta, b64] = dataUri.split(',');
const mime = (meta.match(/data:([^;]+)/) || [, 'application/octet-stream'])[1];
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return new Blob([arr], { type: mime });
}
async function runLocalRedraw() {
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
const btn = $('redrawBtn');
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
flog('===== 调后端重绘 =====', 'info');
flog('提示词=' + prompt, 'info');
const form = new FormData();
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 ' + 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);
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('✅ 重绘完成 (' + dt + 's)', 'success');
flog('重绘完成 耗时=' + dt + 's', 'info');
} else {
const msg = json.message || JSON.stringify(json);
setStatus('❌ 重绘失败:' + msg, 'error');
flog('重绘失败: ' + msg, 'error');
}
} catch (err) {
setStatus('❌ 重绘网络错误:' + err.message, 'error');
flog('重绘网络错误: ' + err.message, 'error');
} finally {
btn.disabled = false; btn.textContent = '🧪 重绘';
}
}
function downloadFrontendLog() {
const text = FE_LOGS.map(l => '[' + l.ts + '] [' + l.level.toUpperCase() + '] ' + l.msg).join('\n');
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'iface12_frontend_log_' + Date.now() + '.txt';
a.click();
}
async function downloadBackendLog() {
flog('下载后端日志...', 'info');
try {
const resp = await fetch(API_BASE + LOG_API + '?tail=1000');
const text = await resp.text();
flog('后端日志获取成功 ' + text.length + ' 字符', 'info');
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'iface12_backend_log_' + Date.now() + '.txt';
a.click();
} catch (err) {
flog('下载后端日志失败: ' + err.message, 'error');
}
}
$('imageFile').addEventListener('change', function(){ if(this.files.length) flog('选择图片: ' + this.files[0].name, '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>
+204
View File
@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口12 final — 发际线带重绘(精简版)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1240px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 4px; }
h2 { font-size: 16px; margin: 20px 0 12px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 16px; line-height: 1.6; }
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
input[type=file] { flex: 1; min-width: 200px; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.btn { padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; font-weight: 600; }
.btn-primary { background: #7c3aed; color: #fff; }
.btn-primary:disabled { background: #c4b5fd; cursor: not-allowed; }
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
.btn-green { background: #059669; color: #fff; }
.params { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-top: 16px; }
.pf { display: flex; flex-direction: column; gap: 4px; }
.pf label { font-size: 13px; font-weight: 600; }
.pf select { padding: 8px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; }
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; font-family: monospace; }
.status.info { background: #ede9fe; color: #5b21b6; display: block; }
.status.error { background: #fee2e2; color: #991b1b; display: block; }
.status.success { background: #d1fae5; color: #065f46; display: block; }
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; }
.step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; }
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
.big img { max-height: 520px; object-fit: contain; }
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
.lightbox img { max-width: 95%; max-height: 95%; }
.hidden { display: none; }
</style>
<script src="/static/img_downscale.js"></script>
</head>
<body>
<div class="container">
<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>后端重绘接口(/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>
<div class="card">
<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>🧪 重绘</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="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
</div>
<div class="params">
<div class="pf">
<label>发际线类型 ID</label>
<select id="hairlineId">
<option value="chang_bolang" selected>chang_bolang(波浪)</option>
<option value="chang_zhixian">chang_zhixian(直线)</option>
<option value="chang_tuoyuan">chang_tuoyuan(椭圆)</option>
<option value="chang_xinxing">chang_xinxing(心形)</option>
<option value="chang_huaban">chang_huaban(花瓣)</option>
</select>
</div>
</div>
</div>
<div class="status" id="statusBar"></div>
<div id="resultsArea" class="hidden">
<div class="card">
<h2 style="margin-top:0">🎯 final + 纯红遮罩 → 重绘结果</h2>
<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">🧪 重绘结果 <small>final + 遮罩 → ComfyUI(0716add-hair)</small></div><img id="localResult"></div>
</div>
<div style="font-size:12px;color:#888;margin-top:10px">流程:后端产出 final(接缝融合基底)+ 纯红遮罩 PNG,再调后端 /api/v1/redraw 完成发际线补发。</div>
</div>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg" alt=""></div>
<script>
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),供重绘使用
let _finalDataUri = '';
let _maskDataUri = '';
function $(id) { return document.getElementById(id); }
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
function renderResult(d) {
const s = d.steps || {};
const finalSrc = pick(s, 'final') || '';
const maskSrc = pick(s, 'redraw_band_mask') || '';
_finalDataUri = finalSrc;
_maskDataUri = maskSrc;
$('finalBase').src = finalSrc;
$('finalBase').onclick = function(){ zoom(this.src); };
$('maskPng').src = maskSrc;
$('maskPng').onclick = function(){ zoom(this.src); };
const canRedraw = !!(finalSrc && maskSrc);
$('redrawBtn').disabled = !canRedraw;
}
// data URI → Blob,用于把后端返回的 final/遮罩图作为文件 POST 给后端重绘
function dataUriToBlob(dataUri) {
const [meta, b64] = dataUri.split(',');
const mime = (meta.match(/data:([^;]+)/) || [, 'application/octet-stream'])[1];
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return new Blob([arr], { type: mime });
}
async function submitTest() {
let file = $('imageFile').files[0];
if (!file) { setStatus('请先选择图片', 'error'); return; }
file = await window.downscaleImageFile(file);
const btn = $('submitBtn');
btn.disabled = true; btn.textContent = '⏳ 生成中...';
setStatus('正在请求(内部跑接口11 生成 final + 纯红遮罩,约 10~15s...', 'info');
$('resultsArea').classList.remove('hidden');
const form = new FormData();
form.append('image_file', file);
form.append('hairline_id', $('hairlineId').value);
const t0 = performance.now();
try {
const resp = await fetch(API_BASE + ENDPOINT, {
method: 'POST',
headers: { 'X-Internal-Token': TOKEN },
body: form,
});
const json = await resp.json();
const dt = ((performance.now() - t0) / 1000).toFixed(2);
if (json.code === 0) {
const d = json.data;
const hasFinal = !!(pick(d.steps, 'final'));
const hasMask = !!(pick(d.steps, 'redraw_band_mask'));
setStatus((hasFinal && hasMask ? '✅ 已生成 final + 纯红遮罩' : '⚠️ 已返回(final/遮罩缺失,见日志)') + ' (' + dt + 's)', (hasFinal && hasMask) ? 'success' : 'error');
renderResult(d);
} else {
setStatus('❌ 业务错误 code=' + json.code + '' + json.message, 'error');
}
} catch (err) {
setStatus('❌ 网络错误: ' + err.message, 'error');
} finally {
btn.disabled = false; btn.textContent = '🎨 生成 final+遮罩';
}
}
async function runLocalRedraw() {
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
const btn = $('redrawBtn');
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
const form = new FormData();
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(API_BASE + '/api/v1/redraw', {
method: 'POST',
body: form,
});
const dt = ((performance.now() - t0) / 1000).toFixed(2);
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('✅ 重绘完成 (' + dt + 's)', 'success');
} else {
setStatus('❌ 重绘失败:' + (json.message || JSON.stringify(json)), 'error');
}
} catch (err) {
setStatus('❌ 重绘网络错误:' + err.message, 'error');
} finally {
btn.disabled = false; btn.textContent = '🧪 重绘';
}
}
</script>
</body>
</html>
+143
View File
@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口12 final v2 — 发际线带重绘(局部加发+全脸美颜)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1240px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 4px; }
h2 { font-size: 16px; margin: 20px 0 12px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 16px; line-height: 1.6; }
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
input[type=file] { flex: 1; min-width: 200px; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.btn { padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; font-weight: 600; }
.btn-primary { background: #7c3aed; color: #fff; }
.btn-primary:disabled { background: #c4b5fd; cursor: not-allowed; }
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
.btn-green { background: #059669; color: #fff; }
.params { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-top: 16px; }
.pf { display: flex; flex-direction: column; gap: 4px; }
.pf label { font-size: 13px; font-weight: 600; }
.pf select { padding: 8px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; }
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; font-family: monospace; }
.status.info { background: #ede9fe; color: #5b21b6; display: block; }
.status.error { background: #fee2e2; color: #991b1b; display: block; }
.status.success { background: #d1fae5; color: #065f46; display: block; }
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; }
.step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; }
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
.big img { max-height: 520px; object-fit: contain; }
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
.lightbox img { max-width: 95%; max-height: 95%; }
.hidden { display: none; }
</style>
<script src="/static/img_downscale.js"></script>
</head>
<body>
<div class="container">
<h1>接口12 final v2 — 发际线带重绘 <span style="font-size:13px;color:#888">(局部加发+全脸美颜)</span></h1>
<div class="subtitle">
只需上传图片 + 选择发型,其余参数全部用当前调优默认值(<code>/api/v1/hairline/grow_v2_final_v2</code>)。<br>
重绘输出为 <b>B 局部加发+全脸美颜</b>(加发只在发际线带、band 外保留 final 结构并叠加全脸美颜)。⚠️ 需 ComfyUI(:8188) 在跑。
整帧重绘版见 <a href="/static/test_interface12_final.html">接口12 final</a>;完整参数调试见 <a href="/static/test_interface12.html">接口12 调试页</a>
</div>
<div class="card">
<div class="upload-row">
<input type="file" id="imageFile" accept="image/*">
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🎨 提交重绘</button>
</div>
<div class="params">
<div class="pf">
<label>发际线类型 ID</label>
<select id="hairlineId">
<option value="chang_bolang" selected>chang_bolang(波浪)</option>
<option value="chang_zhixian">chang_zhixian(直线)</option>
<option value="chang_tuoyuan">chang_tuoyuan(椭圆)</option>
<option value="chang_xinxing">chang_xinxing(心形)</option>
<option value="chang_huaban">chang_huaban(花瓣)</option>
</select>
</div>
</div>
</div>
<div class="status" id="statusBar"></div>
<div id="resultsArea" class="hidden">
<div class="card">
<h2 style="margin-top:0">🎯 局部加发+全脸美颜结果</h2>
<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">B · 局部加发+全脸美颜 <small>加发只在发际线带、美颜保留全脸</small></div><img id="outBand"></div>
</div>
</div>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg" alt=""></div>
<script>
const API_BASE = window.location.origin;
const ENDPOINT = '/api/v1/hairline/grow_v2_final_v2';
const TOKEN = 'dev-shared-secret-2026';
function $(id) { return document.getElementById(id); }
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
function renderResult(d) {
const s = d.steps || {};
$('finalBase').src = pick(s, 'final') || '';
$('finalBase').onclick = function(){ zoom(this.src); };
$('outBand').src = pick(s, 'redraw_band') || '';
$('outBand').onclick = function(){ zoom(this.src); };
}
async function submitTest() {
let file = $('imageFile').files[0];
if (!file) { setStatus('请先选择图片', 'error'); return; }
file = await window.downscaleImageFile(file);
const btn = $('submitBtn');
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
setStatus('正在请求(内部先跑接口11,再 Flux-2 重绘,约 15~30s...', 'info');
$('resultsArea').classList.remove('hidden');
const form = new FormData();
form.append('image_file', file);
form.append('hairline_id', $('hairlineId').value);
const t0 = performance.now();
try {
const resp = await fetch(API_BASE + ENDPOINT, {
method: 'POST',
headers: { 'X-Internal-Token': TOKEN },
body: form,
});
const json = await resp.json();
const dt = ((performance.now() - t0) / 1000).toFixed(2);
if (json.code === 0) {
const d = json.data;
const okRedraw = d.redraw && d.redraw.enabled && !d.redraw.c_error && pick(d.steps, 'redraw_band');
setStatus((okRedraw ? '✅ 局部加发+全脸美颜成功' : '⚠️ 已返回(重绘可能未生效)') + ' (' + dt + 's)', okRedraw ? 'success' : 'error');
renderResult(d);
} else {
setStatus('❌ 业务错误 code=' + json.code + '' + json.message, 'error');
}
} catch (err) {
setStatus('❌ 网络错误: ' + err.message, 'error');
} finally {
btn.disabled = false; btn.textContent = '🎨 提交重绘';
}
}
</script>
</body>
</html>
+12 -8
View File
@@ -69,6 +69,7 @@
.hidden { display: none !important; }
</style>
<script src="/static/img_downscale.js?v=2"></script>
</head>
<body>
<div class="container">
@@ -106,7 +107,7 @@
<div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
<input type="text" id="promptInput" value="充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
<input type="text" id="promptInput" value="充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
</div>
<div id="statusBar" class="status hidden"></div>
</div>
@@ -140,9 +141,9 @@ function $(id) { return document.getElementById(id); }
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
async function submitTest() {
const f = $('imageFile').files[0];
let f = $('imageFile').files[0];
if (!f) { setStatus('请选择图片', 'error'); return; }
f = await window.downscaleImageFile(f);
_origUrl = URL.createObjectURL(f);
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ 请求中...';
@@ -156,7 +157,7 @@ async function submitTest() {
fd.append('prompt', $('promptInput').value);
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hair/grow', { method:'POST', body:fd });
const r = await fetch(API_BASE + '/api/v1/hair/grow', { method:'POST', headers:{ 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
@@ -179,6 +180,9 @@ function renderSchemes(results) {
let html = '';
results.forEach((r, i) => {
const lb = TYPE_LABELS[r.hairline_type] || r.hairline_type;
// 兼容网关 *_url 与 worker *_base64(原始或 data URI
const overlaySrc = resolveImgSrc(r.image_url, r.image_base64, 'image/png');
const grownSrc = resolveImgSrc(r.grown_image_url, r.grown_image_base64, 'image/jpeg');
// 原图
const origSlot = '<div class="img-slot">'+
@@ -190,19 +194,19 @@ function renderSchemes(results) {
'<div class="label"><span class="dot preview"></span>原图+发际线叠加</div>'+
'<div class="thumb"><div class="img-stack">'+
'<img class="layer-base" src="'+_origUrl+'" alt="原图">'+
'<img class="layer-anno" src="'+r.image_url+'" alt="叠加">'+
'<img class="layer-anno" src="'+overlaySrc+'" alt="叠加">'+
'</div></div></div>';
// 生发效果
let grownSlot;
if (r.grown_image_url) {
if (grownSrc) {
grownSlot = '<div class="img-slot">'+
'<div class="label"><span class="dot grown"></span>生发效果</div>'+
'<div class="thumb"><img src="'+r.grown_image_url+'" alt="生发"></div></div>';
'<div class="thumb"><img src="'+grownSrc+'" alt="生发"></div></div>';
} else {
grownSlot = '<div class="img-slot">'+
'<div class="label"><span class="dot grown"></span>生发效果</div>'+
'<div class="na">⚠ 未返回<br><span style="font-size:10px;color:#9ca3af">ComfyUI 未就绪或生成失败</span></div></div>';
'<div class="na">⚠ 未返回<br><span style="font-size:10px;color:#9ca3af">生发失败(female=换发型/重绘,male=ComfyUI</span></div></div>';
}
html += '<div class="scheme-card">'+
+6 -5
View File
@@ -71,6 +71,7 @@
@media (max-width: 800px) { .results-layout, .preview-row { flex-direction: column; } }
</style>
<script src="/static/img_downscale.js?v=2"></script>
</head>
<body>
<div class="container">
@@ -93,7 +94,7 @@
</div>
<div class="upload-group" style="margin-top:14px">
<div class="label">💬 提示词(prompt</div>
<input type="text" id="promptInput" value="充遮罩区域的头发" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
<input type="text" id="promptInput" value="充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
</div>
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
@@ -139,9 +140,9 @@ function $(id) { return document.getElementById(id); }
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
async function submitTest() {
const mf = $('markedFile').files[0];
let mf = $('markedFile').files[0];
if (!mf) { setStatus('请选择划线图', 'error'); return; }
mf = await window.downscaleImageFile(mf);
const markedUrl = URL.createObjectURL(mf);
// 先显示原图
@@ -161,7 +162,7 @@ async function submitTest() {
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', body:fd });
const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
@@ -174,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 = resolveImgSrc(d.hair_growth_image_url, d.hair_growth_image_base64, 'image/jpeg');
if (grownSrc) {
$('blendTop').src = grownSrc;
$('blendTop').style.display = 'block';
+4 -2
View File
@@ -57,6 +57,7 @@
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
</style>
<script src="/static/img_downscale.js"></script>
</head>
<body>
<div class="container">
@@ -121,8 +122,9 @@ function $(id) { return document.getElementById(id); }
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
async function submitTest() {
const f = $('imageFile').files[0];
let f = $('imageFile').files[0];
if (!f) { setStatus('请选择图片', 'error'); return; }
f = await window.downscaleImageFile(f);
$('imgPreview').innerHTML = '<img src="'+URL.createObjectURL(f)+'" alt="preview">';
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ 分析中...';
@@ -131,7 +133,7 @@ async function submitTest() {
const fd = new FormData(); fd.append('image_file', f);
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/face/features', { method:'POST', body:fd });
const r = await fetch(API_BASE + '/api/v1/face/features', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
+36 -16
View File
@@ -88,6 +88,7 @@
.hidden { display: none !important; }
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
</style>
<script src="/static/img_downscale.js?v=2"></script>
</head>
<body>
<div class="container">
@@ -112,6 +113,12 @@
</div>
</div>
</div>
<div class="form-group">
<label>生发效果图</label>
<label class="checkbox-inline" style="font-weight:normal;display:flex;align-items:center;gap:6px">
<input type="checkbox" id="genGrowImg" checked> generate_grow_image(默认开;关闭后跳过最耗时的生发,仅返回三档叠图与中心点)
</label>
</div>
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
</div>
@@ -132,8 +139,12 @@
<div class="card-header"><span>🎯 所有发型结果(三档 + 生发图)</span><span style="font-weight:400;font-size:12px;color:#9ca3af" id="resultCount">共 0 个发型</span></div>
<div class="card-body">
<div class="coord-box" style="margin-top:0;margin-bottom:16px">
<div class="label">📍 最佳发际线中心点(best_hairline_center_point,首个发型 middle 档)— 原图像素坐标</div>
<div class="value" id="centerPoint"></div>
<div class="label">📍 发际线中心点(首个发型三档)— 原图像素坐标</div>
<div class="value">
middle: <span id="centerPoint"></span>
high: <span id="centerHigh"></span>
low: <span id="centerLow"></span>
</div>
</div>
<div id="resultsGrid"></div>
</div>
@@ -191,19 +202,20 @@ function selectAllHair(select) {
}
async function submitTest() {
const f = $('imageFile').files[0];
let f = $('imageFile').files[0];
if (!f) { setStatus('请选择图片', 'error'); return; }
const checked = [...document.querySelectorAll('#hairStyleGroup input:checked')].map(cb => cb.value);
if (!checked.length) { setStatus('请至少选择一个发型', 'error'); return; }
f = await window.downscaleImageFile(f);
_origUrl = URL.createObjectURL(f);
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...';
setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden');
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(','));
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(',')); fd.append('generate_grow_image', $('genGrowImg').checked ? 'true' : 'false');
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', body:fd });
const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
@@ -211,7 +223,10 @@ async function submitTest() {
if (json.code === 0) {
_images = json.data.hairline_images || [];
_center = json.data.best_hairline_center_point;
$('centerPoint').textContent = _center ? '(' + _center.x + ', ' + _center.y + ')' : '—';
const _fmtPt = p => p ? '(' + p.x + ', ' + p.y + ')' : '—';
$('centerPoint').textContent = _fmtPt(json.data.best_hairline_center_point);
$('centerHigh').textContent = _fmtPt(json.data.high_hairline_center_point);
$('centerLow').textContent = _fmtPt(json.data.low_hairline_center_point);
$('resultCount').textContent = '共 ' + _images.length + ' 个发型';
setStatus('✅ ' + _images.length + ' 个发型 × 三档 (' + _elapsed + 's)', 'success');
renderGrid();
@@ -228,25 +243,30 @@ async function submitTest() {
function renderGrid() {
if (!_images.length) { $('resultsGrid').innerHTML = '<span style="color:#9ca3af">无数据</span>'; return; }
let h = '';
// 叠图档:原图打底 + 透明 PNG 叠加(无原图或无叠图 → 显示占位)
const overlayCell = (label, url) => (url && _origUrl)
// 叠图档:原图打底 + 透明 PNG 图层(无原图或无叠图 → 显示占位)
const overlayCell = (label, src) => (src && _origUrl)
? '<div class="level-cell"><div class="level-label">'+label+'</div><div class="img-stack">'+
'<img class="layer-base" src="'+_origUrl+'" alt="原图">'+
'<img class="layer-anno" src="'+url+'" alt="'+label+'"></div></div>'
'<img class="layer-anno" src="'+src+'" alt="'+label+'"></div></div>'
: '<div class="level-cell"><div class="level-label">'+label+'</div><div class="level-none"></div></div>';
// 生发图:独立 img(已是 ComfyUI 完整人像照片)
const grownCell = (label, url) => url
? '<div class="level-cell"><div class="level-label">'+label+'</div><img src="'+url+'" alt="'+label+'"></div>'
const grownCell = (label, src) => src
? '<div class="level-cell"><div class="level-label">'+label+'</div><img src="'+src+'" alt="'+label+'"></div>'
: '<div class="level-cell"><div class="level-label">'+label+'</div><div class="level-none"></div></div>';
_images.forEach(function(it) {
// 兼容网关 *_url 与 worker *_base64
const mid = resolveImgSrc(it.image_middle_url, it.image_middle_base64, 'image/png');
const high = resolveImgSrc(it.image_high_url, it.image_high_base64, 'image/png');
const low = resolveImgSrc(it.image_low_url, it.image_low_base64, 'image/png');
const grown = resolveImgSrc(it.grown_image_url, it.grown_image_base64, 'image/jpeg');
h += '<div class="hair-block">' +
'<div class="hair-block-title">#' + (it.order||'—') + ' ' + (it.hairline_type||'') +
(it.grown_image_url ? ' &nbsp;<span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' +
(grown ? ' &nbsp;<span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' +
'<div class="level-row">' +
overlayCell('middle', it.image_middle_url) +
overlayCell('high', it.image_high_url) +
overlayCell('low', it.image_low_url) +
grownCell('生发图', it.grown_image_url) +
overlayCell('middle', mid) +
overlayCell('high', high) +
overlayCell('low', low) +
grownCell('生发图', grown) +
'</div>' +
'</div>';
});
+12 -3
View File
@@ -59,6 +59,7 @@
.diff-list { font-size: 12px; color: #6b7280; margin-top: 6px; line-height: 1.7; }
.diff-list li { margin-left: 18px; }
</style>
<script src="/static/img_downscale.js?v=2"></script>
</head>
<body>
<div class="container">
@@ -129,8 +130,9 @@ function setStatus(text, type) {
async function submitTest() {
const fileInput = $('imageFile');
const file = fileInput.files[0];
let file = fileInput.files[0];
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
file = await window.downscaleImageFile(file);
const _reqStart = performance.now();
@@ -150,7 +152,7 @@ async function submitTest() {
form.append('image_file', file);
try {
const resp = await fetch(API_BASE + '/api/v1/face/measure-v2', { method: 'POST', body: form });
const resp = await fetch(API_BASE + '/api/v1/face/measure-v2', { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
const json = await resp.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
@@ -159,7 +161,10 @@ async function submitTest() {
if (json.code === 0) {
setStatus('✅ 请求成功 (' + _elapsed + 's) — request_id: ' + json.request_id, 'success');
showOverlay(json.data.annotated_image_url);
// 兼容本地直连 worker*_base64)与网关(*_url
const _d = json.data || {};
const annoUrl = resolveImgSrc(_d.annotated_image_url, _d.annotated_image_base64, 'image/png');
showOverlay(annoUrl);
renderMetrics(json.data);
$('metricsBar').classList.remove('hidden');
} else {
@@ -183,6 +188,10 @@ function showOverlay(annoUrl) {
setTimeout(() => showOverlay(annoUrl), 200);
return;
}
if (!annoUrl) {
$('imgPanel').innerHTML = '<span class="placeholder">后端未返回标注图(annotated_image_base64 为空)</span>';
return;
}
const checked = $('showAnno').checked ? '' : 'display:none';
const opacity = ($('annoOpacity').value / 100).toFixed(2);
+5 -4
View File
@@ -66,6 +66,7 @@
.badge-v2 { background: #7c3aed; color: #fff; font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-left: 6px; vertical-align: middle; }
</style>
<script src="/static/img_downscale.js"></script>
</head>
<body>
<div class="container">
@@ -103,7 +104,7 @@
<div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待 &nbsp;|&nbsp; 工作流: add_hair2.json</div>
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
<input type="text" id="promptInput" value="充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
<input type="text" id="promptInput" value="充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
</div>
<div id="statusBar" class="status hidden"></div>
</div>
@@ -137,9 +138,9 @@ function $(id) { return document.getElementById(id); }
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
async function submitTest() {
const f = $('imageFile').files[0];
let f = $('imageFile').files[0];
if (!f) { setStatus('请选择图片', 'error'); return; }
f = await window.downscaleImageFile(f);
_origUrl = URL.createObjectURL(f);
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ 请求中...';
@@ -153,7 +154,7 @@ async function submitTest() {
fd.append('prompt', $('promptInput').value);
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hair/grow-v2', { method:'POST', body:fd });
const r = await fetch(API_BASE + '/api/v1/hair/grow-v2', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
+4 -2
View File
@@ -51,6 +51,7 @@
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
.lightbox img { max-width: 95%; max-height: 95%; }
</style>
<script src="/static/img_downscale.js"></script>
</head>
<body>
<div class="container">
@@ -194,8 +195,9 @@ function renderMetrics(data) {
}
async function submitTest() {
const file = $('imageFile').files[0];
let file = $('imageFile').files[0];
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
file = await window.downscaleImageFile(file);
const t0 = performance.now();
const btn = $('submitBtn');
@@ -208,7 +210,7 @@ async function submitTest() {
form.append('erode_cm', $('erodeCm').value || '1.2');
try {
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', body: form });
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
const json = await resp.json();
const dt = ((performance.now() - t0) / 1000).toFixed(2);
-123
View File
@@ -1,123 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>发际线生发</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.wrap { max-width: 720px; width: 100%; }
h1 { font-size: 20px; margin-bottom: 16px; text-align: center; }
.card { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 1px 4px rgba(0,0,0,.08); margin-bottom: 20px; }
.row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
.field { flex: 1; min-width: 180px; display: flex; flex-direction: column; gap: 6px; }
label { font-size: 13px; font-weight: 600; color: #555; }
input[type=file] { padding: 8px; border: 2px dashed #ddd; border-radius: 8px; font-size: 14px; cursor: pointer; }
select { padding: 8px 10px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; background: #fff; }
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; background: #2563eb; color: #fff; white-space: nowrap; }
.btn:disabled { background: #93c5fd; cursor: not-allowed; }
.status { padding: 10px 14px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
.status.info { background: #dbeafe; color: #1e40af; display: block; }
.status.error { background: #fee2e2; color: #991b1b; display: block; }
.status.success { background: #d1fae5; color: #065f46; display: block; }
.result { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.result .col { text-align: center; }
.result .col .cap { font-size: 13px; color: #888; margin-bottom: 8px; font-weight: 600; }
.result img { width: 100%; border-radius: 8px; box-shadow: 0 1px 6px rgba(0,0,0,.12); cursor: zoom-in; display: block; }
.result .empty { color: #bbb; padding: 60px 0; font-size: 14px; grid-column: 1 / -1; text-align: center; }
.hint { font-size: 12px; color: #999; margin-top: 8px; text-align: center; }
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.88); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; padding: 24px; }
.lightbox img { max-width: 100%; max-height: 100%; }
</style>
</head>
<body>
<div class="wrap">
<h1>发际线生发</h1>
<div class="card">
<div class="row">
<div class="field">
<label>选择图片</label>
<input type="file" id="imageFile" accept="image/*">
</div>
<div class="field">
<label>发型</label>
<select id="hairlineId">
<option value="chang_zhixian">直线</option>
<option value="chang_tuoyuan">椭圆</option>
<option value="chang_bolang">波浪</option>
<option value="chang_xinxing">心形</option>
<option value="chang_huaban">花瓣</option>
</select>
</div>
<button class="btn" id="submitBtn" onclick="submit()">生成</button>
</div>
<div class="hint">其余参数走默认值(pushed 遮罩 + multiband 融合)</div>
</div>
<div class="status hidden" id="statusBar"></div>
<div class="card">
<div class="result" id="resultArea">
<div class="empty">上传图片、选发型、点生成</div>
</div>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg"></div>
<script>
const API = window.location.origin + '/api/v1/hairline/grow_v2';
const TOKEN = 'dev-shared-secret-2026';
function $(id) { return document.getElementById(id); }
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
async function submit() {
const file = $('imageFile').files[0];
if (!file) { setStatus('请先选择图片', 'error'); return; }
const btn = $('submitBtn');
btn.disabled = true; btn.textContent = '生成中...';
setStatus('正在生成(约 5~15 秒)...', 'info');
// 先读原图用于对比
const origSrc = await new Promise(r => { const fr = new FileReader(); fr.onload = () => r(fr.result); fr.readAsDataURL(file); });
$('resultArea').innerHTML =
'<div class="col"><div class="cap">原图</div><img src="' + origSrc + '"></div>'
+ '<div class="col"><div class="cap">⏳ 生成中...</div><div class="empty" style="padding:40px 0">处理中</div></div>';
const form = new FormData();
form.append('image_file', file);
form.append('hairline_id', $('hairlineId').value);
try {
const resp = await fetch(API, {
method: 'POST',
headers: { 'X-Internal-Token': TOKEN },
body: form,
});
const json = await resp.json();
if (json.code === 0 && json.data && json.data.final_base64) {
const src = json.data.final_base64;
setStatus('✅ 生成成功', 'success');
$('resultArea').innerHTML =
'<div class="col"><div class="cap">原图</div><img src="' + origSrc + '" onclick="zoom(this.src)"></div>'
+ '<div class="col"><div class="cap">生成结果</div><img src="' + src + '" onclick="zoom(this.src)"></div>';
} else {
setStatus('❌ 失败:' + (json.message || '未知错误'), 'error');
$('resultArea').innerHTML = '<div class="empty">生成失败:' + (json.message||'') + '</div>';
}
} catch (err) {
setStatus('❌ 网络错误:' + err.message, 'error');
$('resultArea').innerHTML = '<div class="empty">请求失败</div>';
} finally {
btn.disabled = false; btn.textContent = '生成';
}
}
$('imageFile').addEventListener('change', function() {
if (this.files.length) setStatus('已选择:' + this.files[0].name, 'info');
});
</script>
</body>
</html>
+38
View File
@@ -62,3 +62,41 @@ def test_threshold_gating_rejects_when_zeroed():
def test_pose_none_is_not_blocked():
"""solvePnP 失败(返回 None)时不拦截,check_frontal_face 返回 True。"""
assert pose.estimate_head_pose.__doc__ # 占位,确保导入
def test_iterative_flipped_solution_falls_back_to_sqpnp():
"""回归:部分正面照上 ITERATIVE 会解出 tz<0、roll≈±180°,应回退 SQPNP。
像素点取自一张真实正面短发照720×945裸跑 ITERATIVE 会得到负深度
"""
W, H = 720, 945
# 鼻尖 / 下巴 / 左眼外 / 右眼外 / 左嘴角 / 右嘴角(像素)
px = [
(358.32715988, 600.98652095),
(346.83344364, 779.63507116),
(242.94779778, 466.76155195),
(478.43703747, 480.10321766),
(284.13277388, 679.95527387),
(422.19510555, 684.31899190),
]
lm = [_LM(0.5, 0.5) for _ in range(478)]
for idx, (u, v) in zip(PNP_INDICES, px):
lm[idx] = _LM(u / W, v / H)
class _Holder:
landmark = lm
holder = _Holder()
# 确认裸 ITERATIVE 确实是翻转解(否则本回归失去意义)
image_points = np.array(px, dtype=np.float64)
cam = np.array([[float(W), 0, W / 2], [0, float(W), H / 2], [0, 0, 1]],
dtype=np.float64)
ok, rvec, tvec = cv2.solvePnP(
_MODEL_POINTS, image_points, cam, np.zeros((4, 1)),
flags=cv2.SOLVEPNP_ITERATIVE,
)
assert ok and float(tvec[2, 0]) < 0
yaw, pitch, roll = estimate_head_pose(holder, W, H)
assert abs(roll) < 30, f"roll 应被纠正,实际 roll={roll}"
assert check_frontal_face(holder, W, H) is True
+222
View File
@@ -0,0 +1,222 @@
# 优云智算 网络加速配置说明
> 配置日期:2026-07-01
> 适用实例:虚机实例(系统镜像)
> 操作系统:Ubuntu 22.04.4 LTS (Jammy Jellyfish)
> 主机名 / 内网 IP10-60-64-219 / 10.60.64.219
---
## 一、功能简介
优云智算「网络加速」通过独立 DNS 服务器提供优化解析服务,有效提升**海外资源访问下载的网络稳定性和速度**,解决:
- 大模型网站(HuggingFace、PyTorch 等)下载缓慢
- Github 访问卡顿、丢包、克隆失败
- Docker / Conda / Go 模块等拉取超时
### 已支持加速域名
| 分类 | 域名 |
|------|------|
| 代码托管 | `.github.com` |
| GPU / 容器镜像 | `.nvidia.com``.nvcr.io``.docker.com``.k8s.io``.gcr.io` |
| 开发语言 | `.golang.org``.googlesource.com` |
| Python 生态 | `.pythonhosted.org``.pytorch.org``.anaconda.org``.conda.io``.anaconda.com` |
| AI 模型库 | `.huggingface.co``.civitai.com``.wandb.ai` |
---
## 二、加速 DNS 服务器地址
| 主 DNS | 备 DNS |
|--------|--------|
| **100.90.90.90** | **100.90.90.100** |
> 主备顺序建议优先填写前者。
---
## 三、实例类型与配置方式
| 实例类型 | 是否需手动配置 |
|----------|----------------|
| 容器实例(基础镜像 / 社区镜像) | ❌ 否,开通加速后自动配置 |
| **虚机实例(系统镜像)** | ✅ **是,需按本文档手动配置** |
> **本机为虚机实例**,已按下方步骤完成配置。
---
## 四、配置方式(Ubuntu 20.04 / 22.04 / 24.04
### 方式 A:临时修改(立即生效,重启后失效)
```bash
sudo vim /etc/resolv.conf
```
删除原有 `nameserver` 行,添加:
```text
nameserver 100.90.90.90
nameserver 100.90.90.100
```
> ⚠️ 注意:本机的 `/etc/resolv.conf` 是指向 `/run/systemd/resolve/resolv.conf` 的**软链接**(由 systemd-resolved 管理),重启后会被 netplan / systemd 重新生成覆盖。**推荐使用方式 B 持久化配置。**
### 方式 B:持久化配置(重启后保留)✅ 本机采用
编辑 netplan 配置文件:
```bash
sudo vim /etc/netplan/50-cloud-init.yaml
```
找到 `nameservers:` 区域,修改为:
```yaml
nameservers:
addresses:
- 100.90.90.90
- 100.90.90.100
```
保存并立即生效:
```bash
sudo netplan apply
```
---
## 五、本机实际配置记录
### 5.1 配置文件(/etc/netplan/50-cloud-init.yaml
```yaml
network:
ethernets:
eth0:
addresses:
- 10.60.64.219/16
match:
macaddress: 52:54:00:5c:90:69
name: eth0
mtu: 1452
nameservers:
addresses:
- 100.90.90.90 # 加速 DNS(主)
- 100.90.90.100 # 加速 DNS(备)
routes:
- to: default
via: 10.60.0.1
set-name: eth0
version: 2
```
> 配置修改前已备份至:`/etc/netplan/50-cloud-init.yaml.bak.20260701_223916`
### 5.2 配置验证命令
```bash
# 1. 语法校验(无输出即通过)
sudo netplan generate
# 2. 应用配置
sudo netplan apply
# 3. 查看当前生效的 DNS
resolvectl dns
# 期望输出:Link 2 (eth0): 100.90.90.90 100.90.90.100
# 4. 查看 /etc/resolv.conf
cat /etc/resolv.conf | grep nameserver
# 期望输出:
# nameserver 100.90.90.90
# nameserver 100.90.90.100
```
---
## 六、加速效果实测(配置前后对比)
### 6.1 DNS 解析速度
| 域名 | 配置前 | 配置后 | 提升 |
|------|--------|--------|------|
| huggingface.co | 7 ms | 0 ms | ✅ |
| github.com | 3 ms | 0 ms | ✅ |
| pytorch.org | **487 ms** | 0 ms | ✅ 显著 |
| nvidia.com | - | 0 ms | ✅ |
| nvcr.io | - | 0 ms | ✅ |
### 6.2 解析路由(已接入加速网关)
加速域名均被解析到内网加速节点:
```text
huggingface.co -> 10.60.132.229 (优云加速网关)
github.com -> 10.60.132.229
pytorch.org -> 10.60.132.229
nvcr.io -> 10.60.132.229
```
### 6.3 实际下载测试(GitHub raw 文件)
```text
下载大小 : 916,147 bytes (~896 KB)
总耗时 : 0.88 s
平均速度 : ~1.04 MB/s
DNS解析 : 0.7 ms
连接时间 : 1.8 ms
```
### 6.4 加速节点连通性
```text
ping 10.60.132.229
4 packets transmitted, 4 received, 0% packet loss
rtt min/avg/max = 0.186 / 0.302 / 0.588 ms (内网级延迟)
```
**结论:DNS 切换、加速路由、下载链路均已正常工作,0 丢包。**
---
## 七、日常验证与排障
### 7.1 快速自检脚本
```bash
# 检查当前 DNS 是否为加速服务器
echo "当前 DNS"; resolvectl dns
# 检查加速域名是否解析到加速网关
for d in huggingface.co github.com pytorch.org nvcr.io; do
printf "%-18s -> %s\n" "$d" "$(dig +short $d | head -1)"
done
```
### 7.2 常见问题
| 现象 | 原因 | 解决 |
|------|------|------|
| 重启后 DNS 变回旧地址 | 仅做了临时修改(方式 A) | 改用方式 B 持久化配置 |
| `netplan apply` 报 Open vSwitch 警告 | 未启用 OVS,可忽略 | 不影响使用 |
| 修改后无法解析任何域名 | DNS 地址填写错误 | 核对为 100.90.90.90 / 100.90.90.100 |
| 非加速域名解析变慢 | 正常现象 | 仅支持域名列表内的加速,其他域名走常规解析 |
---
## 八、回滚方法
如需恢复原始 DNS 配置:
```bash
# 从备份还原
sudo cp /etc/netplan/50-cloud-init.yaml.bak.20260701_223916 /etc/netplan/50-cloud-init.yaml
sudo netplan apply
# 验证已恢复为 100.65.128.2 / 100.65.128.3 / 114.114.114.114
resolvectl dns
```