41 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
xsl d5397a6d6f chore: 接口2/3/5/7 生发 ComfyUI 默认提示词加「加一点美颜」
将接口2(/api/v1/hair/grow)、接口3(/api/v1/hair/grow-b)、
接口5(/api/v1/hairline/generate)、接口7(/api/v1/hair/grow-v2)的
prompt 参数默认值从「补充遮罩区域的头发」改为「补充遮罩区域的头发,加一点美颜」。
2026-07-13 23:41:42 +08:00
xsl 95c6a2d929 feat: 接口2/5 发际线叠图改为透明 PNG(仅曲线),生发图不变
接口2(/api/v1/hair/grow)的 image_url 和接口5(/api/v1/hairline/generate)
的 image_middle/high/low_url 从「原图+白线合成 JPG」改为「透明底 PNG(仅含
发际线曲线)」,前端需叠加原图显示。grown_image_url 生发图保持不变(ComfyUI
完整人像照片)。

实现:
- app.py 新增 _rgba_png_b64() 编码 RGBA 透明层为 PNG base64(保留 alpha)
- hairline/service.py 接口2/5 改用 build_overlay_layer(返回 RGBA 透明层)
  替代 render_hairline_overlay(合成到原图)
- 网关零改动:rewrite_base64_to_url 已按 \x89PNG 魔数嗅探落盘为 .png

测试页:test_interface2/5.html 改为「原图打底 + 透明PNG 绝对定位叠加」显示
(复用 .img-stack 结构,固定叠加无开关)。

文档:接口文档.md / integration.html 更新接口2/5 图片字段说明。
测试:43 passed,接口2 image_base64 改断言为 PNG 魔数,接口5 三档叠图同。
2026-07-13 23:38:55 +08:00
xsl 28255ef7c2 feat: 接口5 新增 face_measure(复用接口1测量数值)+ 接口1 七眼 eye1~eye7
接口5(/api/v1/hairline/generate)在发际线结果基础上新增 data.face_measure,
复用接口1的四庭七眼测量数值(四庭/七眼 eye1~eye7/landmarks/姿态),
不含标注图;独立流程容错,测量失败时为 null 不影响发际线主结果。

重构:提取 _run_face_measure_data() 共用函数,接口1/6 改调它再补标注图,
行为不变(43 测试全过,错误码 1001/1003/1008 回归正常)。

接口1/5 七眼新增 eye1~eye7 从左到右 7 段宽度(cm),eye1/eye7 耳朵不可见
时为 null(保留键)。

测试页 test_interface5.html:移除点击切换卡片网格改为所有发型平铺,
新增四庭七眼测量卡片。前端接入页 integration.html / 接口文档同步更新。
2026-07-13 22:40:55 +08:00
xsl b163a3f34a 调整 baseline 关键点 + 新增极简测试页
- head_mask.py: BASELINE_IDX 改为 [162,71,68,104,69,108,151,337,299,333,298,301,389],
  左端点 21→162、右端点 251→389,新增 71/301 两点;同步更新注释
- static/test_simple.html: 极简测试页,仅需选图片+发型,原图与结果左右并排对比
- .gitignore: 忽略 report_hairline_v2.zip
2026-07-12 22:27:02 +08:00
Ubuntu 5d9d91bc82 save code 2026-07-12 18:50:05 +08:00
xsl 3fe5f6cf0d fix: batch_grow_v2.py 的 hr_options 从 HR_OPTIONS 动态生成,不再硬编码两档
旧代码硬编码 hr_options=[hr,nohr],导致仅非高清批量跑完后 meta 仍含 hr 档,
报告脚本取 hr_opts[0]=hr 与实际结果的 nohr 不匹配,结果图全部渲染不出来。
2026-07-12 00:08:01 +08:00
xsl e17677158a 批量测试:仅非高清(20脸×5发型=100张)+ 报告原图大图并排对比
- batch_grow_v2.py: HR_OPTIONS 仅保留非高清档,去掉高清
- gen_report_hairline_v2.py: 每张脸 grid 改为「原图大图 + 5发型」6列并排,
  原图不再是缩略图;去掉高清/非高清行标签和失败表高清列
2026-07-12 00:02:42 +08:00
xsl 1e6c2e54de 接口11/12:固定 pushed 遮罩 + multiband 融合,移除其他算法选项
遮罩算法只保留 pushed(发际线外推),融合算法只保留 multiband(多频段金字塔),
eroded/closed/feather/alpha_gradient/seamless 等旧选项从接口参数层移除。

- generate_hairline_grow: 删除 mask_type/blend_method/feather_px/color_match 参数,
  内部固定 mask_type=pushed、blend_method=multiband
- app.py 接口11/12: 删除 mask_type/blend_method/feather_px/color_match Form 参数,
  调用处改关键字传参;grow_v2 只传 image+hairline_id 即默认走最新算法
- 前端两个测试页: 删除遮罩/融合下拉选项及相关联动,固定展示 pushed 步骤
- 文档: 更新为"固定 pushed + multiband,移除其他选项"
2026-07-11 23:31:19 +08:00
74 changed files with 5954 additions and 1219 deletions
+6 -1
View File
@@ -46,5 +46,10 @@ _grow*_resp.json
# 测试素材图(体积大,不入 git)
image/test/
# 批量报告输出(200张生成图+原图,体积大,不入 git
# 批量报告输出(生成图+原图,体积大,不入 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": {
+481 -219
View File
@@ -14,6 +14,7 @@ from typing import Any, List, Optional
import cv2
import numpy as np
from PIL import Image
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
@@ -137,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")
@@ -229,6 +270,20 @@ def _jpg_b64(bgr) -> str:
return base64.b64encode(buf.tobytes()).decode()
def _rgba_png_b64(rgba) -> str:
"""(H,W,4) float32/uint8 RGBA 透明层 → PNG base64(保留 alpha 通道)。
用于发际线叠图/预览图:只含发际线曲线像素、背景透明,前端叠加到原图上显示。
"""
arr = np.asarray(rgba)
if arr.dtype != np.uint8:
arr = np.clip(arr, 0, 255).astype(np.uint8)
img = Image.fromarray(arr, mode="RGBA")
buf = BytesIO()
img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode()
def _png_to_jpg_b64(png_bytes) -> str:
"""ComfyUI 返回的 PNG 字节 → 重编码为 JPG base64;无法解码则原样透传。"""
img = cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR)
@@ -310,6 +365,105 @@ class ImageJsonBody(BaseModel):
# ---------------------------------------------------------------------------
def _run_face_measure_data(image, variant="v1"):
"""接口1测量数值核心:detect → 姿态校验 → 头发/耳朵分割 → measure_face → eye1~eye7。
返回 (data_dict, result, hair_mask, ear_mask),或检测/姿态失败时返回 None。
不含标注图(annotated_image_*),供接口5 容错复用;接口1 调用后再自行生成标注图。
任何分割/七眼计算异常均内部吞掉(七眼相关字段不出现),不影响主测量结果。
variant="v1"(接口1/接口5):完整四庭七眼 + eye1~eye7(含头部端线段)。
variant="v6"(接口6):去顶庭、不画端线、无 eye1~eye7。
"""
h, w = image.shape[:2]
from face_analysis.detector import detector
from face_analysis.pose import estimate_head_pose, check_frontal_face
from face_analysis.measure import measure_face
landmarks = detector.detect(image)
if landmarks is None:
return None
if not check_frontal_face(landmarks, w, h):
return None
head_pose = estimate_head_pose(landmarks, w, h)
# 头发/耳朵分割(方案 B,单次推理),失败传 None 由 measure 内部回退方案 A。
hair_mask = None
ear_mask = None
try:
from face_analysis.hair_segmenter import get_segmenter
from face_analysis.calibration import normalized_to_pixel
pxs = [normalized_to_pixel(p, w, h) for p in landmarks.landmark]
face_box = (min(p[0] for p in pxs), min(p[1] for p in pxs),
max(p[0] for p in pxs), max(p[1] for p in pxs))
hair_mask, ear_mask = get_segmenter().segment_hair_and_ears(image, face_box=face_box)
except Exception as seg_e: # noqa: BLE001
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":
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 保留返回(供前端/下游定位头顶),但顶庭数值、
# 占比、标注图仍按三庭处理,显示效果不变。
# 七眼段宽度(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
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,
top_y, vd["chin_tip"][1],
lcx, rcx, (lcx + rcx) / 2)
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
async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
"""接口1/6 共用实现:四庭七眼测量 + 标注图生成。返回 (ok_dict, err_dict)。
@@ -326,60 +480,24 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
if image is None:
return None, err(1008, "图片格式不支持(仅 JPG / PNG)")
h, w = image.shape[:2]
try:
from face_analysis.detector import detector
from face_analysis.pose import estimate_head_pose, check_frontal_face
from face_analysis.measure import measure_face
from face_analysis.annotation import create_annotated_image
# 5. 人脸检测
landmarks = detector.detect(image)
if landmarks is None:
return None, err(1001, "无法识别人像")
# 6. 姿态校验
if not check_frontal_face(landmarks, w, h):
ret = _run_face_measure_data(image, variant=variant)
if ret is None:
# 区分错误码:未检出人脸 vs 非正面。重新检测一次以判断。
from face_analysis.detector import detector
from face_analysis.pose import check_frontal_face
if detector.detect(image) is None:
return None, err(1001, "无法识别人像")
return None, err(1003, "角度问题,请上传正面照")
head_pose = estimate_head_pose(landmarks, w, h)
data, result, hair_mask, ear_mask = ret
# 7. 头发/耳朵分割(方案 B,单次推理),失败传 None 由 measure 内部回退方案 A。
# 传人脸包围盒 → 先按人脸裁剪再分割(全身/街拍等脸偏小的图也能稳出耳朵)。
hair_mask = None
ear_mask = None
try:
from face_analysis.hair_segmenter import get_segmenter
from face_analysis.calibration import normalized_to_pixel
pxs = [normalized_to_pixel(p, w, h) for p in landmarks.landmark]
face_box = (min(p[0] for p in pxs), min(p[1] for p in pxs),
max(p[0] for p in pxs), max(p[1] for p in pxs))
hair_mask, ear_mask = get_segmenter().segment_hair_and_ears(image, face_box=face_box)
except Exception as seg_e: # noqa: BLE001
logger.warning("头发/耳朵分割失败,回退方案A%s", seg_e)
# 8. 测量 + 标注图(hair_mask 定发际线/头顶;ear_mask 定人头最左/最右竖线)
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
# 标注图(接口1/6 才需要;接口5 复用 _run_face_measure_data 时不生成)
from face_analysis.annotation import create_annotated_image
annotated = create_annotated_image(
image, result, ear_mask=ear_mask, hair_mask=hair_mask, variant=variant)
buf = BytesIO()
annotated.save(buf, format="PNG")
# 9. 拆分架构:返回 base64,不落盘不拼 URL(落盘改 URL 由网关完成)
data = result.to_response()
if variant == "v6":
# 接口6:数据去顶庭(重算三庭比例与总高,移除顶庭/头顶字段)
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)
data["annotated_image_base64"] = base64.b64encode(buf.getvalue()).decode()
return ok(data), None
except Exception as ex: # noqa: BLE001
@@ -409,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 段
""",
@@ -442,6 +560,8 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
"face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44,
"ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143},
"eye1": 3.44, "eye2": 3.44, "eye3": 3.44, "eye4": 3.44,
"eye5": 3.44, "eye6": 3.44, "eye7": 3.44,
},
"landmarks": {
"hair_top": {"x": 540, "y": 120},
@@ -491,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 一致。
@@ -512,7 +632,7 @@ async def face_measure(
**标注图片 UI 规范**(真实版本生效):
- 字体/线条/箭头颜色:`#FFFFFF 100%`,透明底
- 字号/线宽/虚线/箭头按图片短边自适应缩放
- 三庭数值(名+数值两行,不带 cm)在图片**左侧**呈现,七眼段宽**上下穿插**展示,底部标「单位cm」
- 三庭(名 + 数值带cm + 百分比 三行)在图片**左侧**呈现,七眼段宽**上下穿插**展示(数值带cm,下方另起一行标占头宽百分比)
- 段宽/庭高用虚线 + 实心三角双箭头标示
""",
responses={
@@ -542,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},
@@ -593,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`)。
@@ -649,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"):
@@ -672,17 +798,24 @@ 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, "无法识别人像")
results = []
for p in items:
results.append({
"image_base64": _jpg_b64(p["image_bgr"]), # 预览图 JPG
"image_base64": _rgba_png_b64(p["overlay"]), # 发际线曲线透明 PNG
"grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG
if p["grown_png"] else None),
"hairline_type": p["hairline_type"],
@@ -695,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")
# ---------------------------------------------------------------------------
@@ -855,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)
@@ -986,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`,仅返回三档发际线叠图与中心点,大幅降低耗时。
**返回说明**
@@ -994,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: {
@@ -1012,6 +1047,33 @@ 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": {
"top_court_cm": 3.44, "upper_court_cm": 3.44,
"middle_court_cm": 3.44, "lower_court_cm": 3.44,
"ratios": {"top_court": 0.25, "upper_court": 0.25,
"middle_court": 0.25, "lower_court": 0.25},
},
"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},
"eye1": 3.44, "eye2": 3.44, "eye3": 3.44, "eye4": 3.44,
"eye5": 3.44, "eye6": 3.44, "eye7": 3.44,
},
"landmarks": {
"hair_top": {"x": 540, "y": 120},
"hairline": {"x": 540, "y": 430},
"brow_center": {"x": 540, "y": 740},
"nose_bottom": {"x": 540, "y": 1050},
"chin_tip": {"x": 540, "y": 1360},
},
"hairline_source": "segmentation",
"head_pose": {"yaw": -1.39, "pitch": 2.49, "roll": -0.06},
},
},
}
}
@@ -1034,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")
@@ -1058,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, "无法识别人像")
@@ -1067,18 +1131,32 @@ async def hairline_generate(
ov = it["overlays"]
hairline_images.append({
"hairline_type": it["hairline_type"],
"image_middle_base64": _jpg_b64(ov["middle"]), # 发际线叠图 middle 档 JPG
"image_high_base64": _jpg_b64(ov["high"]), # 发际线叠图 high 档 JPG
"image_low_base64": _jpg_b64(ov["low"]), # 发际线叠图 low 档 JPG
"image_middle_base64": _rgba_png_b64(ov["middle"]), # 发际线曲线透明 PNG middle 档
"image_high_base64": _rgba_png_b64(ov["high"]), # 发际线曲线透明 PNG high 档
"image_low_base64": _rgba_png_b64(ov["low"]), # 发际线曲线透明 PNG low 档
"grown_image_base64": (_png_to_jpg_b64(it["grown_png"]) # 生发图 JPG(失败为 null
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/姿态),不含标注图。
# 独立流程,容错:测量失败(无人脸/非正面/分割失败)→ null,不影响发际线主结果。
try:
fm = _run_face_measure_data(image, variant="v1")
data["face_measure"] = fm[0] if fm is not None else None
except Exception as fm_e: # noqa: BLE001
logger.warning("接口5 face_measure 失败:%s", fm_e)
data["face_measure"] = None
return ok(data)
except Exception as ex: # noqa: BLE001
logger.exception("接口5 处理异常")
@@ -1297,19 +1375,23 @@ async def hairline_grow(
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"),
mask_type: str = Form(default="eroded", description="遮罩类型:eroded(内缩) | closed(闭合区域) | pushed(发际线外推)(默认 eroded"),
erode_cm: float = Form(default=1.2, description="遮罩外缘朝中心151内缩距离(厘米,同接口9),默认 1.2"),
hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米,往头发方向推进;仅 mask_type=pushed 生效),默认 1.0"),
hairline_edge: str = Form(default="column", description="发际线提取方式:column(逐列最低点) | contour(形态学轮廓)(仅 pushed 生效),默认 column"),
swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask(改造换发型用接口9遮罩) | as_is(不改换发型,贴回再裁)(默认 ext_mask)"),
blend_method: str = Form(default="feather", description="接缝融合:feather(高斯羽化) | alpha_gradient(距离渐变) | seamless(泊松无缝) | multiband(多频段金字塔)(默认 feather)"),
feather_px: int = Form(default=15, description="羽化/渐变过渡宽度(像素),默认 15"),
erode_cm: float = Form(default=0.6, description="baseline 参考内缩距离(厘米),默认 0.6"),
hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米,往头发方向推进),默认 1.0"),
hairline_edge: str = Form(default="column", description="发际线提取方式:column | contour,默认 column"),
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"),
color_match: bool = Form(default=False, description="融合前对生成图做 Reinhard 颜色校正(消除整体色差,seamless 下自动跳过),默认 false"),
mb_levels: int = Form(default=5, description="multiband 多频段金字塔层数(2~6,越大低频色差抹得越宽,仅 multiband 生效),默认 5"),
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:发际线生发 + 分步可视化"""
"""接口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
@@ -1323,14 +1405,24 @@ 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 收到请求: mask_type=%s hairline_push_cm=%s hairline_edge=%s",
rid, mask_type, hairline_push_cm, hairline_edge)
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, is_hr, seg_model,
mask_type, erode_cm, swap_mode, blend_method, feather_px, edge_erode_px,
denoising_strength, gen_backend, hairgrow_strength, color_match, mb_levels,
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, rid=rid)
generate_hairline_grow, 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=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)
except NoFaceError:
return err(1001, "无法识别人像")
except SwapError as se:
@@ -1343,22 +1435,33 @@ async def hairline_grow(
# ---------------------------------------------------------------------------
# 接口 12:发际线生发(接口11 固定参数精简版
# 接口 12:发际线带重绘(调用接口11 的 ④final 作输入 + ⑤-①重绘带作遮罩,Flux-2 重绘
# ---------------------------------------------------------------------------
@app.post(
"/api/v1/hairline/grow_v2",
summary="接口12 发际线生发(固定金字塔融合,仅返回最终图",
summary="接口12 发际线带重绘(接口11 final + 发际线重绘带 → Flux-2 保色重绘",
tags=["生发"],
description=f"""
接口11 的固定参数精简版,适合生产直调。与接口11 共用同一管线,区别仅在于:
接口12 是接口11 的**下游重绘阶段**。内部先跑接口11 核心管线拿到 **④ 接缝融合最终图(final)**,
再取 **⑤-① 发际线重绘带**(发际线沿外推方向 `band_lo_mult×push` ~ `band_hi_mult×push`
之间、经 ①-a baseline 截断只留上部的带状区域,默认 0.5×~1.5×push
作为遮罩,用 **Flux-2ComfyUI** 做 reference-latent 保色重绘(不易染绿),重绘结果再与 final 融合。
- **固定** `blend_method=multiband`(多频段金字塔融合)、`mb_levels=5`、`erode_cm=0.6`
(外缘朝中心151 内缩 0.6cm)。这三项不可调,故本接口不暴露。
- **返回值精简**:只返回 `final_base64`(最终合成图),不再附带接口11 的分步可视化
与接口11 的关系:接口11 只负责生成 final(不再含重绘);接口12 负责在 final 上做发际线带重绘。
接口11 的可调参数(seg_model/gen_backend/hairline_push_cm/blend_method/color_match 等)
在本接口同样暴露,用于内部生成 final 与重绘带;另有 `comfyui_prompt` 控制 Flux-2 提示词
其余参数(hairline_id、seg_model、gen_backend、is_hr、denoising_strength、color_match、
edge_erode_px 等)仍保留为可选 Form,调用方可按需覆盖,未传则用接口11 同款默认值。
⚠️ 依赖 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}
""",
@@ -1372,16 +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"),
mask_type: str = Form(default="eroded", description="遮罩类型:eroded(内缩) | closed(闭合区域) | pushed(发际线外推)(默认 eroded"),
swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask(改造换发型用接口9遮罩) | as_is(不改换发型,贴回再裁)(默认 ext_mask"),
feather_px: int = Form(default=15, description="羽化/渐变过渡宽度(像素,本接口固定 multiband 故不生效,仅留作兼容)"),
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"),
color_match: bool = Form(default=False, description="融合前对生成图做 Reinhard 颜色校正(消除整体色差),默认 false"),
hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米,仅 mask_type=pushed 生效),默认 1.0"),
hairline_edge: str = Form(default="column", description="发际线提取方式:column | contour(仅 pushed 生效),默认 column"),
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:发际线生发(固定 multiband/mb_levels=5/erode_cm=0.6,仅返回最终图)"""
"""接口12:发际线带重绘。同时产出 redraw_full(整帧美颜) 与 redraw_band(局部加发+全脸美颜) 两版对比"""
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
@@ -1392,42 +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:
# 固定三项:blend_method=multiband、mb_levels=5、erode_cm=0.6
data = await run_in_threadpool(
generate_hairline_grow, image, hairline_id, is_hr, seg_model,
mask_type, 0.6, swap_mode, "multiband", feather_px, edge_erode_px,
denoising_strength, gen_backend, hairgrow_strength, color_match, 5,
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge)
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=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,11 +1,14 @@
# 发际线生发遮罩算法(pushed 模式)
> 对应接口11 `/api/v1/hairline/grow`、接口12 `/api/v1/hairline/grow_v2``mask_type=pushed`
> 代码:`face_analysis/hairline_grow.py``_extract_hairline` / `_pushed_mask` / `compute_mask`
> 对应接口11 `/api/v1/hairline/grow`、接口12 `/api/v1/hairline/grow_v2`。
> 遮罩算法固定为 pushed;融合算法默认 multiband(多频段金字塔),接口11 可切换 seamless/two_stage/feather
> 代码:`face_analysis/hairline_grow.py``_extract_hairline` / `_pushed_mask` / `compute_mask` / `_composite`)。
## 概述
pushed 模式是发际线生发的默认遮罩算法(接口11/12 的 `mask_type` 三选一:`eroded` / `closed` / `pushed`,当前只用 pushed。它从头发分割结果中提取「头发/皮肤交界线」(发际线),以眉心为圆心逐点径向外推一段距离,与 baseline 组成闭合区域作为最终遮罩。这样遮罩顶部会覆盖现有头发下沿一小段,贴回生发结果时顶部与真头发重叠、过渡自然。
pushed 是发际线生发的**唯一**遮罩算法。融合算法默认 multiband(多频段金字塔),接口11 暴露 `blend_method` 可切换为 seamless(泊松)/two_stage(泊松→多频段两段式)/feather(羽化),便于对比调优。它从头发分割结果中提取「头发/皮肤交界线」(发际线),以眉心为圆心逐点径向外推一段距离,与 baseline 组成闭合区域作为最终遮罩。这样遮罩顶部会覆盖现有头发下沿一小段,贴回生发结果时顶部与真头发重叠、过渡自然。
> 接口12 `/api/v1/hairline/grow_v2` 只需传 `image` + `hairline_id`,遮罩和融合全部固定,无需任何算法选择参数。
## 算法流程(5 步)
@@ -54,15 +57,52 @@ segformer(默认)或 bisenet 得到的头发二值掩码。
## 关键参数
遮罩算法(pushed)固定。融合算法接口11 通过 `blend_method` 可切换(默认 multiband),其余融合参数均可调:
| 参数 | 默认 | 说明 |
|---|---|---|
| `mask_type` | `eroded`(接口默认)/ `pushed`(当前推荐) | pushed 走上述流程;eroded/closed 走旧的 top_fill→closed/eroded 流程 |
| `hairline_push_cm` | 1.0 | 内轮廓径向外推距离(厘米),= push_px / px_per_cm。`px_per_cm` 由虹膜直径标定 |
| `hairline_edge` | `column` | 兼容保留的入参;新版内轮廓提取(轮廓+内侧判定)不再按它分支,取值不影响结果 |
| `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 用默认「补充遮罩区域的头发,加一点美颜」|
## 与旧模式(eroded/closed)的区别
> 接口12 `/api/v1/hairline/grow_v2` 只需传 `image` + `hairline_id`,遮罩和融合全部用默认值(multiband + color_match=true),不暴露算法选择参数。
| | eroded/closed | pushed(当前) |
### 融合方法选择建议
- **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,已移除)的区别
| | eroded/closed(已移除) | pushed(当前唯一) |
|---|---|---|
| 遮罩来源 | top_fill(头发向下填充含额头)外缘内缩 | 头发内轮廓线 径向外推成带 |
| 截断方式 | 用 baseline 组上半区 upper | 内侧判定 + 下颌 y 截断(不再用 baseline |
+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
```
+86 -70
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`(四庭,自上而下):
@@ -128,6 +130,15 @@
| face_width_cm | number | 脸宽(cm |
| inter_eye_distance_cm | number | 两眼间距(cm |
| ratios | object | 七眼各段占脸宽的比例 |
| eye1 | number \| null | 从左到右第 1 段宽度(cm):人头最左 → 左脸颊(左耳外侧段)。该侧耳朵不可见时为 null |
| eye2 | number | 从左到右第 2 段宽度(cm):左脸颊 → 左眼外角 |
| eye3 | number | 从左到右第 3 段宽度(cm):左眼外角 → 左眼内角(左眼宽度) |
| eye4 | number | 从左到右第 4 段宽度(cm):左眼内角 → 右眼内角(两眼间距) |
| eye5 | number | 从左到右第 5 段宽度(cm):右眼内角 → 右眼外角(右眼宽度) |
| eye6 | number | 从左到右第 6 段宽度(cm):右眼外角 → 右脸颊 |
| eye7 | number \| null | 从左到右第 7 段宽度(cm):右脸颊 → 人头最右(右耳外侧段)。该侧耳朵不可见时为 null |
> `eye1`~`eye7` 为从左到右共 7 段宽度,与标注图竖线一一对应。最左/最右端线取自耳朵分割外缘;某侧耳朵被头发或侧脸遮挡(不可见)时该侧端线省略,对应 `eye1` 或 `eye7` 为 `null`(键始终保留),实际有效段为 5 或 6 段。`eye3`/`eye5` 为左右眼宽、`eye4` 为两眼间距,与 `eye_width_cm` / `inter_eye_distance_cm` 语义一致。
### 标注图片(UI)规范
@@ -161,7 +172,9 @@
"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 },
"eye1": 3.44, "eye2": 3.44, "eye3": 3.44, "eye4": 3.44,
"eye5": 3.44, "eye6": 3.44, "eye7": 3.44
},
"landmarks": {
"hair_top": { "x": 540, "y": 120 },
@@ -198,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 有)。
### 响应示例
@@ -217,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 },
@@ -233,10 +251,10 @@
## 接口 2C 端生发接口
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染预览图 + 生发图。
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染发际线曲线透明 PNG + 生发图。
> **每个方案返回两张图**`image_url`=「原照片 + 发际线曲线叠加的**预览图**」;`grown_image_url`=
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。
> **每个方案返回两张图**`image_url`=「发际线曲线**透明 PNG**(仅白色曲线,透明底,需叠加原图显示)」;`grown_image_url`=
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」(完整人像照片)。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。
**请求**`POST /api/v1/hair/grow`
@@ -250,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
@@ -257,14 +276,16 @@
| 字段 | 类型 | 说明 |
|------|------|------|
| image_url | string | 方案**预览图** URL(发际线曲线叠加图 |
| grown_image_url | string | **生发后图片** URLComfyUI/Flux「植发 3 个月」效果图) |
| image_url | string | 发际线曲线**透明 PNG** URL仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示 |
| grown_image_url | string | **生发后图片** URLComfyUI/Flux「植发 3 个月」效果图,完整人像照片 |
| hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`female),`ellipse`/`m`/`straight`/`inverse_arc`male |
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
> ⚠️ 生发图由本机 ComfyUIFlux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
> worker 侧返回 `image_base64` / `grown_image_base64`
> 网关落盘后改写为上表的 `image_url` / `grown_image_url`。
>
> 💡 `image_url` 为透明底 PNG,前端需用绝对定位叠加到原图上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface2.html)的 `.img-stack` 叠加结构)。
### 响应示例
@@ -302,6 +323,7 @@
|------|------|------|------|
| marked_image_* | file / string | 是 | 已用马克笔标注发际线的图片,三选一 |
| use_mask | bool | 否 | 是否画发际线,默认 `true``false` 时跳过划线检测、直接送划线图,模型仅凭手绘黑线生发,供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
### 输出(data
@@ -386,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 张生发图。
@@ -395,20 +418,40 @@
| 字段 | 类型 | 说明 |
|------|------|------|
| 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` 元素:
| 字段 | 类型 | 说明 |
|------|------|------|
| hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`female),`ellipse`/`m`/`straight`/`inverse_arc`male |
| image_middle_url | string | middle 档发际线叠加图 URL |
| image_high_url | string | high 档发际线叠加图 URL |
| image_low_url | string | low 档发际线叠加图 URL |
| grown_image_url | string \| null | **生发后图片** URLComfyUI「植发」效果图,生发失败时为 `null` |
| 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 | **生发后图片** URLComfyUI「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null` |
| order | int | 发型序号(= 传入的 hair_style 值) |
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。
>
> 💡 三档 `image_*_url` 为透明底 PNG,前端需用绝对定位叠加到原图上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface5.html)的 `.img-stack` 叠加结构)。`grown_image_url` 是完整人像照片,直接显示即可。
`face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段):
| 字段 | 类型 | 说明 |
|------|------|------|
| face_total_height_cm | number | 全脸总高度(cm= 四庭之和 |
| four_courts | object | 四庭数据(顶/上/中/下庭 cm + 占比 ratios),结构同接口1 |
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + eye1~eye7 从左到右 7 段宽度),结构同接口1 |
| 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说明。
### 响应示例(当前 Mock 返回值)
@@ -436,7 +479,34 @@
"order": 3
}
],
"best_hairline_center_point": { "x": 540, "y": 430 }
"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": {
"top_court_cm": 5.77, "upper_court_cm": 5.93,
"middle_court_cm": 7.62, "lower_court_cm": 7.44,
"ratios": { "top_court": 0.216, "upper_court": 0.222,
"middle_court": 0.285, "lower_court": 0.278 }
},
"seven_eyes": {
"eye_width_cm": 2.76, "face_width_cm": 15.08,
"inter_eye_distance_cm": 3.9,
"ratios": { "eye_width": 0.183, "inter_eye_distance": 0.259 },
"eye1": null, "eye2": 3.0, "eye3": 2.76, "eye4": 3.9,
"eye5": 2.76, "eye6": 3.0, "eye7": null
},
"landmarks": {
"hair_top": { "x": 504, "y": 103 },
"hairline": { "x": 504, "y": 228 },
"brow_center": { "x": 504, "y": 357 },
"nose_bottom": { "x": 505, "y": 522 },
"chin_tip": { "x": 506, "y": 683 }
},
"hairline_source": "segmentation",
"head_pose": { "yaw": -1.39, "pitch": 2.49, "roll": -0.06 }
}
}
}
```
@@ -445,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
}
]
}
}
```
---
## 汇总:输入输出一览
| 接口 | 输入 | 主要输出 |
@@ -508,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 的版本)
+463 -90
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,20 +64,22 @@ 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,
"seg_model": "segformer", # bisenet | segformer
"mask_type": "eroded", # eroded | closed
"erode_cm": 1.2,
"hairline_push_cm": 1.0, # 发际线径向外推距离(厘米)
"hairline_edge": "column", # column(逐列下沿)
"swap_mode": "ext_mask", # ext_mask | as_is(仅 swaphair
"denoising_strength": 0.6, # 仅 swaphair
"hairgrow_strength": 0.75, # 仅 hairgrow
"blend_method": "feather", # feather | alpha_gradient | seamless | multiband
"feather_px": 15,
"edge_erode_px": 3,
"color_match": False, # True 时对生成图做 Reinhard 颜色校正(seamless 下自动跳过)
"mb_levels": 5, # multiband 金字塔层数(2~6,越大色差抹得越宽)
"erode_cm": 0.6, # 接口12 固定值(pushed 模式下仅用于 baseline 截断参考,影响很小)
}
@@ -96,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 构件)
# ---------------------------------------------------------------------------
@@ -296,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。
@@ -305,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
@@ -318,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) # 闭合区域:头发+额头,底=基线
@@ -336,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))
@@ -363,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")}
@@ -412,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
@@ -432,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]
@@ -502,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)
@@ -555,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)
@@ -603,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]
@@ -620,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
@@ -661,22 +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",
mask_type="eroded", erode_cm=1.2, swap_mode="ext_mask",
blend_method="feather", feather_px=15, edge_erode_px=3,
denoising_strength=0.6, gen_backend="swaphair",
hairgrow_strength=0.75, color_match=False, mb_levels=5,
hairline_push_cm=0.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
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。
"""
if rid is None:
rid = uuid4().hex[:8]
logger.info("[%s] ===== generate_hairline_grow 开始 =====", rid)
logger.info("[%s] 参数: mask_type=%r erode_cm=%s blend=%s hairline_push_cm=%s hairline_edge=%r "
"seg=%s gen_backend=%s swap_mode=%s", rid, mask_type, erode_cm, blend_method,
hairline_push_cm, hairline_edge, seg_model, gen_backend, swap_mode)
mask_type = "pushed" # 固定:只支持 pushed 遮罩算法
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, 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:
@@ -685,11 +880,12 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
px_per_cm = estimate_scale_factor(landmarks, w, h)
logger.info("[%s] 人脸检出 px_per_cm=%.3f 图尺寸=%dx%d", rid, px_per_cm, w, h)
# 步骤1:接口9 遮罩
# 步骤1:接口9 遮罩(固定 pushed
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()))
@@ -699,47 +895,105 @@ 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:接缝融合
# 步骤4:接缝融合(默认 multiband)→ ④ final
t0 = time.time()
final, alpha = _composite(
image_bgr, swap_result, mask_bool, blend_method, feather_px, edge_erode_px,
color_match=color_match, mb_levels=mb_levels)
image_bgr, swap_result, mask_bool, blend_method, 0, edge_erode_px,
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,
"feather_px": int(feather_px),
"edge_erode_px": int(edge_erode_px),
"color_match": bool(color_match) and blend_method != "seamless",
"mb_levels": int(mb_levels),
"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
+15 -12
View File
@@ -2,8 +2,8 @@
流程(详见需求讨论):
1. MediaPipe 关键点检测。
2. 底部分割线 = 关键点 [21,68,104,69,108,151,337,299,333,298,251] 的连线(左端21→中心151→右端251),
再把左端点 21 水平延伸到图片最左边、右端点 251 水平延伸到图片最右边。
2. 底部分割线 = 关键点 [162,71,68,104,69,108,151,337,299,333,298,301,389] 的连线(左端162→中心151→右端389),
再把左端点 162 水平延伸到图片最左边、右端点 389 水平延伸到图片最右边。
3. 上半区 = 分割线以上区域(多边形填充:左边缘→弧线→右边缘→上边缘闭合)。
4. 头发分割:BiSeNet 与 SegFormer 各出一张 hair_mask(两套供对比)。
5. 闭合区域(含额头):每列从最顶端头发像素向下填充到分割线,把头发与画线之间的额头皮肤
@@ -22,8 +22,11 @@ import numpy as np
from face_analysis.detector import detector
from face_analysis.calibration import estimate_scale_factor, normalized_to_pixel
# 底部额头弧线关键点(图像上从左到右:左端 21 → 中心 151 → 右端 251
BASELINE_IDX = [21, 68, 104, 69, 108, 151, 337, 299, 333, 298, 251]
# 底部分割线关键点(图像上从左到右,眉骨弧线 → 中心 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 类索引
@@ -50,12 +53,12 @@ def _baseline_points(landmarks, w, h):
def _upper_region_mask(baseline_pts, w, h):
"""分割线以上区域(boolH×W)。
多边形顶点:左上角 →(0, y54)→ 弧线各点 →(w-1, y284)→ 右上角,闭合后填充。
其中 54→左边缘、284→右边缘为两段水平延长线
多边形顶点:左上角 →(0, y左端)→ 弧线各点 →(w-1, y右端)→ 右上角,闭合后填充。
左端/右端为两段水平延长线(向图片左右边缘延伸)
"""
x54, y54 = baseline_pts[0]
x284, y284 = baseline_pts[-1]
poly = [(0, 0), (0, y54)] + baseline_pts + [(w - 1, y284), (w - 1, 0)]
x0, y0 = baseline_pts[0]
x1, y1 = baseline_pts[-1]
poly = [(0, 0), (0, y0)] + baseline_pts + [(w - 1, y1), (w - 1, 0)]
mask = np.zeros((h, w), np.uint8)
cv2.fillPoly(mask, [np.array(poly, np.int32)], 1)
return mask.astype(bool)
@@ -108,9 +111,9 @@ def _overlay(image, mask_bool, color, alpha=0.45):
def _draw_baseline(image, baseline_pts, w):
"""画分割线(含左右水平延长线)+ 关键点,中心点 151 标红。"""
out = image.copy()
y54 = baseline_pts[0][1]
y284 = baseline_pts[-1][1]
chain = [(0, y54)] + baseline_pts + [(w - 1, y284)]
y0 = baseline_pts[0][1]
y1 = baseline_pts[-1][1]
chain = [(0, y0)] + baseline_pts + [(w - 1, y1)]
for a, b in zip(chain[:-1], chain[1:]):
cv2.line(out, a, b, (0, 255, 255), 2, cv2.LINE_AA)
for idx, p in zip(BASELINE_IDX, baseline_pts):
+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(竖轴)转 → 左右扭头
+15 -6
View File
@@ -37,7 +37,8 @@ _PROMPT = (
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
)
_client = None
_client = None # 缓存的 Ark clientapi_key 变更时自动重建)
_client_key: str | None = None # _client 构建时使用的 api_key,用于检测配置变更
def _load_api_key() -> str | None:
@@ -60,13 +61,21 @@ def _load_api_key() -> str | None:
def get_client():
global _client
if _client is None:
"""返回 Ark client。
client 全局缓存,但每次都会重新读取 api_key —— 一旦配置(环境变量 /
worker_config.json / gateway/config.json 的 ark_api_key)发生变化,
自动重建 client。这样换 key 后无需重启进程。
"""
global _client, _client_key
key = _load_api_key()
if not key:
raise RuntimeError("缺少火山方舟 API Key(设 ARK_API_KEY 或 worker_config.json.ark_api_key")
# client 未建、或 key 变了 → 重建
if _client is None or key != _client_key:
from volcenginesdkarkruntime import Ark
key = _load_api_key()
if not key:
raise RuntimeError("缺少火山方舟 API Key(设 ARK_API_KEY 或 worker_config.json.ark_api_key")
_client = Ark(base_url=ARK_BASE_URL, api_key=key)
_client_key = key
return _client
+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)
+278 -38
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)
@@ -141,7 +201,7 @@ def generate_previews(image_bgr: np.ndarray, gender: str):
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
prompt: str = None, hair_styles: list[int] | None = None,
workflow_path: str | None = None):
"""指定发际线类型:预览图(白线) + 生发图(ComfyUI)。
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
hair_styles1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5male: 1..4。
为 None 时生成全部(兼容旧调用)。
@@ -149,7 +209,8 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。
prompt(默认 None):ComfyUI 提示词,非 None 时替换工作流节点60文本。
workflow_path(默认 None):ComfyUI 工作流 JSON 路径,None 用默认 add_hair.json。
Returns: list[dict] {"hairline_type","order","image_bgr"(预览), "grown_png"(bytes 或 None)}。
Returns: list[dict] {"hairline_type","order","overlay"((H,W,4) RGBA 透明层),
"grown_png"(bytes 或 None)}。
无人脸返回 None。某张 ComfyUI 失败时该项 grown_png=None,不抛异常。
"""
if gender not in ("male", "female"):
@@ -170,16 +231,22 @@ 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)
results = []
h, w = image_bgr.shape[:2]
for order, (key, white_path) in items:
white = load_texture_rgba(white_path)
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white)
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
if not use_mask:
grown_png = shared_grown
@@ -189,14 +256,118 @@ 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)
results.append({"hairline_type": key, "order": order,
"image_bgr": preview, "grown_png": grown_png})
"overlay": overlay, "grown_png": grown_png})
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
@@ -216,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)
@@ -225,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):
"""接口5:对选中发型返回 middle/high/low 三档发际线叠图 + 生发图(同接口2)。
prompt: str | None = None,
generate_grow_image: bool = True):
"""接口5:对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。
入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。
每个选中发型返回三档叠图(middle/high/low)与一张生发图;三档贴图同名,
生发黑模板固定取自 hairline_texture_black/middle),故生发目标固定 middle 档。
每个选中发型返回三档叠图(middle/high/lowRGBA 透明层只含发际线曲线)与一张生发图;
三档贴图同名,生发黑模板固定取自 hairline_texture_black/middle),故生发目标固定 middle 档。
use_mask/prompt:同接口2 的生发参数。
Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}(BGR),grown_png}],
"best_center":(x,y)};无人脸 None。best_center 取首个选中发型的 middle 档
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_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}")
@@ -252,32 +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] = render_hairline_overlay(image_bgr, 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)
overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
# 生发:固定 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:
white = load_texture_rgba(mid_path)
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
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):
@@ -286,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)
@@ -309,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 种发际线发型 × {高清, 非高清} = 200 张输出
并发 4失败的跳过并记录原因结果图落盘到 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 = [(True, "hr"), (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": True, "tag": "hr"}, {"is_hr": False, "tag": "nohr"}],
"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()
-229
View File
@@ -1,229 +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:140px repeat({len(hairstyles)*2}, 1fr);
gap:8px; align-items:start; }}
.col-hdr {{ font-size:12px; color:var(--muted); text-align:center; padding:6px 4px;
border-bottom:1px solid var(--border); }}
.row-lbl {{ font-size:13px; color:var(--accent); padding:8px 4px;
border-right:1px solid var(--border); }}
.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">固定参数blend_method=multiband · mb_levels=5 · erode_cm=0.6 · 生成后端=swaphair</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 种发际线发型,每种发型分 '
'<span class="tag">高清 is_hr=true</span>'
'<span class="tag no">非高清 is_hr=false</span> 两列。</div>')
# 每张脸一个区块
for stem in faces:
orig_rel = f"orig/{stem}.jpg"
parts.append('<div class="face-block">')
parts.append(f' <div class="face-head">'
f'<img class="orig-thumb zoomable" src="{url(orig_rel)}" '
f'data-full="{url(orig_rel)}" alt="原图 {html.escape(stem)}">'
f'<h2>{html.escape(stem)}</h2>'
f'</div>')
# 网格表头
parts.append('<div class="grid">')
parts.append('<div class="col-hdr"></div>')
for h in hairstyles:
parts.append(f'<div class="col-hdr" style="grid-column:span 2;">{html.escape(h["cn"])}<br><span style="opacity:.6">{html.escape(h["id"])}</span></div>')
# 行:原图占满左侧第一列的高度由第一行承载;每行 = 一个 hr 选项
for hro in hr_opts:
hr_tag = hro["tag"]
is_hr = hro["is_hr"]
tag_cls = "" if is_hr else " no"
tag_txt = "高清" if is_hr else "非高清"
parts.append(f'<div class="row-lbl">{tag_txt}<span class="tag{tag_cls}">{hr_tag}</span></div>')
for h in hairstyles:
r = idx.get((stem, h["id"], hr_tag))
if r and r["ok"]:
ms = r["ms"]
cap = f"{html.escape(h['cn'])} · {tag_txt} · {ms}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"])} {hr_tag}">'
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>高清</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["hr_tag"]}</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> ·
固定 multiband / mb_levels=5 / erode_cm=0.6 ·
数据源 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;
})();
+44 -48
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>
@@ -103,10 +102,14 @@
<table>
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
<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>四庭:top/upper/middle/lower,各含 _cm 和 ratios</td></tr>
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:eye_width/face_width/inter_eye_distance,各含 _cm 和 ratios</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>四庭:top/upper/middle/lower,各含 <code>_cm</code><code>ratios</code></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> + <code>eye1</code>~<code>eye7</code>(从左到右 7 段宽度 cm<code>eye1</code>/<code>eye7</code> 耳朵不可见时为 null</td></tr>
<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>
@@ -131,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>
@@ -144,12 +147,14 @@ const { code, data } = await res.json();
<p style="margin-top:12px"><strong>data.results[] 元素</strong></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>生发后效果图(ComfyUI/Flux「植发3个月」)⚠ 可空</td></tr>
<tr><td><code>image_url</code></td><td>string</td><td>发际线曲线<strong>透明 PNG</strong>(仅白色曲线,透明底,需叠加原图显示</td></tr>
<tr><td><code>grown_image_url</code></td><td>string</td><td>生发后效果图(ComfyUI/Flux「植发3个月」完整人像)⚠ 可空</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>排序(1=最佳,当前按贴图顺序)</td></tr>
</table>
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 <code>image_url</code> 是透明底 PNG,前端需用绝对定位叠加到原图上显示(参考 <a href="/static/test_interface2.html">测试页</a><code>.img-stack</code> 叠加结构)。</p>
<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>
@@ -185,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>
@@ -210,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>
@@ -226,14 +231,33 @@ 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> 三档叠图<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>
<p style="margin-top:12px"><code>face_measure</code> 字段(与接口1 的 <code>data</code> 同构,<strong>不含</strong> <code>annotated_image_*</code>):</p>
<table>
<tr><th>字段</th><th>类型</th><th>说明</th></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>四庭:top/upper/middle/lower,各含 <code>_cm</code><code>ratios</code></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> + <code>eye1</code>~<code>eye7</code>(从左到右 7 段宽度 cm<code>eye1</code>/<code>eye7</code> 耳朵不可见时为 null</td></tr>
<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>
</div>
</div>
@@ -260,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>
@@ -306,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>
@@ -324,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);
+21 -68
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">
@@ -113,32 +114,23 @@
</div>
<div class="pf">
<label>mask_type <span class="desc">遮罩类型</span></label>
<select id="maskType">
<option value="eroded">eroded(外缘内缩,默认)</option>
<option value="closed">closed(闭合区域,未内缩)</option>
<option value="pushed">pushed(发际线外推)</option>
</select>
</div>
<div class="pf">
<label>erode_cm <span class="desc">外缘内缩距离(cm)</span></label>
<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="1.2">
<input type="range" id="erodeRange" min="0" max="3" step="0.1" value="1.2">
<input type="number" id="erodeCm" min="0" max="5" step="0.1" value="0.6">
<input type="range" id="erodeRange" min="0" max="3" step="0.1" value="0.6">
</div>
</div>
<div class="pf" data-show="mask:pushed">
<label>hairline_push_cm <span class="desc">发际线外推距离(仅pushed)</span></label>
<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">
<input type="range" id="hairlinePushRange" min="0" max="3" step="0.1" value="1.0">
</div>
</div>
<div class="pf" data-show="mask:pushed">
<label>hairline_edge <span class="desc">发际线提取方式(仅pushed)</span></label>
<div class="pf">
<label>hairline_edge <span class="desc">发际线提取方式</span></label>
<select id="hairlineEdge">
<option value="column">column(逐列最低点,稳定)</option>
<option value="contour">contour(形态学轮廓,精确)</option>
@@ -146,30 +138,7 @@
</div>
<div class="pf">
<label>blend_method <span class="desc">接缝融合算法</span></label>
<select id="blendMethod">
<option value="feather">feather(高斯羽化,默认)</option>
<option value="alpha_gradient">alpha_gradient(距离变换内渐变)</option>
<option value="seamless">seamless(泊松无缝克隆)</option>
<option value="multiband">multiband(多频段金字塔融合)</option>
</select>
</div>
<div class="pf" data-show="blend:feather,alpha_gradient,multiband">
<label>color_match <span class="desc">颜色校正前置</span></label>
<div class="chk"><input type="checkbox" id="colorMatch"><span style="font-size:13px">融合前对生成图做 Reinhard 颜色校正(消除整体色差,seamless 自动跳过)</span></div>
</div>
<div class="pf" data-show="blend:feather,alpha_gradient">
<label>feather_px <span class="desc">羽化/渐变过渡宽度(px)</span></label>
<div class="row">
<input type="number" id="featherPx" min="1" max="80" step="1" value="15">
<input type="range" id="featherRange" min="1" max="60" step="1" value="15">
</div>
</div>
<div class="pf" data-show="blend:multiband">
<label>mb_levels <span class="desc">多频段金字塔层数(仅multiband)</span></label>
<label>mb_levels <span class="desc">多频段金字塔层数</span></label>
<div class="row">
<input type="number" id="mbLevels" min="2" max="6" step="1" value="5">
<input type="range" id="mbLevelsRange" min="2" max="6" step="1" value="5">
@@ -291,9 +260,8 @@ function renderMetrics(d) {
{ label: 'hairline_id', value: d.hairline_id },
{ label: 'swap_mode', value: d.swap_mode },
{ label: 'seg_model', value: d.seg_model },
{ label: 'mask_type', value: d.mask_type },
{ label: 'mask', value: d.mask_type },
{ label: 'blend', value: d.blend_method },
{ label: 'color_match', value: d.color_match },
{ label: 'mb_levels', value: d.mb_levels },
{ label: 'hairline_push', value: d.hairline_push_cm + 'cm' },
{ label: 'hairline_edge', value: d.hairline_edge },
@@ -317,19 +285,17 @@ function renderResult(d) {
$('finalOut').onclick = function(){ zoom(this.src); };
renderMetrics(d);
const grid = $('stepsGrid'); grid.innerHTML = '';
// 按 mask_type 过滤步骤:pushed 只显示 baseline/upper/头发分割/发际线/外推;
// eroded/closed 只显示 baseline/upper/头发分割/填充/闭合,不显示发际线/外推。
const mt = d.mask_type;
const showKeys = new Set(['baseline_overlay','upper_overlay','hair_seg_overlay','mask_overlay','mask','swap_raw','hard_paste','alpha','final']);
if (mt === 'pushed') { showKeys.add('hairline_overlay'); showKeys.add('pushed_overlay'); }
else { showKeys.add('top_fill_overlay'); showKeys.add('closed_overlay'); }
// 固定 pushed:只展示 pushed 相关步骤(不展示 top_fill/closed
const showKeys = new Set(['baseline_overlay','upper_overlay','hair_seg_overlay',
'hairline_overlay','pushed_overlay','mask_overlay','mask','swap_raw','hard_paste','alpha','final']);
STEPS.filter(st => showKeys.has(st.key)).forEach(st =>
grid.appendChild(stepCard(st.title, st.sub, pick(s, st.key))));
}
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 = '⏳ 请求中...';
@@ -343,18 +309,13 @@ async function submitTest() {
form.append('hairgrow_strength', $('hgStrength').value || '0.75');
form.append('is_hr', $('isHr').checked ? 'true' : 'false');
form.append('seg_model', $('segModel').value);
form.append('mask_type', $('maskType').value);
form.append('erode_cm', $('erodeCm').value || '1.2');
setStatus('正在请求 mask_type=' + $('maskType').value
+ ' hairline_push_cm=' + ($('hairlinePushCm').value||'1.0')
form.append('erode_cm', $('erodeCm').value || '0.6');
setStatus('正在请求(固定 pushed + multiband hairline_push_cm=' + ($('hairlinePushCm').value||'1.0')
+ ' hairline_edge=' + $('hairlineEdge').value
+ '(换发型走 GPU,约 10~15s...', 'info');
form.append('swap_mode', $('swapMode').value);
form.append('blend_method', $('blendMethod').value);
form.append('feather_px', $('featherPx').value || '15');
form.append('edge_erode_px', $('edgeErodePx').value || '3');
form.append('denoising_strength', $('denoise').value || '0.6');
form.append('color_match', $('colorMatch').checked ? 'true' : 'false');
form.append('mb_levels', $('mbLevels').value || '5');
form.append('hairline_push_cm', $('hairlinePushCm').value || '1.0');
form.append('hairline_edge', $('hairlineEdge').value);
@@ -397,7 +358,7 @@ function copyJson() {
}
// ---- 参数持久化 + 联动 ----
const FIELDS = ['token','genBackend','hairlineId','swapMode','segModel','maskType','erodeCm','blendMethod','featherPx','edgeErodePx','denoise','hgStrength','mbLevels','hairlinePushCm','hairlineEdge'];
const FIELDS = ['token','genBackend','hairlineId','swapMode','segModel','erodeCm','edgeErodePx','denoise','hgStrength','mbLevels','hairlinePushCm','hairlineEdge'];
function saveField(id) { try { localStorage.setItem('if11_' + id, $(id).type === 'checkbox' ? ($(id).checked?'1':'0') : $(id).value); } catch(e){} }
function linkNumRange(numId, rngId) {
const num = $(numId), rng = $(rngId);
@@ -419,42 +380,34 @@ document.addEventListener('DOMContentLoaded', () => {
});
try { $('isHr').checked = localStorage.getItem('if11_isHr') === '1'; } catch(e){}
$('isHr').addEventListener('change', () => saveField('isHr'));
try { $('colorMatch').checked = localStorage.getItem('if11_colorMatch') === '1'; } catch(e){}
$('colorMatch').addEventListener('change', () => saveField('colorMatch'));
linkNumRange('erodeCm','erodeRange');
linkNumRange('featherPx','featherRange');
linkNumRange('edgeErodePx','edgeErodeRange');
linkNumRange('denoise','denoiseRange');
linkNumRange('hgStrength','hgStrengthRange');
linkNumRange('mbLevels','mbLevelsRange');
linkNumRange('hairlinePushCm','hairlinePushRange');
// 初始化滑块值
$('erodeRange').value = Math.min(3, parseFloat($('erodeCm').value)||1.2);
$('featherRange').value = Math.min(60, parseFloat($('featherPx').value)||15);
$('erodeRange').value = Math.min(3, parseFloat($('erodeCm').value)||0.6);
$('edgeErodeRange').value = Math.min(30, parseFloat($('edgeErodePx').value)||3);
$('denoiseRange').value = Math.min(1.0, parseFloat($('denoise').value)||0.6);
$('hgStrengthRange').value = Math.min(1.0, parseFloat($('hgStrength').value)||0.75);
$('mbLevelsRange').value = Math.min(6, Math.max(2, parseFloat($('mbLevels').value)||5));
$('hairlinePushRange').value = Math.min(3, parseFloat($('hairlinePushCm').value)||1.0);
// 参数联动:按 gen_backend / blend_method 显隐无用参数(data-show 声明)
// 参数联动:按 gen_backend 显隐无用参数(data-show 声明,遮罩/融合已固定无选项
function applyVisibility() {
const gen = $('genBackend').value, bm = $('blendMethod').value, mt = $('maskType').value;
const gen = $('genBackend').value;
document.querySelectorAll('.pf[data-show]').forEach(el => {
const conds = el.dataset.show.split(',').map(s => s.trim());
let vis = true;
conds.forEach(c => {
const [k, v] = c.split(':');
if (k === 'gen' && v !== gen) vis = false;
if (k === 'blend' && v !== bm) vis = false;
if (k === 'mask' && v !== mt) vis = false;
});
el.style.display = vis ? '' : 'none';
});
}
$('genBackend').addEventListener('change', applyVisibility);
$('blendMethod').addEventListener('change', applyVisibility);
$('maskType').addEventListener('change', applyVisibility);
applyVisibility();
});
</script>
+168 -91
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>
重点排查:mask_type 是否真的传成 pushed、①-f/①-g 是否有图、最终遮罩是否走了新算法
重点排查:遮罩是否走 pushed、①-f/①-g 是否有图、最终遮罩是否正确。遮罩固定 pushed;默认 blend=two_stage、redraw 开、发际线波浪
</div>
<div class="card">
@@ -68,56 +73,77 @@
<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>mask_type <span class="desc">遮罩类型(关键!)</span></label>
<select id="maskType">
<option value="eroded">eroded(外缘内缩)</option>
<option value="closed">closed(闭合区域)</option>
<option value="pushed">pushed(发际线外推)★</option>
</select>
</div>
<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>
<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>erode_cm <span class="desc">外缘内缩(cm)</span></label>
<div class="row"><input type="number" id="erodeCm" min="0" max="5" step="0.1" value="1.2"></div>
</div>
<div class="pf" data-show="mask:pushed">
<label>hairline_push_cm <span class="desc">发际线外推(cm,仅pushed)</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" data-show="mask:pushed">
<label>hairline_edge <span class="desc">发际线提取(仅pushed)</span></label>
<select id="hairlineEdge">
<option value="column">column(逐列最低点)</option>
<option value="contour">contour(形态学轮廓)</option>
</select>
<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>
<label>blend_method <span class="desc">接缝融合</span></label>
<select id="blendMethod">
<option value="feather">feather</option>
<option value="alpha_gradient">alpha_gradient</option>
<option value="seamless">seamless</option>
<option value="multiband">multiband</option>
<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。默认 blend=two_stage;多频带融合参数与 change_hair 换发型参数已在上方可调,其余隐藏参数按原默认值随请求提交。<b>本接口不含重绘</b>,发际线带重绘见 <a href="/static/test_interface12.html">接口12 测试页</a></div>
</div>
<div class="status hidden" id="statusBar"></div>
@@ -126,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>
@@ -171,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); }
@@ -191,54 +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 开始mask_type=' + d.mask_type, '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 = '';
// 按 mask_type 过滤步骤
const mt = d.mask_type;
const showKeys = new Set(['baseline_overlay','upper_overlay','hair_seg_overlay','mask_overlay','mask','swap_raw','hard_paste','alpha','final']);
if (mt === 'pushed') { showKeys.add('hairline_overlay'); showKeys.add('pushed_overlay'); }
else { showKeys.add('top_fill_overlay'); showKeys.add('closed_overlay'); }
$('stepsInfo').textContent = '(mask_type=' + mt + ',按此过滤步骤)';
// 固定 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 = '(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');
// 记录前端实际读取的每个参数值(关键诊断)
const mt = $('maskType').value;
flog('前端读取 mask_type=' + mt, '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('正在请求 mask_type=' + mt + ' ...', 'info');
setStatus('正在请求 (mask=pushed, blend=' + $('blendMethod').value + ')...', 'info');
$('resultsArea').classList.remove('hidden');
const form = new FormData();
@@ -246,19 +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('mask_type', mt);
form.append('erode_cm', $('erodeCm').value || '1.2');
form.append('is_hr', HIDDEN.is_hr);
form.append('seg_model', HIDDEN.seg_model);
form.append('swap_mode', 'ext_mask');
form.append('blend_method', $('blendMethod').value);
form.append('feather_px', '15');
form.append('edge_erode_px', '3');
form.append('edge_erode_px', $('edgeErodePx').value || '3');
form.append('denoising_strength', '0.6');
form.append('color_match', 'false');
form.append('mb_levels', '5');
form.append('hairline_push_cm', $('hairlinePushCm').value || '1.0');
form.append('hairline_edge', $('hairlineEdge').value);
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('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 = {};
@@ -279,8 +359,10 @@ async function submitTest() {
if (json.code === 0) {
const d = json.data;
flog('后端返回 mask_type=' + d.mask_type + ' mask_pixels=' + d.mask_pixels, 'info');
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 || {};
@@ -327,20 +409,15 @@ async function downloadBackendLog() {
}
}
// 参数联动
function applyVisibility() {
const mt = $('maskType').value;
document.querySelectorAll('.pf[data-show]').forEach(el => {
const [k, v] = el.dataset.show.split(':');
el.style.display = (k === 'mask' && v === mt) ? '' : 'none';
});
}
$('maskType').addEventListener('change', function(){ flog('mask_type 切换为: ' + this.value, 'info'); applyVisibility(); });
// 步骤序号 → 对应英文 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('请选择图片,mask_type 切到 pushed,然后点提交', 'info');
applyVisibility();
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>
+21 -10
View File
@@ -59,12 +59,17 @@
.scheme-imgs .img-slot .thumb { background: #222; border-radius: 6px; overflow: hidden; line-height: 0; }
.scheme-imgs .img-slot .thumb img { width: 100%; max-height: 220px; object-fit: contain; display: block; }
.scheme-imgs .img-slot .na { color: #d1d5db; font-size: 12px; padding: 30px 0; background: #f9fafb; border-radius: 6px; }
/* 原图 + 透明 PNG 叠加显示 */
.img-stack { position: relative; display: inline-block; line-height: 0; }
.img-stack .layer-base { display: block; width: 100%; max-height: 220px; object-fit: contain; }
.img-stack .layer-anno { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; }
.json-panel { max-height: 550px; overflow: auto; }
.json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.hidden { display: none !important; }
</style>
<script src="/static/img_downscale.js?v=2"></script>
</head>
<body>
<div class="container">
@@ -102,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>
@@ -136,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 = '⏳ 请求中...';
@@ -152,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);
@@ -175,27 +180,33 @@ 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">'+
'<div class="label">📷 原图</div>'+
'<div class="thumb"><img src="'+_origUrl+'" alt="原图"></div></div>';
// 发际线叠加
// 发际线叠加(原图 + 透明 PNG 叠加显示)
const overlaySlot = '<div class="img-slot">'+
'<div class="label"><span class="dot preview"></span>发际线叠加</div>'+
'<div class="thumb"><img src="'+r.image_url+'" alt="叠加"></div></div>';
'<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="'+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);
+157 -48
View File
@@ -45,15 +45,11 @@
.col-main { flex: 1.5; min-width: 0; }
.col-side { flex: 1; min-width: 0; }
/* 方案网格 */
.results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 14px; margin-top: 12px; }
.result-card { background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); overflow: hidden; cursor: pointer; transition: .15s; border: 3px solid transparent; }
.result-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,.12); }
.result-card.selected { border-color: #2563eb; }
.result-card .img-wrap { background: #222; min-height: 150px; max-height: 200px; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.result-card .img-wrap img { max-width: 100%; max-height: 200px; object-fit: contain; }
.result-card .info { padding: 10px 12px; font-size: 12px; display: flex; justify-content: space-between; align-items: center; }
.result-card .info .badge { background: #2563eb; color: #fff; padding: 1px 8px; border-radius: 10px; font-size: 11px; }
/* 每个发型区块 */
.hair-block { padding: 16px 0; border-bottom: 1px solid #f0f0f0; }
.hair-block:first-child { padding-top: 4px; }
.hair-block:last-child { border-bottom: none; padding-bottom: 4px; }
.hair-block-title { font-size: 15px; font-weight: 700; color: #111827; margin-bottom: 10px; }
/* 三档 + 生发图 横向排列 */
.level-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
@@ -61,6 +57,10 @@
.level-cell .level-label { font-size: 12px; font-weight: 600; color: #fff; background: rgba(0,0,0,.55); padding: 4px 8px; }
.level-cell img { width: 100%; max-height: 380px; object-fit: contain; display: block; }
.level-cell .level-none { color: #9ca3af; font-size: 13px; padding: 28px 0; text-align: center; }
/* 原图 + 透明 PNG 叠加(三档叠图用) */
.level-cell .img-stack { position: relative; line-height: 0; }
.level-cell .img-stack .layer-base { width: 100%; max-height: 380px; object-fit: contain; display: block; }
.level-cell .img-stack .layer-anno { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; }
@media (max-width: 700px) { .level-row { grid-template-columns: repeat(2, 1fr); } }
/* 坐标高亮 */
@@ -68,12 +68,27 @@
.coord-box .label { font-size: 11px; color: #9ca3af; margin-bottom: 4px; }
.coord-box .value { font-weight: 700; font-size: 18px; color: #111827; }
/* 四庭七眼测量(face_measure */
.fm-section { margin-bottom: 18px; }
.fm-section:last-child { margin-bottom: 0; }
.fm-title { font-size: 13px; font-weight: 700; color: #374151; margin-bottom: 8px; }
.fm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
.fm-cell { background: #f9fafb; border-radius: 6px; padding: 8px 10px; border: 1px solid #f0f0f0; }
.fm-cell .k { font-size: 11px; color: #9ca3af; margin-bottom: 2px; }
.fm-cell .v { font-size: 15px; font-weight: 700; color: #111827; }
.fm-cell .v .sub { font-size: 11px; font-weight: 500; color: #6b7280; margin-left: 4px; }
.fm-cell.muted .v { color: #9ca3af; font-weight: 600; }
.fm-tag { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-left: 6px; font-weight: 600; }
.fm-tag.ok { background: #d1fae5; color: #065f46; }
.fm-tag.warn { background: #fef3c7; color: #92400e; }
.json-panel { max-height: 550px; overflow: auto; }
.json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.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">
@@ -98,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>
@@ -108,21 +129,25 @@
<div class="results-layout hidden" id="resultsArea">
<div class="col-main">
<!-- 预览 -->
<!-- 四庭七眼测量(face_measure,复用接口1数值) -->
<div class="card">
<div class="card-header"><span>📐 三档发际线叠图 + 生发图</span></div>
<div class="card-body" style="text-align:center">
<div id="previewArea"><span style="color:#9ca3af">← 点击下方发型卡片</span></div>
<div class="coord-box">
<div class="label">📍 最佳发际线中心点(best_hairline_center_point,首个选中发型 middle 档)— 原图像素坐标</div>
<div class="value" id="centerPoint"></div>
</div>
</div>
<div class="card-header"><span>📐 四庭七眼测量(face_measure</span><span style="font-weight:400;font-size:12px;color:#9ca3af">复用接口1测量数值</span></div>
<div class="card-body"><div id="faceMeasureArea"><span style="color:#9ca3af"></span></div></div>
</div>
<!-- 发型列表 -->
<!-- 所有发型平铺 -->
<div class="card">
<div class="card-header"><span>🎯 选中发型</span><span style="font-weight:400;font-size:12px;color:#9ca3af">点击切换预览</span></div>
<div class="card-body"><div class="results-grid" id="resultsGrid"></div></div>
<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">📍 发际线中心点(首个发型三档)— 原图像素坐标</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>
</div>
</div>
<div class="col-side">
@@ -177,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);
@@ -197,10 +223,14 @@ 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();
if (_images.length) selectCard(0);
renderFaceMeasure(json.data.face_measure);
} else {
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
}
@@ -211,38 +241,117 @@ async function submitTest() {
}
function renderGrid() {
if (!_images.length) { $('resultsGrid').innerHTML = '<span style="color:#9ca3af">无数据</span>'; return; }
let h = '';
_images.forEach((img, i) => {
h += '<div class="result-card'+(i===0?' selected':'')+'" onclick="selectCard('+i+',this)">'+
'<div class="img-wrap"><img src="'+img.image_middle_url+'" alt="#'+img.order+'" loading="lazy"></div>'+
'<div class="info"><span class="badge">#'+(img.order||'—')+' '+(img.hairline_type||'')+'</span>'+
(img.grown_image_url?'<span style="font-size:11px;color:#7c3aed">🌱 生发</span>':'')+'</div></div>';
// 叠图档:原图打底 + 透明 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="'+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, 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||'') +
(grown ? ' &nbsp;<span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' +
'<div class="level-row">' +
overlayCell('middle', mid) +
overlayCell('high', high) +
overlayCell('low', low) +
grownCell('生发图', grown) +
'</div>' +
'</div>';
});
$('resultsGrid').innerHTML = h;
}
function selectCard(idx, el) {
document.querySelectorAll('.result-card').forEach(c => c.classList.remove('selected'));
if (el) el.classList.add('selected');
const it = _images[idx];
const cell = (label, url) => url
? '<div class="level-cell"><div class="level-label">'+label+'</div><img src="'+url+'" alt="'+label+'"></div>'
: '<div class="level-cell"><div class="level-label">'+label+'</div><div class="level-none"></div></div>';
$('previewArea').innerHTML =
'<div class="level-row">'+
cell('middle', it.image_middle_url)+
cell('high', it.image_high_url)+
cell('low', it.image_low_url)+
cell('🌱 生发图', it.grown_image_url)+
'</div>';
}
function clearResults() {
_images=[]; _origUrl=''; _center=null;
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
$('imageFile').value=''; $('jsonContent').textContent=''; $('resultsGrid').innerHTML='';
$('previewArea').innerHTML='<span style="color:#9ca3af">← 点击下方发型卡片</span>';
$('centerPoint').textContent='—';
$('resultCount').textContent = '共 0 个发型';
$('faceMeasureArea').innerHTML = '<span style="color:#9ca3af"></span>';
}
// 四庭七眼测量渲染(face_measure,接口1数值)
const _PCT = r => (r == null ? '' : '' + (r * 100).toFixed(1) + '%');
const _FMT = v => (v == null ? '—' : Number(v).toFixed(2));
function renderFaceMeasure(fm) {
const box = $('faceMeasureArea');
if (!fm) {
box.innerHTML = '<div style="padding:24px;text-align:center;color:#9ca3af;font-size:14px">⚠️ face_measure = null(测量失败或未启用)</div>';
return;
}
// 发际线来源 + 头部姿态
const src = fm.hairline_source;
const srcTag = src === 'segmentation'
? '<span class="fm-tag ok">segmentation 真实分割</span>'
: (src === 'estimated' ? '<span class="fm-tag warn">estimated 比例估算</span>' : '');
let poseHtml = '—';
if (fm.head_pose) {
const p = fm.head_pose;
poseHtml = 'yaw ' + _FMT(p.yaw) + '° &nbsp; pitch ' + _FMT(p.pitch) + '° &nbsp; roll ' + _FMT(p.roll) + '°';
}
let h = '<div class="coord-box" style="margin-top:0;margin-bottom:16px;display:flex;gap:32px;flex-wrap:wrap;align-items:center">'
+ '<div><div class="label">脸总高</div><div class="value">' + _FMT(fm.face_total_height_cm) + ' cm</div></div>'
+ '<div><div class="label">发际线来源</div><div class="value" style="font-size:15px">' + (src || '—') + srcTag + '</div></div>'
+ '<div><div class="label">头部姿态</div><div class="value" style="font-size:14px">' + poseHtml + '</div></div>'
+ '</div>';
// 四庭
const fc = fm.four_courts || {};
const fr = (fc.ratios || {});
const courts = [
['顶庭', fc.top_court_cm, fr.top_court],
['上庭', fc.upper_court_cm, fr.upper_court],
['中庭', fc.middle_court_cm, fr.middle_court],
['下庭', fc.lower_court_cm, fr.lower_court],
];
h += '<div class="fm-section"><div class="fm-title">📏 四庭(纵向,自上而下)</div><div class="fm-grid">';
courts.forEach(c => {
h += '<div class="fm-cell"><div class="k">' + c[0] + '</div><div class="v">' + _FMT(c[1]) + ' cm<span class="sub">' + _PCT(c[2]).replace(/[()]/g, '') + '</span></div></div>';
});
h += '</div></div>';
// 七眼
const se = fm.seven_eyes || {};
const ser = (se.ratios || {});
h += '<div class="fm-section"><div class="fm-title">👁 七眼(横向汇总)</div><div class="fm-grid">';
[['单眼宽', se.eye_width_cm, ser.eye_width], ['脸宽', se.face_width_cm, null], ['两眼间距', se.inter_eye_distance_cm, ser.inter_eye_distance]].forEach(e => {
h += '<div class="fm-cell"><div class="k">' + e[0] + '</div><div class="v">' + _FMT(e[1]) + ' cm' + (e[2] != null ? '<span class="sub">' + (e[2] * 100).toFixed(1) + '%</span>' : '') + '</div></div>';
});
h += '</div></div>';
// 七眼 7 段(从左到右)
const segLabels = ['eye1 左耳外段', 'eye2 左脸颊段', 'eye3 左眼', 'eye4 两眼间距', 'eye5 右眼', 'eye6 右脸颊段', 'eye7 右耳外段'];
h += '<div class="fm-section"><div class="fm-title">↔️ 七眼 7 段宽度(从左到右,cm)</div><div class="fm-grid" style="grid-template-columns:repeat(7,1fr)">';
segLabels.forEach((lab, i) => {
const v = se['eye' + (i + 1)];
const muted = v == null ? ' muted' : '';
h += '<div class="fm-cell' + muted + '"><div class="k">' + lab + '</div><div class="v">' + (v == null ? '—' : _FMT(v)) + '</div></div>';
});
h += '</div></div>';
// 关键点坐标
const lm = fm.landmarks || {};
const lmLabels = { hair_top: '头顶', hairline: '发际线', brow_center: '眉心', nose_bottom: '鼻翼下缘', chin_tip: '下巴尖' };
h += '<div class="fm-section"><div class="fm-title">📍 关键分界点(原图像素坐标)</div><div class="fm-grid">';
Object.keys(lmLabels).forEach(k => {
const p = lm[k];
h += '<div class="fm-cell"><div class="k">' + lmLabels[k] + '</div><div class="v" style="font-size:14px">' + (p ? '(' + p.x + ', ' + p.y + ')' : '—') + '</div></div>';
});
h += '</div></div>';
box.innerHTML = h;
}
function copyJson() {
+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);
+31 -5
View File
@@ -90,8 +90,8 @@ def test_grow_female_returns_5(client, monkeypatch):
results = body["data"]["results"]
assert [x["hairline_type"] for x in results] == ["ellipse", "flower", "heart", "straight", "wave"]
assert [x["order"] for x in results] == [1, 2, 3, 4, 5]
assert base64.b64decode(results[0]["image_base64"])[:3] == b"\xff\xd8\xff" # JPEG
assert base64.b64decode(results[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff" # JPEG
assert base64.b64decode(results[0]["image_base64"])[:8] == b"\x89PNG\r\n\x1a\n" # 透明 PNG(发际线曲线)
assert base64.b64decode(results[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff" # JPEG(生发图)
assert "image_url" not in results[0]
@@ -149,12 +149,32 @@ def test_hairline_gen_female(client, monkeypatch):
# 只返回选中发型,order = 发型序号
assert [x["order"] for x in imgs] == [1, 3]
assert [x["hairline_type"] for x in imgs] == ["ellipse", "heart"]
# 三档叠图 + 生发图为 JPEGworker 返回 base64,非 url
for k in ("image_middle_base64", "image_high_base64", "image_low_base64", "grown_image_base64"):
assert base64.b64decode(imgs[0][k])[:3] == b"\xff\xd8\xff", k
# 三档叠图为透明 PNG(发际线曲线,含 alpha),生发图为 JPEG完整人像
for k in ("image_middle_base64", "image_high_base64", "image_low_base64"):
assert base64.b64decode(imgs[0][k])[:8] == b"\x89PNG\r\n\x1a\n", k
assert base64.b64decode(imgs[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff"
assert "image_middle_url" not in imgs[0]
c = d["best_hairline_center_point"]
assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内
# face_measure:复用接口1测量数值(容错,失败为 null;本用例正常 → 必须有完整结构)
fm = d["face_measure"]
assert fm is not None, "face_measure 不应为 null(正常正面照)"
assert set(["face_total_height_cm", "four_courts", "seven_eyes",
"landmarks", "hairline_source", "head_pose"]).issubset(fm.keys())
# 不应含标注图字段(接口5 只要数值,不要画线图)
assert "annotated_image_base64" not in fm
assert "annotated_image_url" not in fm
# 子结构
assert set(["top_court_cm", "upper_court_cm", "middle_court_cm",
"lower_court_cm", "ratios"]).issubset(fm["four_courts"].keys())
assert set(["eye_width_cm", "face_width_cm", "inter_eye_distance_cm",
"ratios"]).issubset(fm["seven_eyes"].keys())
# 七眼 eye1~eye7 键必须存在(eye1/eye7 在耳朵不可见时可为 null)
assert set([f"eye{i}" for i in range(1, 8)]).issubset(fm["seven_eyes"].keys())
assert set(["hair_top", "hairline", "brow_center",
"nose_bottom", "chin_tip"]).issubset(fm["landmarks"].keys())
assert fm["hairline_source"] in ("segmentation", "estimated")
assert set(["yaw", "pitch", "roll"]).issubset(fm["head_pose"].keys())
# 接口4(用户特征)已迁到网关本机实现(直接调豆包),不再在 worker;
@@ -174,6 +194,12 @@ def test_success_structure(client):
"lower_court_cm", "ratios"]).issubset(data["four_courts"].keys())
assert set(["eye_width_cm", "face_width_cm", "inter_eye_distance_cm",
"ratios"]).issubset(data["seven_eyes"].keys())
# 七眼:从左到右 eye1~eye7eye1/eye7 在耳朵不可见时可为 null,但键必须存在)
assert set([f"eye{i}" for i in range(1, 8)]).issubset(data["seven_eyes"].keys())
# eye3/eye5 为左右眼宽、eye4 为两眼间距,与 eye_width_cm/inter_eye_distance_cm 语义一致
assert data["seven_eyes"]["eye3"] is not None
assert data["seven_eyes"]["eye4"] is not None
assert data["seven_eyes"]["eye5"] is not None
assert data["hairline_source"] in ("segmentation", "estimated")
# base64 解码为合法 PNG(非 URL
assert "annotated_image_url" not in data
+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
```