Author SHA1 Message Date
xsl e3bd3a8916 save 2026-08-04 22:34:42 +08:00
xsl 1ff493a1a3 save code 2026-08-04 22:34:42 +08:00
xsl d1af9101c6 save 2026-08-04 22:34:38 +08:00
xslandCursor 718372dc07 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:05:44 +08:00
xsl 3a7c3fa07b feat(接口2): ComfyUI重绘分辨率默认改为1024 + wave测试套件 + 报告统一部署
- hairline/service.py: _REDRAW_MAX_SIDE 默认 896→1024
  逻辑: 输入图长边>1024 才缩到1024; ≤1024 原图分辨率直送(不放大)
  male路径_GROW_B_MAX_SIDE 已是1024,行为一致
- static/test_interface2.html: 分辨率下拉选项标签同步(默认1024/>1024才缩)
- image/wave_test/: wave发型5档分辨率对比测试(21图×5档=105次,全成功)
  batch_test.py/gen_report.py/report-server-wave.service
- image/reports/: 报告统一HTTP服务(单端口8850,路径区分/wave /v2 /v1)
  含索引页index.html + report-server.service + 三报告软链接
- .gitignore: 补充 wave_test/out/ 及运行期文件忽略规则
2026-07-27 23:38:53 +08:00
xsl b61ea6f33b feat(接口5): 生发机制对齐接口2 + 统一ComfyUI重绘提示词
接口5改造(生发机制与接口2一致,按性别分流):
- generate_hairline_pngs: 生发图来源从 _grow_from_texture(局部inpaint)
  改为按性别分流——female走 generate_grow_results_swap(swapHair+Flux整帧重绘),
  male走 generate_grow_results(ComfyUI add_hair)
- 新增参数 redraw_max_side/unet_name/v2_defaults(female路径透传)
- 接口5 handler 加 flux_model/redraw_max_side Form参数
- 保留接口5独有输出: 3档叠图(middle/high/low)/中心点/face_measure
- 已验证: female日志出现"接口2女 管线降分辨率max_side=640"+swap+Flux;
  male走add_hair.json; generate_grow_image=false正确跳过生发

统一ComfyUI重绘提示词:
- "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" → "填充遮罩区域的头发"
- 覆盖: _REDRAW_PROMPT/_DEFAULT_PROMPT常量 + app.py各接口Form默认 +
  工作流JSON节点60(add_hair/0716add-hair-api/hair_repaint) + 全部测试页输入框
2026-07-27 23:36:15 +08:00
xsl b354c8c627 fix: 删除 MeasureResult.__init__ 中重复的七眼厘米赋值块 2026-07-27 23:17:35 +08:00
xsl fd9c41430c feat: 接口1/5/6 发际线弃用逻辑(顶庭<0.7cm)
发际线离头顶<0.7cm时判定分割不可靠,弃用发际线:
顶/上庭字段置null、face_total只算中下庭、标注图保留头顶线去掉发际线、
只标中/下庭。eye1/7竖向范围改用眉心。
2026-07-27 23:12:33 +08:00
xsl 5101bb5f6b feat(接口2): 测试页暴露重绘分辨率选项 + 分辨率对比测试套件
- test_interface2.html: 新增「重绘分辨率」下拉(默认896/1024/768/640/不缩图0),
  仅 female 生效,append redraw_max_side 字段;male 时自动隐藏
- 新增分辨率对比测试工具 (image/compare_test, image/res_test):
  - batch_test.py: 串行批量测试脚本,支持断点续跑
  - gen_report.py: 生成自包含 HTML 对比报告(速度色阶+缩略图+点击放大)
- 两轮测试结果:
  - v1: 4图×5发型×2档=40次 (compare_test)
  - v2: 5图×3发型×5档=75次 (res_test)
  - 结论: 大图(长边>1600)原图直送比896档慢~3倍; 中小图各档差异小
- .gitignore: 忽略测试结果图/原图副本/运行日志(out/, *.log, progress.json)
2026-07-26 22:06:27 +08:00
xsl ff4019c570 chore: 默认模型固定为 9B-Q4_K_M,测试页移除模型/分辨率选择器
测试结论: 9B-Q4_K_M 分辨率896 为最佳性价比组合。
- 三个工作流(0716add-hair-api/add_hair/hair_repaint)默认模型改为
  flux-2-klein-9b-Q4_K_M.gguf (UnetLoaderGGUF节点)
- test_interface2.html 移除 Flux模型/压图长边 下拉选择器,
  接口调用不再传 flux_model/redraw_max_side,统一用工作流默认(Q4/896)
2026-07-25 16:25:44 +08:00
xsl 5226e23989 save code 2026-07-25 16:22:41 +08:00
xsl c462fd3634 refactor(报告): 图片改为独立JPG文件引用,不再base64内嵌
- 测试结果图(258张)和原图(3张)以JPG存入 static/bench/{matrix,hairstyle,orig}/
- 报告HTML改为 <img src> 引用相对路径,体积从 ~45MB 降至 ~30KB
- 报告和图片均部署在 static/ 下,可通过URL直接访问
2026-07-25 16:20:28 +08:00
xsl ab6b0cb0bb docs: 添加模型对比/发型对比 HTML 测试报告
报告图片已压缩为内嵌 JPG(缩放至300px宽,质量70),体积从 ~45MB 降至 ~3MB:
- static/benchmark_report.html: 4模型×3分辨率矩阵对比 (2.7MB)
- static/hairstyle_report.html: 3图×5发型×10组合对比 (3.1MB)

同步更新报告生成脚本支持图片压缩,并从 .gitignore 移除报告文件排除。
2026-07-25 16:17:14 +08:00
xsl 94cdfd6de5 feat(接口2): 支持动态切换Flux模型+分辨率 + 模型对比测试脚本
代码改动:
- comfyui.py: run() 新增 unet_name 参数,提交前自动改写模型节点
  (.gguf→UnetLoaderGGUF, .safetensors→UNETLoader),并按模型自动同步
  文本编码器(4b→qwen_3_4b, 9b→qwen_3_8b),避免切换时维度不匹配
- redraw.py: run_redraw() 透传 unet_name
- service.py: generate_grow_results_swap/generate_grow_results 支持
  redraw_max_side(分辨率参数化) 和 unet_name 透传
- app.py: 接口2 新增 flux_model/redraw_max_side 两个 Form 参数(男女路径都加)
- test_interface2.html: 新增 Flux模型/压图长边 下拉选择器
- add_hair.json/0716add-hair-api.json: 工作流默认模型改为 9b

测试脚本:
- benchmark_matrix.py: 4模型×3分辨率×3图×3次 矩阵测试
- benchmark_hairstyle.py: 3图×5发型×10组合 发型对比测试
- benchmark_report.py/benchmark_hairstyle_report.py: HTML报告生成

清理:
- .gitignore: 排除 benchmark_out/、报告HTML、gateway.log、*.bak.*
- 移除 gateway.log 的 git 跟踪
2026-07-25 16:16:13 +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
430 changed files with 7469 additions and 1192 deletions
+29
View File
@@ -49,3 +49,32 @@ image/test/
# 批量报告输出(生成图+原图,体积大,不入 git)
static/report_hairline_v2/
static/report_hairline_v2.zip
# local_test 运行期日志 / pid(不入 git)
local_test/hair_service.log
local_test/hair_service.pid
# benchmark 原始输出(含结果图+原图,体积大,不入 git)
benchmark_out/
# benchmark 部署的 HTML 报告(图片 base64 内嵌,体积大,不入 git)
static/hairstyle_thumbs/
# 网关运行期日志(不入 git
gateway.log
# 工作流备份文件(不入 git
*.json.bak.*
# 分辨率对比测试产物(out/ 下结果图+原图副本+日志,体积大,不入 git)
# 仅忽略 out/ 与运行期文件;测试脚本与 HTML 报告仍入库
image/compare_test/out/
image/res_test/out/
image/wave_test/out/
image/compare_test/progress.json
image/res_test/progress.json
image/wave_test/progress.json
image/compare_test/batch_test.log
image/res_test/batch_test.log
image/wave_test/batch_test.log
image/compare_test/http.log
+327
View File
@@ -0,0 +1,327 @@
{
"16": {
"class_type": "UnetLoaderGGUF",
"inputs": {
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
"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": "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,10 +170,10 @@
},
"16": {
"inputs": {
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn"
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
"weight_dtype": "fp8_e4m3fn_fast"
},
"class_type": "UNETLoader",
"class_type": "UnetLoaderGGUF",
"_meta": {
"title": "UNet加载器"
}
@@ -410,7 +410,7 @@
},
"60": {
"inputs": {
"text": "补充遮罩区补充遮罩区域的头发,头发填满遮罩区域。发际线往下挡住额头"
"text": "充遮罩区域的头发"
},
"class_type": "JjkText",
"_meta": {
@@ -421,7 +421,7 @@
"inputs": {
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
"type": "flux2",
"device": "default"
"device": "cpu"
},
"class_type": "CLIPLoader",
"_meta": {
+459 -157
View File
@@ -138,7 +138,47 @@ app = FastAPI(
app.mount("/static", StaticFiles(directory="static"), name="static")
# 不校验鉴权的路径前缀(供网关探测 / 文档 / 静态)
_AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static", "/api/v1/debug")
_AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static",
"/api/v1/debug", "/api/v1/redraw",
"/api/swapHair", "/hairColor")
# ---------------------------------------------------------------------------
# change_hair 代理路由(解决 CORS 问题)
# ---------------------------------------------------------------------------
_CHANGE_HAIR_BASE = os.getenv("CHANGE_HAIR_BASE", "http://127.0.0.1:8801")
@app.post("/api/swapHair/v1", tags=["change_hair"])
async def proxy_swap_hair(request: Request):
"""代理转发到 change_hair /api/swapHair/v1(换发型)"""
try:
import httpx
body = await request.body()
async with httpx.AsyncClient(timeout=300.0) as client:
resp = await client.post(f"{_CHANGE_HAIR_BASE}/api/swapHair/v1",
content=body,
headers={"Content-Type": "application/json"})
return JSONResponse(content=resp.json(), status_code=resp.status_code)
except Exception as e:
logger.exception("代理 swapHair 失败")
return err(1007, f"换发型服务异常:{e}")
@app.post("/hairColor/v2", tags=["change_hair"])
async def proxy_hair_color(request: Request):
"""代理转发到 change_hair /hairColor/v2(换发色)"""
try:
import httpx
body = await request.body()
async with httpx.AsyncClient(timeout=300.0) as client:
resp = await client.post(f"{_CHANGE_HAIR_BASE}/hairColor/v2",
content=body,
headers={"Content-Type": "application/json"})
return JSONResponse(content=resp.json(), status_code=resp.status_code)
except Exception as e:
logger.exception("代理 hairColor 失败")
return err(1007, f"换发色服务异常:{e}")
@app.middleware("http")
@@ -361,20 +401,36 @@ def _run_face_measure_data(image, variant="v1"):
logger.warning("头发/耳朵分割失败,回退方案A%s", seg_e)
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
discarded = result.hairline_discarded
data = result.to_response()
vd = result.vertical
if variant == "v6":
vd = result.vertical
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
# 接口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)
data["landmarks"].pop("hair_top", None)
if discarded:
# 发际线弃用:接口6 的上庭也依赖发际线,一并置 null;只保留中/下庭。
base_px = vd["middle_court_px"] + vd["lower_court_px"]
data["four_courts"]["upper_court_cm"] = None
data["four_courts"]["ratios"] = {
"upper_court": None,
"middle_court": round(vd["middle_court_px"] / base_px, 3),
"lower_court": round(vd["lower_court_px"] / base_px, 3),
}
data["four_courts"].pop("top_court_cm", None)
data["face_total_height_cm"] = round(
result.middle_cm + result.lower_cm, 2)
data["landmarks"]["hairline"] = None
else:
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top
data["four_courts"]["ratios"] = {
"upper_court": round(vd["upper_court_px"] / base_px, 3),
"middle_court": round(vd["middle_court_px"] / base_px, 3),
"lower_court": round(vd["lower_court_px"] / base_px, 3),
}
data["four_courts"].pop("top_court_cm", None)
data["face_total_height_cm"] = round(
result.upper_cm + result.middle_cm + result.lower_cm, 2)
# 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、
# 占比、标注图仍按三庭处理,显示效果不变。
# 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
@@ -389,11 +445,14 @@ def _run_face_measure_data(image, variant="v1"):
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(左/右耳外段),需耳朵分割端线
# 接口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,
result.vertical["hair_top"][1], result.vertical["chin_tip"][1],
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))
@@ -468,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 段
""",
@@ -573,7 +632,7 @@ async def face_measure(
**标注图片 UI 规范**(真实版本生效):
- 字体/线条/箭头颜色:`#FFFFFF 100%`,透明底
- 字号/线宽/虚线/箭头按图片短边自适应缩放
- 三庭数值(名+数值两行,不带 cm)在图片**左侧**呈现,七眼段宽**上下穿插**展示,底部标「单位cm」
- 三庭(名 + 数值带cm + 百分比 三行)在图片**左侧**呈现,七眼段宽**上下穿插**展示(数值带cm,下方另起一行标占头宽百分比)
- 段宽/庭高用虚线 + 实心三角双箭头标示
""",
responses={
@@ -655,21 +714,27 @@ async def face_measure_v2(
- 发际线类型 `hairline_type`(英文 key
- 顺序 `order`(本期固定 `1..N`,不排序)
> **female 走「换发型」模式**1..5 的生发图 `grown_image_base64` 由换发型(change_hair
> + Flux-2 整帧重绘(= 接口12 final 管线,整帧美颜+整帧重绘)生成,其余参数用固化默认值。
> **6/7bigflower/clasicalflower)与 male 一样走原生发(ComfyUI add_hair)管线**。
> **male 全部走原生发(ComfyUI add_hair)管线**。入参与返回结构不变。
> female 1..5 依赖 change_hair 与 ComfyUI(:8188) 均在跑。
{_image_fields_desc}
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
---
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 7 张 / male 6 张)。
非法或缺失返回 `1004`。
- **hair_style**(必填):发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数量。
`female`1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave
`male`1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007`。
`female`1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower
`male`1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。越界/非法返回 `1007`。
- **beauty_enabled**:本期保留但不生效。
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`female),
`ellipse` / `m` / `straight` / `inverse_arc`male)。
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave` / `bigflower` / `clasicalflower`female),
`ellipse` / `m` / `straight` / `inverse_arc` / `heart` / `Softpetal`male)。
""",
responses={
200: {
@@ -708,17 +773,19 @@ async def hair_grow(
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"),
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-7 male:1-6"),
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的文本"),
flux_model: Optional[str] = Form(default=None, description="Flux 模型文件名(切换模型用)。None=工作流默认;如 flux-2-klein-9b-Q5_K_M.gguf / flux-2-klein-9b-Q4_K_M.gguf / flux2.0/flux-2-klein-9b-fp8.safetensors"),
redraw_max_side: Optional[int] = Form(default=None, description="重绘压图长边像素。None=默认896;0=不缩图(原图直送);其他如 768/640/1024"),
):
# 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]
max_styles = {"female": 7, "male": 6}[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}")
@@ -734,10 +801,19 @@ 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,
redraw_max_side=redraw_max_side, unet_name=flux_model, prompt=prompt)
else:
from hairline.service import generate_grow_results
items = await run_in_threadpool(
generate_grow_results, image, gender, use_mask, prompt, hair_styles,
unet_name=flux_model)
if items is None:
return err(1001, "无法识别人像")
@@ -757,119 +833,160 @@ async def hair_grow(
# ---------------------------------------------------------------------------
# 接口 7C 端生发 v2add_hair2.json 工作流)
# 调试接口:接口2 女性生发 分步计时
# ---------------------------------------------------------------------------
_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}},
}
}
},
},
},
"/api/v1/debug/grow-timing",
summary="调试-接口2女性生发分步计时",
tags=["调试"],
include_in_schema=False,
)
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的文本"),
async def debug_grow_timing(
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
hair_style: str = Form(default="2", description="发型序号(花瓣=2),逗号分隔多选"),
webui_steps: Optional[int] = Form(default=None, description="swapHair webui img2img 采样步数,None=服务端默认(15),可填10/15/20/25对比"),
redraw_max_side: Optional[int] = Form(default=None, description="ComfyUI重绘分辨率(长边像素)。None=默认8960=原图不缩;其他如640/768/1024"),
redraw_prompt: Optional[str] = Form(default=None, description="ComfyUI重绘提示词,None=默认'填充遮罩区域的头发'"),
):
# 1. gender 必填校验(非法/缺失 → 1004)
if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female")
"""单图跑接口2女性生发,返回每个步骤的耗时 + 结果图,用于定位性能瓶颈。
# 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}")
步骤拆分:
1. extract_context:人脸关键点检测 + 头发分割 + 发际线几何
2. [每个发型] generate_hairline_redraw
2a. compute_mask:发际线遮罩计算
2b. _call_swap:调 change_hair 换发型(内含 webui SD1.5 推理,远程或本机)
2c. _composite:接缝融合(多频段/羽化)
3. [每个发型] _call_local_redraw:调本机 ComfyUI 用 Flux.2 重绘
"""
import time as _time
from fastapi.concurrency import run_in_threadpool
# 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
max_styles = 7
hair_styles = _parse_hair_styles(hair_style, max_styles)
if hair_styles is None:
return err(1007, f"hair_style 必须为 1..{max_styles}")
# 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_styles, _WORKFLOW2_PATH)
if items is None:
t_total0 = _time.perf_counter()
timings = {"total_ms": 0, "extract_context_ms": 0, "per_hairstyle": []}
# 步骤1: extract_context
t0 = _time.perf_counter()
from hairline.service import extract_context as _ec, _call_local_redraw, _REDRAW_MAX_SIDE # noqa
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError # noqa
from face_analysis.head_mask import SEGFORMER_HAIR # noqa
ctx = await run_in_threadpool(_ec, image)
timings["extract_context_ms"] = int((_time.perf_counter() - t0) * 1000)
if ctx 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})
hair_mask_reuse = (ctx["parse_map"] == SEGFORMER_HAIR)
h, w = image.shape[:2]
eff_side = _REDRAW_MAX_SIDE if redraw_max_side is None else redraw_max_side
redraw_img, hair_mask_redraw = image, hair_mask_reuse
downscale_info = None
if eff_side > 0 and max(h, w) > eff_side:
from hairline.service import _downscale_max_side
redraw_img, _rs = _downscale_max_side(image, eff_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)
downscale_info = {"from": f"{w}x{h}", "to": f"{_nw}x{_nh}", "max_side": eff_side}
textures_map = None
from hairline.service import get_texture_map, _FEMALE_KEY_TO_CHANG, load_texture_rgba, build_overlay_layer, load_ext_mesh
textures = get_texture_map()["female"]
items = [(s, textures[s - 1]) for s in hair_styles]
for order, (key, white_path) in items:
hs_t0 = _time.perf_counter()
entry = {"hairline_type": key, "order": order}
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
entry["chang_id"] = chang_id
entry["ok"] = False
entry["error"] = None
entry["grown_b64"] = None
if chang_id is None:
entry["error"] = f"无对应 chang_id"
timings["per_hairstyle"].append(entry)
continue
try:
# 2a/2b/2c: generate_hairline_redraw (内部含 mask+swap+blend)
t0 = _time.perf_counter()
data = await run_in_threadpool(
generate_hairline_redraw, redraw_img, chang_id,
hair_mask=hair_mask_redraw, webui_steps=webui_steps, **_V2_FINAL_DEFAULTS)
t_redraw_pipeline = _time.perf_counter() - t0
_tm = data.get("timings_ms") or {}
entry["mask_ms"] = _tm.get("mask", 0)
entry["swap_ms"] = _tm.get("swap", 0)
entry["blend_ms"] = _tm.get("blend", 0)
entry["redraw_pipeline_ms"] = int(t_redraw_pipeline * 1000)
steps = data.get("steps") or {}
final_b64 = steps.get("final_base64") or ""
mask_b64 = steps.get("redraw_band_mask_base64") or ""
if not final_b64 or not mask_b64:
entry["error"] = f"final/遮罩缺失(final={len(final_b64)} mask={len(mask_b64)})"
timings["per_hairstyle"].append(entry)
continue
if final_b64.startswith("data:"):
final_b64 = final_b64.split(",", 1)[1]
if mask_b64.startswith("data:"):
mask_b64 = mask_b64.split(",", 1)[1]
# 3: ComfyUI 重绘
t0 = _time.perf_counter()
# max_side: 0 或 None 都让 _call_local_redraw 用默认逻辑(外层已控制分辨率)
_ms = redraw_max_side if redraw_max_side is not None and redraw_max_side > 0 else None
grown_png = await run_in_threadpool(
_call_local_redraw,
base64.b64decode(final_b64), base64.b64decode(mask_b64),
max_side=_ms, prompt=redraw_prompt)
entry["comfyui_redraw_ms"] = int((_time.perf_counter() - t0) * 1000)
if grown_png:
entry["grown_b64"] = "data:image/jpeg;base64," + _png_to_jpg_b64(grown_png)
entry["ok"] = True
else:
entry["error"] = "ComfyUI 重绘返回空"
except NoFaceError:
entry["error"] = "未检出人脸"
except Exception as ex: # noqa: BLE001
entry["error"] = str(ex)[:150]
entry["hairstyle_total_ms"] = int((_time.perf_counter() - hs_t0) * 1000)
timings["per_hairstyle"].append(entry)
timings["total_ms"] = int((_time.perf_counter() - t_total0) * 1000)
timings["downscale"] = downscale_info
timings["image_size"] = f"{w}x{h}"
return ok(timings)
except Exception as ex: # noqa: BLE001
logger.exception("接口7 处理异常")
logger.exception("debug/grow-timing 异常")
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 接口 7:C 端生发 v2 —— 已弃用(add_hair2.json 用 Klein-9b 大模型,会把常驻的
# Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。
# 保留路由返回明确错误,避免老客户端拿到裸 404。
# ---------------------------------------------------------------------------
@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")
# ---------------------------------------------------------------------------
# 接口 3B 端生发
# ---------------------------------------------------------------------------
@@ -917,7 +1034,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)
@@ -1043,11 +1160,18 @@ async def face_features(
---
**入参**(同接口2:先选性别,再多选发型):
- 必填 `gender``male`/`female`),决定发型集合(female 5 / male 4)。
- 必填 `gender``male`/`female`),决定发型集合(female 7 / male 6)。
- 必填 `hair_style`(发型序号,逗号分隔如 `1,2,3`),决定返回哪些发际线类型。缺失/越界/非法返回 `1007`。
`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 的生发控制参数
`female`1=ellipse,2=flower,3=heart,4=straight,5=wave,6=bigflower,7=clasicalflower
`male`1=ellipse,2=inverse_arc,3=m,4=straight,5=heart,6=Softpetal
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数(仅 male 路径生效)。
注:生发黑模板固定取 `hairline_texture_black/`middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle。
- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(ComfyUI 生发,全流程最耗时)。
`false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,大幅降低耗时。
- **生发机制(同接口2,按性别分流)**:
`female` 走「换发型 + Flux-2 整帧重绘」(依赖 change_hair:8801 与 ComfyUI:8188);
`male` 走 ComfyUI `add_hair` 原生 inpaint。
- 可选 `flux_model` / `redraw_max_side`:同接口2(仅 female 路径生效)。
**返回说明**
@@ -1123,15 +1247,18 @@ async def hairline_generate(
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"),
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用"),
prompt: str = Form(default="充遮罩区域的头发,加一点美颜", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-7 male:1-6"),
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,仅 male 路径生效"),
prompt: str = Form(default="充遮罩区域的头发", description="ComfyUI 提示词(同接口2,仅 male 路径生效),会替换工作流节点60的文本"),
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
flux_model: Optional[str] = Form(default=None, description="Flux 模型文件名(同接口2,切换模型用)。None=工作流默认"),
redraw_max_side: Optional[int] = Form(default=None, description="重绘压图长边像素(同接口2,仅 female 路径生效)。None=默认896;0=不缩图(原图直送);其他如 768/640/1024"),
):
if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female")
# hair_style 必填(同接口2):解析逗号分隔,缺失/越界/非法 → 1007
max_styles = {"female": 5, "male": 4}[gender]
max_styles = {"female": 7, "male": 6}[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}")
@@ -1149,7 +1276,10 @@ 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,
redraw_max_side=redraw_max_side, unet_name=flux_model,
v2_defaults=_V2_FINAL_DEFAULTS)
if res is None:
return err(1001, "无法识别人像")
@@ -1414,13 +1544,11 @@ async def hairline_grow(
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"),
redraw: bool = Form(default=False, description="发际线带重绘开关:开启后额外跑一条分支——外推↔发际线带重绘,final输入,swapHair/Flux-2两路对比,结果单独展示(不替换final),默认 False"),
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"),
comfyui_prompt: Optional[str] = Form(default=None, description="redraw Flux-2路提示词,None用默认「补充遮罩区域的头发,加一点美颜」"),
):
"""接口11:发际线生发 + 分步可视化。遮罩固定 pushed,融合默认 multiband(可选 seamless/two_stage/feather)。"""
"""接口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
@@ -1435,10 +1563,10 @@ async def hairline_grow(
from uuid import uuid4 as _uuid4
rid = _uuid4().hex[:8]
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 redraw=%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, redraw,
color_match, color_match_strength, mb_feather_px, transition_band_px,
inpainting_fill, mask_blur, mask_dilate_scale)
try:
data = await run_in_threadpool(
@@ -1450,8 +1578,8 @@ async def hairline_grow(
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,
redraw=redraw, inpainting_fill=inpainting_fill, mask_blur=mask_blur,
mask_dilate_scale=mask_dilate_scale, comfyui_prompt=comfyui_prompt, rid=rid)
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:
@@ -1464,20 +1592,33 @@ async def hairline_grow(
# ---------------------------------------------------------------------------
# 接口 12:发际线生发(接口11 固定参数精简版
# 接口 12:发际线带重绘(调用接口11 的 ④final 作输入 + ⑤-①重绘带作遮罩,Flux-2 重绘
# ---------------------------------------------------------------------------
@app.post(
"/api/v1/hairline/grow_v2",
summary="接口12 发际线生发(固定 pushed 遮罩 + 多频段融合,仅返回最终图",
summary="接口12 发际线带重绘(接口11 final + 发际线重绘带 → Flux-2 保色重绘",
tags=["生发"],
description=f"""
接口11 的固定参数精简版,适合生产直调。与接口11 共用同一管线,遮罩固定 pushed(发际线外推)
融合固定 multiband(多频段金字塔)。本接口固定 `erode_cm=0.6`、`mb_levels=5` 不暴露,
**只返回 `final_base64`**(最终合成图),不附带分步可视化。
接口12 是接口11 的**下游重绘阶段**。内部先跑接口11 核心管线拿到 **④ 接缝融合最终图(final)**
再取 **⑤-① 发际线重绘带**(发际线沿外推方向 `band_lo_mult×push` ~ `band_hi_mult×push`
之间、经 ①-a baseline 截断只留上部的带状区域,默认 0.5×~1.5×push
作为遮罩,用 **Flux-2ComfyUI** 做 reference-latent 保色重绘(不易染绿),重绘结果再与 final 融合。
其余参数(hairline_id、seg_model、gen_backend、is_hr、denoising_strength、edge_erode_px、
hairline_push_cm、hairline_edge 等)保留为可选 Form,调用方可按需覆盖。
与接口11 的关系:接口11 只负责生成 final(不再含重绘);接口12 负责在 final 上做发际线带重绘。
接口11 的可调参数(seg_model/gen_backend/hairline_push_cm/blend_method/color_match 等)
在本接口同样暴露,用于内部生成 final 与重绘带;另有 `comfyui_prompt` 控制 Flux-2 提示词。
⚠️ 依赖 ComfyUI(默认 :8188)在跑,否则重绘失败会在 `data.redraw.c_error` 报告。
**同时产出两版结果供对比**
- `redraw_full`ComfyUI 整帧输出(全脸美颜 + 全脸重绘),与手动跑 ComfyUI 一致。
- `redraw_band`:加发只在发际线带、美颜保留全脸(band 内用 ComfyUI 重绘,band 外 = final 结构
+ 按 `beauty_alpha` 融入全脸美颜)。
返回 `data.steps``input`(原图)/ `final`(接口11 的 ④,重绘输入基底)/
`redraw_band_overlay`(⑤-① 重绘带可视化)/ `redraw_full`A 整帧)/ `redraw_band`B 局部加发+全脸美颜)/
`redraw_c`(兼容旧字段,=redraw_full)。
{_image_fields_desc}
""",
@@ -1491,13 +1632,27 @@ async def hairline_grow_v2(
hairgrow_strength: float = Form(default=0.75, description="区域生发强度(仅 hairgrow 后端),默认 0.75"),
is_hr: bool = Form(default=False, description="高清模式(换发型输出 1152×1536,否则 576×768"),
seg_model: str = Form(default="segformer", description="头发分割模型:bisenet | segformer(默认 segformer"),
erode_cm: float = Form(default=0.6, description="baseline 参考内缩距离(厘米),默认 0.6"),
swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask | as_is(默认 ext_mask"),
edge_erode_px: int = Form(default=3, description="贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3"),
denoising_strength: float = Form(default=0.6, description="换发型 webui 重绘强度(越大生发越激进),默认 0.6"),
mb_levels: int = Form(default=5, description="多频段金字塔层数(2~6),默认 5"),
hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米),默认 1.0"),
hairline_edge: str = Form(default="column", description="发际线提取方式:column | contour,默认 column"),
blend_method: str = Form(default="multiband", description="接缝融合方法:multiband | seamless | two_stage | feather | alpha_gradient"),
color_match: bool = Form(default=True, description="融合前 Reinhard 颜色迁移消除整体色差,默认 True"),
color_match_strength: float = Form(default=1.0, description="颜色迁移强度(0~1),默认 1.0"),
mb_feather_px: int = Form(default=1, description="多频段最细层掩码轻羽化像素,默认 1"),
transition_band_px: int = Form(default=-1, description="keep-region 过渡带边距(-1=自动),默认 -1"),
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发」"),
beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"),
band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"),
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
):
"""接口12:发际线生发(固定 pushed 遮罩 + multiband 融合,仅返回最终图)"""
"""接口12:发际线带重绘。同时产出 redraw_full(整帧美颜) 与 redraw_band(局部加发+全脸美颜) 两版对比"""
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
@@ -1508,43 +1663,190 @@ async def hairline_grow_v2(
try:
from fastapi.concurrency import run_in_threadpool
from face_analysis.hairline_grow import generate_hairline_grow, NoFaceError, SwapError
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError, SwapError
from uuid import uuid4 as _uuid4
rid = _uuid4().hex[:8]
logger.info("[%s] 接口12 收到请求: hairline_id=%s hairline_push_cm=%s blend=%s comfyui_prompt=%r",
rid, hairline_id, hairline_push_cm, blend_method, comfyui_prompt)
try:
# 固定:mask_type=pushed、blend_method=multiband、erode_cm=0.6、mb_levels=5
data = await run_in_threadpool(
generate_hairline_grow, image, hairline_id,
is_hr=is_hr, seg_model=seg_model, erode_cm=0.6, swap_mode=swap_mode,
generate_hairline_redraw, image, hairline_id,
is_hr=is_hr, seg_model=seg_model, erode_cm=erode_cm, swap_mode=swap_mode,
edge_erode_px=edge_erode_px, denoising_strength=denoising_strength,
gen_backend=gen_backend, hairgrow_strength=hairgrow_strength,
mb_levels=5, hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge)
mb_levels=mb_levels, hairline_push_cm=hairline_push_cm,
hairline_edge=hairline_edge, blend_method=blend_method,
color_match=color_match, color_match_strength=color_match_strength,
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
mask_dilate_scale=mask_dilate_scale, comfyui_prompt=comfyui_prompt,
beauty_alpha=beauty_alpha, band_lo_mult=band_lo_mult,
band_hi_mult=band_hi_mult, rid=rid)
except NoFaceError:
return err(1001, "无法识别人像")
except SwapError as se:
return err(1007, f"换发型失败:{se}")
# 精简返回:只取最终合成图,丢掉接口11 的全部分步可视化
return ok({
"hairline_id": data["hairline_id"],
"image_size": data["image_size"],
"final_base64": data["steps"]["final_base64"],
})
logger.info("[%s] 接口12 成功返回", rid)
return ok(data)
except Exception as ex: # noqa: BLE001
logger.exception("接口12 处理异常")
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 接口 12 final:精简版发际线带重绘(仅需图片 + 发型 ID,其余参数全用默认值)
# ---------------------------------------------------------------------------
# 接口12 final 固化的默认参数(= test_interface12.html 当前默认值,color_match 关闭)
_V2_FINAL_DEFAULTS = dict(
gen_backend="swaphair", hairgrow_strength=0.75, is_hr=False, seg_model="segformer",
erode_cm=0.6, swap_mode="ext_mask", edge_erode_px=3, denoising_strength=0.6,
mb_levels=5, hairline_push_cm=0.8, hairline_edge="column", blend_method="two_stage",
color_match=False, color_match_strength=0.4, mb_feather_px=1, transition_band_px=-1,
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0, comfyui_prompt=None,
beauty_alpha=0.6, band_lo_mult=0.5, band_hi_mult=1.5,
)
async def _run_v2_final(image_file, image_url, image_base64, hairline_id, tag):
"""接口12 final / final v2 共用:仅需图片 + hairline_id,其余用固化默认值。
两者后端计算完全一致(同一次 ComfyUI 输出同时含 A 整帧与 B 局部+美颜),
差异仅在配套测试页展示哪一版。"""
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if image is None:
return err(1008, "图片格式不支持(仅 JPG / PNG)")
try:
from fastapi.concurrency import run_in_threadpool
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError, SwapError
from uuid import uuid4 as _uuid4
rid = _uuid4().hex[:8]
logger.info("[%s] %s 收到请求: hairline_id=%s(其余用默认值)", rid, tag, hairline_id)
try:
data = await run_in_threadpool(
generate_hairline_redraw, image, hairline_id,
rid=rid, **_V2_FINAL_DEFAULTS)
except NoFaceError:
return err(1001, "无法识别人像")
except SwapError as se:
return err(1007, f"换发型失败:{se}")
logger.info("[%s] %s 成功返回", rid, tag)
return ok(data)
except Exception as ex: # noqa: BLE001
logger.exception("%s 处理异常", tag)
return err(1007, f"处理失败:{ex}")
@app.post(
"/api/v1/hairline/grow_v2_final",
summary="接口12 final 精简重绘(整帧重绘;仅图片 + 发型 ID)",
tags=["生发"],
description=f"""
接口12 的**精简/生产版**:只需上传图片 + 选择 `hairline_id`,其余所有参数固化为当前调优默认值
hairline_push_cm=0.8 / blend_method=two_stage / **color_match=关闭** / color_match_strength=0.4 /
beauty_alpha=0.6 / band_lo_mult=0.5 / band_hi_mult=1.5 等)。
**最终重绘取整帧重绘**`data.steps.redraw_full`,全脸美颜 + 全脸重绘)。
返回结构与接口12 一致(`data.steps` 同时含 `redraw_full`A 整帧)/ `redraw_band`B 局部+美颜))。
⚠️ 依赖 ComfyUI(默认 :8188)在跑。
{_image_fields_desc}
""",
)
async def hairline_grow_v2_final(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
hairline_id: str = Form(..., description="发际线类型 ID= change_hair hair_id,如 chang_bolang"),
):
"""接口12 final:整帧重绘版,仅需图片 + hairline_id。"""
return await _run_v2_final(image_file, image_url, image_base64, hairline_id, "接口12final")
@app.post(
"/api/v1/hairline/grow_v2_final_v2",
summary="接口12 final v2 精简重绘(B 局部加发+全脸美颜;仅图片 + 发型 ID)",
tags=["生发"],
description=f"""
接口12 final 的**局部加发版**:参数与 `grow_v2_final` 完全相同(color_match 关闭等),
唯一区别是**最终重绘取 B 局部加发+全脸美颜**`data.steps.redraw_band`
加发只在发际线带内、band 外保留 final 结构并按 beauty_alpha 融入全脸美颜)。
返回结构与接口12 一致(`data.steps` 同时含 `redraw_full`A 整帧)/ `redraw_band`B 局部+美颜))。
⚠️ 依赖 ComfyUI(默认 :8188)在跑。
{_image_fields_desc}
""",
)
async def hairline_grow_v2_final_v2(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
hairline_id: str = Form(..., description="发际线类型 ID= change_hair hair_id,如 chang_bolang"),
):
"""接口12 final v2:B 局部加发+全脸美颜版,仅需图片 + hairline_id。"""
return await _run_v2_final(image_file, image_url, image_base64, hairline_id, "接口12finalv2")
# ---------------------------------------------------------------------------
# 重绘端点(替代 local_test /api/generate
# ---------------------------------------------------------------------------
@app.post(
"/api/v1/redraw",
summary="ComfyUI 重绘",
tags=["重绘"],
description="""
传入人物图片 + 遮罩图片,直接调 ComfyUI0716add-hair 工作流)执行局部重绘。
替代原 local_test :8899 的 /api/generate 接口。
**遮罩图片格式**:支持红色遮罩(R=255)、白色遮罩(R=G=B=255)、Alpha遮罩(A=255),服务取所有通道最大值。
**遮罩区域**表示需要重绘的部分,非遮罩区域保持原图不变。
""",
)
async def api_redraw(
image_file: UploadFile = File(..., description="人物图片(JPG/PNG"),
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
prompt: str = Form(default="填充遮罩区域的头发",
description="ComfyUI 提示词"),
):
image_bytes = await image_file.read()
mask_bytes = await mask_file.read()
from fastapi.concurrency import run_in_threadpool
from hairline.redraw import run_redraw
try:
png_bytes = await run_in_threadpool(
run_redraw, image_bytes, mask_bytes, prompt)
except Exception as e: # noqa: BLE001
logger.warning("重绘失败: %s", e)
return err(500, f"重绘失败: {e}")
b64 = base64.b64encode(png_bytes).decode()
return ok({"image_base64": f"data:image/png;base64,{b64}"})
# ---------------------------------------------------------------------------
# 调试:下载后端日志(接口11 遮罩计算全过程)
# ---------------------------------------------------------------------------
@app.get("/api/v1/debug/hairline_log", include_in_schema=False)
async def download_hairline_log(rid: Optional[str] = None, tail: int = 500):
"""返回 /home/xsl/hair/log/hairline_grow.log 的内容。
"""返回 <仓库根>/log/hairline_grow.log 的内容。
rid 非空时只返回该 request id 相关的行;tail 限制返回最后 N 行(默认 500)。
供调试页"下载日志"按钮调用。
"""
from fastapi.responses import PlainTextResponse
log_path = "/home/xsl/hair/log/hairline_grow.log"
log_path = os.getenv(
"HAIR_LOG_DIR",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "log"),
)
log_path = os.path.join(log_path, "hairline_grow.log")
try:
with open(log_path, encoding="utf-8") as fh:
lines = fh.readlines()
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""发型对比矩阵测试:3图×5发型=15行,每行10张图(4b@896×1 + 9b三模型×三分辨率×9)。
按模型分组跑(减少模型切换次数、降低OOM风险),结果重组为15行存JSON+生成报告。
"""
import base64
import json
import os
import subprocess
import time
from collections import defaultdict
from pathlib import Path
import requests
API = "http://127.0.0.1:8187/api/v1/hair/grow"
TOKEN = "dev-shared-secret-2026"
OUT = Path("/home/ubuntu/hair/benchmark_out/hairstyle")
OUT.mkdir(parents=True, exist_ok=True)
IMGS = [
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
]
HAIRSTYLES = [
(1, "ellipse", "椭圆"), (2, "flower", "花瓣"), (3, "heart", "心形"),
(4, "straight", "直线"), (5, "wave", "波浪"),
]
# 按模型分组:每个模型对应其要跑的(分辨率,列标题)
MODEL_GROUPS = [
("flux-2-klein-4b-fp8.safetensors", [("896", "4B@896")]),
("flux2.0/flux-2-klein-9b-fp8.safetensors",
[("0", "9B-fp8@原图"), ("896", "9B-fp8@896"), ("640", "9B-fp8@640")]),
("flux-2-klein-9b-Q5_K_M.gguf",
[("0", "9B-Q5@原图"), ("896", "9B-Q5@896"), ("640", "9B-Q5@640")]),
("flux-2-klein-9b-Q4_K_M.gguf",
[("0", "9B-Q4@原图"), ("896", "9B-Q4@896"), ("640", "9B-Q4@640")]),
]
# 列顺序(4b在前,然后9b三模型)
COLUMN_TITLES = ["4B@896", "9B-fp8@原图", "9B-fp8@896", "9B-fp8@640",
"9B-Q5@原图", "9B-Q5@896", "9B-Q5@640",
"9B-Q4@原图", "9B-Q4@896", "9B-Q4@640"]
def gpu_used():
try:
out = subprocess.check_output(
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"], timeout=10)
return int(out.decode().strip())
except Exception:
return 0
def call(img_path, hair_num, model_file, res_val):
fd = {"gender": "female", "hair_style": str(hair_num), "use_mask": "true",
"prompt": "填充遮罩区域的头发"}
if model_file:
fd["flux_model"] = model_file
if res_val != "":
fd["redraw_max_side"] = res_val
t0 = time.perf_counter()
peak = gpu_used()
err = None
grown_b64 = None
try:
with open(img_path, "rb") as f:
r = requests.post(API, headers={"X-Internal-Token": TOKEN},
files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
data=fd, timeout=300)
elapsed = time.perf_counter() - t0
peak = max(peak, gpu_used())
j = r.json()
if j.get("code") != 0:
err = f"code={j.get('code')} {j.get('message', '')[:60]}"
else:
res = j.get("data", {}).get("results", [])
if res and res[0].get("grown_image_base64"):
grown_b64 = res[0]["grown_image_base64"]
elif res:
err = "grown_image空"
else:
err = "无results"
except Exception as e:
elapsed = time.perf_counter() - t0
err = str(e)[:150]
return {"elapsed": elapsed, "gpu_peak": peak, "grown_b64": grown_b64, "error": err}
def main():
# 结果字典: results[(img, hair_num, column_title)] = {grown_path, elapsed, gpu_peak, error}
results = {}
total = len(IMGS) * len(HAIRSTYLES) * len(COLUMN_TITLES)
idx = 0
for mfile, res_list in MODEL_GROUPS:
mname = os.path.basename(mfile)
print(f"\n===== 切换到模型: {mname} =====", flush=True)
# 等模型切换稳定
time.sleep(2)
for ilabel, ipath in IMGS:
for hnum, hkey, hname in HAIRSTYLES:
for rval, ctitle in res_list:
idx += 1
print(f"[{idx}/{total}] {ilabel}|{hname}|{ctitle}", flush=True)
r = call(ipath, hnum, mfile, rval)
status = f"{r['elapsed']:.1f}s" if not r["error"] else r["error"][:40]
print(f" -> {status} peak={r['gpu_peak']}M", flush=True)
if r["grown_b64"]:
fname = f"{ilabel}_{hkey}_{ctitle.replace('@','_').replace('-','')}.jpg"
with open(OUT / fname, "wb") as gf:
gf.write(base64.b64decode(r["grown_b64"]))
r["grown_path"] = str(OUT / fname)
results[(ilabel, hnum, ctitle)] = r
# 重组为15行
rows = []
for ilabel, ipath in IMGS:
for hnum, hkey, hname in HAIRSTYLES:
cells = []
for ct in COLUMN_TITLES:
r = results.get((ilabel, hnum, ct), {"error": "未跑"})
cells.append({"title": ct, **{k: v for k, v in r.items() if k != "grown_b64"}})
rows.append({"img": ilabel, "img_path": ipath,
"hair_num": hnum, "hair_key": hkey, "hair_name": hname,
"cells": cells})
with open(OUT / "results.json", "w", encoding="utf-8") as f:
json.dump({"columns": COLUMN_TITLES, "rows": rows}, f, ensure_ascii=False, indent=2)
ok = sum(1 for row in rows for c in row["cells"] if not c.get("error"))
print(f"\n✓ 完成: {ok}/{total} 成功 -> {OUT/'results.json'}", flush=True)
if __name__ == "__main__":
main()
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把发型对比测试结果生成 HTML 报告。
15行(3图×5发型) × 10列(4b@896 + 9b三模型×三分辨率),每行首列=原图。
图片 base64 内嵌,自包含单文件。
"""
import base64
import json
import os
from pathlib import Path
OUT = Path("/home/ubuntu/hair/benchmark_out/hairstyle")
RESULTS = OUT / "results.json"
HTML = OUT / "report.html"
def img_src(path):
"""把绝对路径转成报告里的相对 URL(报告在 static/,图片在 static/bench/)。"""
if not path:
return None
p = str(path)
if "benchmark_out/hairstyle/" in p:
return "bench/hairstyle/" + os.path.basename(p)
if "benchmark_out/matrix/" in p:
return "bench/matrix/" + os.path.basename(p)
return None
def main():
d = json.load(open(RESULTS, encoding="utf-8"))
columns = d["columns"]
rows = d["rows"]
# 统计每列的平均耗时、峰值显存
col_stats = {}
for ct in columns:
times, peaks = [], []
for r in rows:
for c in r["cells"]:
if c.get("title") == ct and not c.get("error"):
times.append(c["elapsed"])
peaks.append(c["gpu_peak"])
col_stats[ct] = {
"avg_t": sum(times) / len(times) if times else 0,
"max_p": max(peaks) / 1024 if peaks else 0,
}
# 表头:原图 + 10列
headers = ['<th class="col-label">原图</th>']
for ct in columns:
s = col_stats[ct]
headers.append(
f'<th class="col-label"><div class="col-title">{ct}</div>'
f'<div class="col-stat">{s["avg_t"]:.0f}s · {s["max_p"]:.0f}G</div></th>'
)
# 表体:15行
body_rows = []
for r in rows:
# 发型+图标签
label = f'<div class="row-label">{r["img"]}<br><b>{r["hair_name"]}</b></div>'
# 原图
ORIG_SRC = {"asdf": "bench/orig/asdf.jpg", "qwer": "bench/orig/qwer.jpg", "girl5": "bench/orig/girl5.jpg"}
orig = ORIG_SRC.get(r["img"])
cells = [f'<td class="cell-orig"><div class="row-label-cell">{label}</div>'
f'<img class="orig-img" src="{orig}"></td>']
# 10个结果列
for ct in columns:
c = next((x for x in r["cells"] if x.get("title") == ct), {})
src = img_src(c.get("grown_path")) if not c.get("error") else None
if src:
cells.append(
f'<td class="cell-result"><img class="result-img" src="{src}" loading="lazy">'
f'<div class="cell-time">{c["elapsed"]:.1f}s</div></td>')
else:
cells.append(f'<td class="cell-result"><div class="na">⚠</div></td>')
body_rows.append(f'<tr>{"".join(cells)}</tr>')
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>发型对比测试报告 — 4模型×3分辨率</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, "Segoe UI", sans-serif; background: #f5f5f5; padding: 16px; }}
h1 {{ font-size: 20px; margin-bottom: 4px; }}
.subtitle {{ color: #888; font-size: 12px; margin-bottom: 12px; }}
.legend {{ background: #fff; border-radius: 8px; padding: 10px 16px; margin-bottom: 12px; font-size: 12px; color: #555; }}
.scroll-wrap {{ overflow-x: auto; }}
table {{ border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden;
box-shadow: 0 1px 4px rgba(0,0,0,.06); }}
th, td {{ border: 1px solid #eee; padding: 6px; vertical-align: top; text-align: center; }}
th {{ background: #f9fafb; position: sticky; top: 0; }}
.col-label {{ min-width: 110px; max-width: 130px; }}
.col-title {{ font-size: 12px; font-weight: 700; color: #374151; }}
.col-stat {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
.row-label {{ font-size: 11px; color: #6b7280; }}
.row-label b {{ color: #1f2937; }}
.row-label-cell {{ font-size: 11px; color: #6b7280; margin-bottom: 4px; }}
.row-label-cell b {{ color: #1f2937; font-size: 13px; }}
img {{ border-radius: 4px; max-width: 120px; max-height: 150px; object-fit: contain; background: #f3f4f6; }}
.orig-img {{ border: 2px solid #d1d5db; max-height: 130px; }}
.cell-time {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
.na {{ color: #d1d5db; font-size: 16px; padding: 40px; }}
</style>
</head>
<body>
<h1>💇 发型对比测试报告</h1>
<p class="subtitle">接口2女性 · 3图×5发型=15行 · 每行: 4B@896(1) + 9B(fp8/Q5/Q4)×(原图/896/640)(9) · 150/150成功 · RTX3090</p>
<div class="legend">列标题下显示<b>平均耗时 · 峰值显存</b>。横向滚动查看更多列。原图列含图片名+发型名。</div>
<div class="scroll-wrap">
<table>
<tr>{"".join(headers)}</tr>
{"".join(body_rows)}
</table>
</div>
</body>
</html>"""
with open(HTML, "w", encoding="utf-8") as f:
f.write(html)
print(f"✓ 报告: {HTML} ({HTML.stat().st_size//1024} KB)")
if __name__ == "__main__":
main()
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""接口2女性 花瓣形 单发型 4模型×3分辨率×3图×3次 矩阵测试。
调用本机 hair-worker (:8187) 的 /api/v1/hair/growgender=female, hair_style=2(花瓣形)。
每次记录:生发图、耗时、显存峰值。结果图存到 benchmark_out/matrix/,最后生成 HTML 报告。
"""
import base64
import json
import os
import subprocess
import sys
import time
from pathlib import Path
import requests
API = "http://127.0.0.1:8187/api/v1/hair/grow"
TOKEN = "dev-shared-secret-2026"
OUT = Path("/home/ubuntu/hair/benchmark_out/matrix")
OUT.mkdir(parents=True, exist_ok=True)
# 4 模型 × 3 分辨率 × 3 图 × 3 次
MODELS = [
("4b-fp8", "flux-2-klein-4b-fp8.safetensors"),
("9b-fp8", "flux2.0/flux-2-klein-9b-fp8.safetensors"),
("9b-Q5", "flux-2-klein-9b-Q5_K_M.gguf"),
("9b-Q4", "flux-2-klein-9b-Q4_K_M.gguf"),
]
RES = [("orig", "0"), ("640", "640"), ("896", "896")]
IMGS = [
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
]
REPEAT = 3
def gpu_used():
"""返回当前显存已用 MiB。"""
try:
out = subprocess.check_output(
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
timeout=10,
)
return int(out.decode().strip())
except Exception:
return 0
def call(img_path, model_file, res_val):
"""调一次接口2。返回 dict: ok/elapsed/grown_path/gpu_peak/error。"""
fd = {
"gender": "female",
"hair_style": "2", # 花瓣形
"use_mask": "true",
"prompt": "填充遮罩区域的头发",
}
if model_file:
fd["flux_model"] = model_file
if res_val != "":
fd["redraw_max_side"] = res_val
t0 = time.perf_counter()
peak = gpu_used()
err = None
grown_path = None
try:
with open(img_path, "rb") as f:
r = requests.post(
API, headers={"X-Internal-Token": TOKEN},
files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
data=fd, timeout=300,
)
elapsed = time.perf_counter() - t0
# 采样峰值(推理刚结束)
peak = max(peak, gpu_used())
j = r.json()
if j.get("code") != 0:
err = f"code={j.get('code')} {j.get('message','')}"
else:
res = j.get("data", {}).get("results", [])
if res and res[0].get("grown_image_base64"):
grown_path = OUT / f"tmp_grown.jpg"
with open(grown_path, "wb") as gf:
gf.write(base64.b64decode(res[0]["grown_image_base64"]))
elif res:
err = "grown_image_base64 为空"
else:
err = "无 results"
except Exception as e:
elapsed = time.perf_counter() - t0
err = str(e)[:200]
return {"elapsed": elapsed, "gpu_peak": peak, "grown_path": str(grown_path) if grown_path else None, "error": err}
def main():
results = [] # 每元素一个组合
total = len(MODELS) * len(RES) * len(IMGS) * REPEAT
idx = 0
for mlabel, mfile in MODELS:
for rlabel, rval in RES:
for ilabel, ipath in IMGS:
# 一个组合:3 次
runs = []
for rep in range(REPEAT):
idx += 1
print(f"[{idx}/{total}] {mlabel} | res={rlabel} | {ilabel} | rep{rep+1}", flush=True)
r = call(ipath, mfile, rval)
print(f" -> {r['elapsed']:.1f}s peak={r['gpu_peak']}MiB err={r['error']}", flush=True)
# 存每次的生发图
if r["grown_path"]:
save_to = OUT / f"{mlabel}_{rlabel}_{ilabel}_r{rep+1}.jpg"
os.replace(r["grown_path"], save_to)
r["grown_path"] = str(save_to)
runs.append(r)
results.append({
"model": mlabel, "model_file": mfile,
"res": rlabel, "res_val": rval,
"img": ilabel, "img_path": ipath,
"runs": runs,
})
# 存原始数据
with open(OUT / "results.json", "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\n✓ 全部完成,原始数据 -> {OUT/'results.json'}", flush=True)
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把 benchmark_out/matrix/results.json 生成 HTML 报告。
每个组合一行:原图 + 3次生发图 + 耗时/显存。
图片用 base64 内嵌(自包含单文件,便于部署)。
"""
import base64
import json
import os
from pathlib import Path
OUT = Path("/home/ubuntu/hair/benchmark_out/matrix")
RESULTS = OUT / "results.json"
HTML = OUT / "report.html"
RES_LABEL = {"orig": "原图", "640": "640", "896": "896(默认)"}
MODEL_LABEL = {
"4b-fp8": "4B fp8 (3.8G)",
"9b-fp8": "9B fp8 (8.8G)",
"9b-Q5": "9B Q5_K_M (6.6G)",
"9b-Q4": "9B Q4_K_M (5.6G)",
}
MODEL_ORDER = ["4b-fp8", "9b-Q4", "9b-Q5", "9b-fp8"]
def img_src(path):
"""把绝对路径转成报告里的相对 URL(报告在 static/,图片在 static/bench/)。"""
if not path:
return None
p = str(path)
# benchmark_out/matrix/xxx.jpg -> bench/matrix/xxx.jpg
if "benchmark_out/matrix/" in p:
return "bench/matrix/" + os.path.basename(p)
if "benchmark_out/hairstyle/" in p:
return "bench/hairstyle/" + os.path.basename(p)
return None
def thumb(src, alt="", cls=""):
if not src:
return f'<div class="na {cls}">⚠ 失败</div>'
return f'<img class="{cls}" src="{src}" alt="{alt}" loading="lazy">'
def main():
data = json.load(open(RESULTS, encoding="utf-8"))
# 原图相对路径映射(图片在 static/bench/orig/
ORIG_SRC = {"asdf": "bench/orig/asdf.jpg", "qwer": "bench/orig/qwer.jpg", "girl5": "bench/orig/girl5.jpg"}
# 统计:每个模型的平均耗时、平均峰值显存
stats = {}
for c in data:
m = c["model"]
stats.setdefault(m, {"times": [], "peaks": []})
for r in c["runs"]:
if not r["error"]:
stats[m]["times"].append(r["elapsed"])
stats[m]["peaks"].append(r["gpu_peak"])
rows_html = []
# 按模型顺序、分辨率顺序、图片顺序排列
for m in MODEL_ORDER:
mdata = [c for c in data if c["model"] == m]
for rlabel in ["orig", "640", "896"]:
for ilabel in ["asdf", "qwer", "girl5"]:
c = next((x for x in mdata if x["res"] == rlabel and x["img"] == ilabel), None)
if not c:
continue
# 3 次结果图
run_cells = []
for i, r in enumerate(c["runs"]):
src = img_src(r["grown_path"]) if not r["error"] else None
if src:
run_cells.append(
f'<div class="run-cell"><div class="run-label">第{i+1}次 · {r["elapsed"]:.1f}s</div>'
f'{thumb(src, f"r{i+1}", "result-img")}</div>'
)
else:
run_cells.append(
f'<div class="run-cell"><div class="run-label">第{i+1}次 · 失败</div>'
f'<div class="na">⚠ {r["error"][:30] if r["error"] else ""}</div></div>'
)
orig = ORIG_SRC.get(c["img"])
rows_html.append(f'''
<div class="combo-row">
<div class="cell-model">{MODEL_LABEL.get(m, m)}<div class="cell-sub">res={RES_LABEL.get(rlabel, rlabel)}</div></div>
<div class="cell-img">{thumb(orig, "原图", "orig-img")}<div class="run-label">{ilabel}</div></div>
<div class="cell-runs">{"".join(run_cells)}</div>
</div>''')
# 模型对比汇总
summary_rows = []
for m in MODEL_ORDER:
s = stats.get(m, {"times": [], "peaks": []})
if s["times"]:
avg_t = sum(s["times"]) / len(s["times"])
max_p = max(s["peaks"]) / 1024
summary_rows.append(
f"<tr><td>{MODEL_LABEL.get(m,m)}</td><td>{avg_t:.1f}s</td>"
f"<td>{max_p:.1f} GB</td><td>{len(s['times'])} 成功</td></tr>"
)
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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; padding: 20px; }}
h1 {{ font-size: 22px; margin-bottom: 4px; }}
.subtitle {{ color: #888; font-size: 13px; margin-bottom: 16px; }}
.summary {{ background: #fff; border-radius: 10px; padding: 16px 20px; margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }}
.summary h2 {{ font-size: 16px; margin-bottom: 10px; }}
.summary table {{ border-collapse: collapse; width: 100%; font-size: 14px; }}
.summary th, .summary td {{ border: 1px solid #e5e7eb; padding: 8px 12px; text-align: left; }}
.summary th {{ background: #f9fafb; font-weight: 600; }}
.combo-row {{ display: flex; align-items: flex-start; gap: 12px; background: #fff; border-radius: 10px;
padding: 12px 16px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.05); }}
.cell-model {{ min-width: 130px; font-weight: 700; font-size: 14px; padding-top: 6px; }}
.cell-sub {{ font-weight: 400; font-size: 12px; color: #6b7280; margin-top: 2px; }}
.cell-img {{ min-width: 160px; text-align: center; }}
.cell-runs {{ display: flex; gap: 10px; flex: 1; }}
.run-cell {{ text-align: center; }}
.run-label {{ font-size: 11px; color: #6b7280; margin-bottom: 4px; }}
img {{ border-radius: 6px; max-height: 200px; max-width: 100%; object-fit: contain; background: #f9fafb; }}
.orig-img {{ max-height: 180px; border: 2px solid #e5e7eb; }}
.result-img {{ max-height: 200px; }}
.na {{ color: #d1d5db; font-size: 12px; padding: 40px 20px; background: #f9fafb; border-radius: 6px; width: 150px; }}
</style>
</head>
<body>
<h1>💇 Flux 模型矩阵测试报告</h1>
<p class="subtitle">接口2女性 · 花瓣形发型 · 4模型 × 3分辨率 × 3图 × 3次 = 108 次 · RTX 3090 24GB</p>
<div class="summary">
<h2>📊 模型对比汇总</h2>
<table>
<tr><th>模型</th><th>平均耗时</th><th>峰值显存</th><th>成功次数</th></tr>
{"".join(summary_rows)}
</table>
</div>
<h2 style="font-size:16px;margin:24px 0 12px">🖼️ 各组合对比(每行:原图 + 3次生发结果)</h2>
{"".join(rows_html)}
</body>
</html>"""
with open(HTML, "w", encoding="utf-8") as f:
f.write(html)
print(f"✓ 报告已生成: {HTML} ({HTML.stat().st_size//1024} KB)")
if __name__ == "__main__":
main()
+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 -1
View File
@@ -68,7 +68,7 @@
`face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182**
`add_hair.json`(Flux-2) 出图。**关键坑**obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
返回 `results[].image_base64` + `grown_image_base64`
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wavemale 1=ellipse 2=inverse_arc 3=m 4=straight。
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave 6=bigflower 7=clasicalflowermale 1=ellipse 2=inverse_arc 3=m 4=straight 5=heart 6=Softpetal。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线;female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`worker)—— 接口2同款,add_hair2 工作流
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` 逗号分隔多选 → N 组预览+生发图)。
+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
```
+14 -62
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`(四庭,自上而下):
@@ -211,6 +213,8 @@
| four_courts | 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 有)。
@@ -261,7 +265,7 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 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` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflowermale1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。越界/非法返回 `1007` |
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
@@ -274,7 +278,7 @@
|------|------|------|
| 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 |
| hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`male |
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
> ⚠️ 生发图由本机 ComfyUIFlux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
@@ -401,10 +405,11 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 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` |
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 7 / male 6)。缺失/非法返回 `1004` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflowermale1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。缺失/越界/非法返回 `1007` |
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| 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 张生发图。
@@ -422,11 +427,11 @@
| 字段 | 类型 | 说明 |
|------|------|------|
| hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`female),`ellipse`/`m`/`straight`/`inverse_arc`male |
| hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`male |
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败时为 `null` |
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败`generate_grow_image=false` 时为 `null` |
| order | int | 发型序号(= 传入的 hair_style 值) |
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。
@@ -443,6 +448,8 @@
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算) |
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
@@ -508,60 +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` 时用干净原图生成(空遮罩、不烧模板黑线) |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
### 输出(data
与接口 2 完全相同。`results`:发际线方案数组,**数量 = 所选发型数**。每个元素:
| 字段 | 类型 | 说明 |
|------|------|------|
| image_url | string | 发际线曲线**透明 PNG** 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
}
]
}
}
```
---
## 汇总:输入输出一览
| 接口 | 输入 | 主要输出 |
@@ -572,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 工作流 |
---
+61 -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,97 @@ 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)) # 文字底部到线的间距(再上移)
# --- 2b. 每条横线在「中间线段」(两内眼角之间)中点画原点突出 ---
# 注意:原点不在整条线的中点 face_cx,而在被竖线切出的中间段(左内眼角↔右内眼角)
# 的正中,即脸的竖直中轴附近、两内眼角连线中点。
li_x = pts["left_inner"][0]
ri_x = pts["right_inner"][0]
dot_cx = (li_x + ri_x) / 2
dot_r = max(2, round(s * 0.0045)) # 原点半径,与 arrow_size 同档自适应
for cy in ys:
x0, y0 = dot_cx - dot_r, cy - dot_r
x1, y1 = dot_cx + dot_r, cy + dot_r
draw.ellipse((x0, y0, x1, y1), fill=LINE_COLOR)
# --- 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 的版本)
+284 -157
View File
@@ -47,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")
@@ -99,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 构件)
# ---------------------------------------------------------------------------
@@ -299,31 +317,48 @@ def _pushed_mask(hair_mask, upper, baseline_pts, push_px, rid="",
def _redraw_band_mask(inner_pts, outer_pts, h, w, rid=""):
"""重绘带遮罩:把发际线(①-f 内轮廓 inner_pts)和外推发际线(①-g outer_pts
两条折线的端点连接成闭合多边形,填充得到带状区域,作为重绘 mask。
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 径向外推 push_px),
闭合环 = inner_pts(正向)+ outer_pts(反向)首尾相接。带只覆盖「现有头发下沿
到外推线」这一段(在头发一侧),正好是发际线交界处需要重绘融合的窄带
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)
# 闭合多边形:内轮廓正向 + 外推线反向,端点自然相连
ring = np.vstack([inner_pts.astype(np.int32), outer_pts[::-1].astype(np.int32)])
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
lg(f"_redraw_band_mask: 内轮廓点={len(inner_pts)} 外推点={len(outer_pts)} "
f"band像素={int(band.sum())}")
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。
@@ -331,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
@@ -344,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) # 闭合区域:头发+额头,底=基线
@@ -391,51 +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 点)红点,标示径向外推的中心(_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)
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")}
@@ -557,7 +603,7 @@ _REPAINT_WORKFLOW = os.path.join(os.path.dirname(os.path.dirname(__file__)), "ha
def _call_comfyui(image_bgr, mask_bool, prompt=None):
"""本机 ComfyUI 的 Flux-2 inpaint 工作流(hair_repaint.json),返回与输入同分辨率的 BGR。
"""远端 ComfyUI 的 Flux-2 inpaint 工作流(hair_repaint.json),返回与输入同分辨率的 BGR。
与 swapHair 的区别:ComfyUI 把「原图 VAE 编码作 reference latent + ColorMatch」双重保色,
天生不易染色;提示词自由可调(中文)。mask 经 RGBA alpha 通道传入(透明=重绘区)。
@@ -565,10 +611,10 @@ def _call_comfyui(image_bgr, mask_bool, prompt=None):
"""
import io
from hairline.mask import compose_comfy_rgba
from hairline.comfyui import run as comfyui_run, ping
from hairline.comfyui import COMFYUI_URL, run as comfyui_run, ping
if not ping():
raise SwapError("ComfyUI 不可达(http://127.0.0.1:8188),redraw Flux-2 路跳过")
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()
@@ -804,47 +850,28 @@ def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erod
# 主入口
# ---------------------------------------------------------------------------
def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segformer",
erode_cm=0.6, swap_mode="ext_mask",
edge_erode_px=3,
denoising_strength=0.6, gen_backend="swaphair",
hairgrow_strength=0.75, mb_levels=5,
hairline_push_cm=1.0, hairline_edge="column",
blend_method="multiband", color_match=True,
color_match_strength=1.0, mb_feather_px=1,
transition_band_px=-1, redraw=False,
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
comfyui_prompt=None, rid=None):
"""接口11 完整管线。返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
edge_erode_px, denoising_strength, gen_backend, hairgrow_strength,
mb_levels, hairline_push_cm, hairline_edge, blend_method, color_match,
color_match_strength, mb_feather_px, transition_band_px,
inpainting_fill, mask_blur, mask_dilate_scale, rid, render_viz=True,
hair_mask=None):
"""接口11 共享核心:遮罩(pushed)→生成→硬贴回→接缝融合,产出 ④ final。
遮罩算法固定为 pushed(发际线外推)。
融合算法 blend_method 默认 multiband(多频段金字塔),可选 seamless(泊松)/
two_stage(泊松→多频段两段式)/feather(羽化)/alpha_gradient(距离变换)。
color_match 默认开启 Reinhard 颜色迁移消除整体色差(对 multiband/feather 有效)
redraw=True 时额外跑一条「发际线带重绘」分支:
重绘区域 = 外推发际线↔内推发际线之间的带(以原内轮廓为中心,向头发/脸各推 push_cm)。
输入图 + 融合基底都用 final(④接缝融合最终图)。两路后端对比:
② swapHair 路(final+band 重绘→final 融合)
③ Flux-2 路(final+band 调 ComfyUI 保色重绘→final 融合)
结果在 steps.redraw_a/redraw_c 单独展示,不替换 final。
comfyui_promptFlux-2 路提示词,None 用默认「补充遮罩区域的头发,加一点美颜」。
inpainting_fill/mask_blur/mask_dilate_scale:透传 change_hair 服务端重绘参数(默认值
=服务端原始硬编码值,未传行为不变)。inpainting_fill=0 保留原图可治"染绿"
rid: 调用方的 request id,用于日志关联。为 None 时自动生成。
不做任何重绘。返回中间产物 dict(供接口11 构造响应、接口12 取 final+重绘带用):
final / swap_result / hard_paste / alpha / mask_bool / mask_viz /
px_per_cm / t_mask / t_swap / t_blend / h / w
未检出人脸抛 NoFaceError
"""
mask_type = "pushed" # 固定:只支持 pushed 遮罩算法
# blend_method 由参数传入(默认 multiband,接口11 可覆盖)
if rid is None:
rid = uuid4().hex[:8]
logger.info("[%s] ===== generate_hairline_grow 开始 =====", rid)
logger.info("[%s] 参数(固定 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 redraw=%s "
"inpainting_fill=%s mask_blur=%s mask_dilate_scale=%s comfyui_prompt=%r",
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, redraw,
inpainting_fill, mask_blur, mask_dilate_scale, comfyui_prompt)
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:
@@ -857,7 +884,8 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
t0 = time.time()
mask_bool, mask_viz = compute_mask(
image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm,
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, rid=rid)
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, rid=rid,
render_viz=render_viz, hair_mask=hair_mask)
t_mask = time.time() - t0
logger.info("[%s] 步骤1 遮罩完成 耗时=%dms mask_pixels=%d", rid, int(t_mask*1000), int(mask_bool.sum()))
@@ -876,7 +904,7 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
hard_paste = image_bgr.copy()
hard_paste[mask_bool] = swap_result[mask_bool]
# 步骤4:接缝融合(默认 multiband)
# 步骤4:接缝融合(默认 multiband)→ ④ final
t0 = time.time()
final, alpha = _composite(
image_bgr, swap_result, mask_bool, blend_method, 0, edge_erode_px,
@@ -885,69 +913,54 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px)
t_blend = time.time() - t0
# 步骤5(可选):发际线带重绘分支 —— 开关 redraw=True 时执行,结果单独展示。
# 重绘区域 = 外推发际线↔内推发际线之间的带(以原内轮廓为中心,向头发/脸各推 push_cm)。
# 输入图 + 融合基底都用 final(④接缝融合最终图)。
redraw_viz = {
"redraw_band_overlay_base64": "",
"redraw_a_base64": "",
"redraw_c_base64": "",
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,
}
redraw_info = {"enabled": False}
if redraw:
t0 = time.time()
logger.info("[%s] 步骤5 发际线带重绘 开始", rid)
# ① 算重绘带:发际线(内轮廓)↔外推发际线 两条折线端点相连组成的带
inner_pts = mask_viz.get("_inner_pts")
outer_pts = mask_viz.get("_outer_pts")
push_px = int(round(max(0.0, hairline_push_cm) * px_per_cm))
try:
band_mask = _redraw_band_mask(inner_pts, outer_pts, h, w, rid=rid)
if band_mask.sum() < 30:
raise RuntimeError("重绘带像素过少,可能内轮廓/外推线缺失")
logger.info("[%s] 步骤5 重绘带 push_px=%d band_pixels=%d",
rid, push_px, int(band_mask.sum()))
redraw_viz["redraw_band_overlay_base64"] = _jpg_b64(
_overlay(final, band_mask, (255, 0, 255)))
redraw_info = {"enabled": True, "band_pixels": int(band_mask.sum()),
"push_px": push_px}
except Exception as ex: # noqa: BLE001
logger.exception("[%s] 步骤5 重绘带计算失败,整个重绘跳过", rid)
redraw_info = {"enabled": False, "error": f"band: {ex}"}
# ② swapHair 路:final + band 作 ext_mask 重绘 → final 作基底融合
if redraw_info.get("enabled"):
try:
redraw_a_raw = _call_swap(final, hairline_id, is_hr, band_mask, denoising_strength,
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
mask_dilate_scale=mask_dilate_scale)
final_a, _ = _composite(
final, redraw_a_raw, band_mask, 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)
redraw_viz["redraw_a_base64"] = _jpg_b64(final_a)
logger.info("[%s] 步骤5 swapHair路完成", rid)
except Exception as ex: # noqa: BLE001
logger.warning("[%s] 步骤5 swapHair路失败,跳过: %s", rid, ex)
redraw_info["swap_error"] = str(ex)
# ③ Flux-2 路:final + band 调 ComfyUIreference latent 保色,不染绿)→ final 融合
try:
prompt = comfyui_prompt if comfyui_prompt else "补充遮罩区域的头发,加一点美颜"
redraw_c_raw = _call_comfyui(final, band_mask, prompt=prompt)
final_c, _ = _composite(
final, redraw_c_raw, band_mask, 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)
redraw_viz["redraw_c_base64"] = _jpg_b64(final_c)
logger.info("[%s] 步骤5 Flux-2路完成", rid)
except Exception as ex: # noqa: BLE001
logger.warning("[%s] 步骤5 Flux-2路失败,跳过: %s", rid, ex)
redraw_info["c_error"] = str(ex)
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
logger.info("[%s] 步骤5 发际线带重绘完成 耗时=%dms", rid, int((time.time()-t0)*1000))
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,
@@ -955,7 +968,7 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
"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,
@@ -971,16 +984,16 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
"inpainting_fill": int(inpainting_fill),
"mask_blur": int(mask_blur),
"mask_dilate_scale": round(float(mask_dilate_scale), 3),
"px_per_cm": round(float(px_per_cm), 4),
"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),
@@ -996,21 +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),
# 步骤5(可选):发际线带重绘分支 —— redraw=False 时为空串
"redraw_band_overlay_base64": redraw_viz["redraw_band_overlay_base64"],
"redraw_a_base64": redraw_viz["redraw_a_base64"],
"redraw_c_base64": redraw_viz["redraw_c_base64"],
"final_base64": _jpg_b64(core["final"]),
},
"redraw": redraw_info,
"_rid": rid, # 调用方的 request id,用于日志关联。为 None 时自动生成。
"_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
+3 -2
View File
@@ -24,8 +24,9 @@ from face_analysis.calibration import estimate_scale_factor, normalized_to_pixel
# 底部分割线关键点(图像上从左到右,眉骨弧线 → 中心 151 → 右侧对称)
# 左端 104 → 中心 151 → 右端 333;首末点向图片左右边缘水平延长
BASELINE_IDX = [104, 69, 108, 151, 337, 299, 333]
# BASELINE_IDX = [123, 116, 143, 71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301, 372, 345, 352,]
# BASELINE_IDX = [104, 69, 108, 151, 337, 299, 333]
# BASELINE_IDX = [34, 139, 71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301, 368, 264]
BASELINE_IDX = [71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301]
CENTER_IDX = 151 # 内缩方向的目标点(额头中心)
ERODE_CM = 1.2 # 外缘内缩距离(厘米,默认;可由入参覆盖)
SEGFORMER_HAIR = 13 # jonathandinu/face-parsing 中 hair 类索引
+113 -47
View File
@@ -12,9 +12,9 @@ from face_analysis.calibration import (
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
)
from face_analysis.face_mesh_landmarks import (
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
GLABELLA_9, NOSE_BOTTOM, CHIN_TIP,
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
LEFT_CHEEK, RIGHT_CHEEK,
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
)
from face_analysis.hair_segmenter import locate_hairline_by_segmentation
@@ -24,10 +24,8 @@ _TOP_RATIO = 0.22 / 0.28 # 顶庭 ÷ 中庭(≈ 0.786)
def _brow_center(lm, w, h):
"""眉心 = 索引 9 / 151 中点"""
g9 = normalized_to_pixel(lm[GLABELLA_9], w, h)
g151 = normalized_to_pixel(lm[GLABELLA_151], w, h)
return (g9[0] + g151[0]) / 2, (g9[1] + g151[1]) / 2
"""眉心 = 索引 9(眉间上点)"""
return normalized_to_pixel(lm[GLABELLA_9], w, h)
def estimate_vertical_landmarks(landmarks, image_width, image_height):
@@ -144,22 +142,47 @@ def measure_seven_eyes(landmarks, image_width, image_height):
}
def pt_or_none(vertical, name):
"""vertical dict 的点 → {"x","y"},值为 None 时返回 None。"""
v = vertical.get(name)
if v is None:
return None
return {"x": int(round(v[0])), "y": int(round(v[1]))}
class MeasureResult:
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose):
# 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。
# hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线
# 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。
HAIRLINE_DISCARD_TOP_CM = 0.7
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose,
landmarks=None, image_width=None, image_height=None):
self.vertical = vertical
self.eyes = eyes
self.px_per_cm = px_per_cm
self.hairline_source = hairline_source
self.head_pose = head_pose # (yaw, pitch, roll) 或 None
# 原始 mediapipe 点集 + 图像尺寸,供 to_response 输出 21/251 号定位点
self.landmarks = landmarks
self.w = image_width
self.h = image_height
# 各庭厘米
self.top_cm = vertical["top_court_px"] / px_per_cm
self.upper_cm = vertical["upper_court_px"] / px_per_cm
self.middle_cm = vertical["middle_court_px"] / px_per_cm
self.lower_cm = vertical["lower_court_px"] / px_per_cm
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
# 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。
# 弃用时 hairline_source 改为 "discarded"face_total 只算中庭+下庭。
self.hairline_discarded = self.top_cm < self.HAIRLINE_DISCARD_TOP_CM
if self.hairline_discarded:
self.hairline_source = "discarded"
self.face_total_cm = self.middle_cm + self.lower_cm
else:
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
# 七眼厘米
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
@@ -167,46 +190,88 @@ class MeasureResult:
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
def to_response(self):
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
fw_px = self.eyes["face_width_px"]
def pt(name):
x, y = self.vertical[name]
return {"x": int(round(x)), "y": int(round(y))}
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": round(self.top_cm, 2),
"upper_court_cm": round(self.upper_cm, 2),
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
# 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭;
# landmarks.hair_top/hairline 置 null。否则按四庭正常输出。
if self.hairline_discarded:
base_px = (self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": None,
"upper_court_cm": None,
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": None,
"upper_court": None,
"middle_court": round(self.vertical["middle_court_px"] / base_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / base_px, 3),
},
},
},
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / fw_px, 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / fw_px, 3),
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
},
},
"landmarks": {
"hair_top": pt("hair_top"),
"hairline": pt("hairline"),
"brow_center": pt("brow_center"),
"nose_bottom": pt("nose_bottom"),
"chin_tip": pt("chin_tip"),
},
"hairline_source": self.hairline_source,
}
"landmarks": {
"hair_top": None,
"hairline": None,
"brow_center": pt_or_none(self.vertical, "brow_center"),
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
},
"hairline_source": self.hairline_source,
}
else:
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": round(self.top_cm, 2),
"upper_court_cm": round(self.upper_cm, 2),
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
},
},
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
},
"landmarks": {
"hair_top": pt_or_none(self.vertical, "hair_top"),
"hairline": pt_or_none(self.vertical, "hairline"),
"brow_center": pt_or_none(self.vertical, "brow_center"),
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
},
"hairline_source": self.hairline_source,
}
# left/right_positionmediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。
# landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。
if self.landmarks is not None and self.w and self.h:
lm = _lm_list(self.landmarks)
def _pt_lm(idx):
px, py = normalized_to_pixel(lm[idx], self.w, self.h)
return {"x": int(round(px)), "y": int(round(py))}
data["left_position"] = _pt_lm(LEFT_POSITION)
data["right_position"] = _pt_lm(RIGHT_POSITION)
if self.head_pose is not None:
yaw, pitch, roll = self.head_pose
data["head_pose"] = {
@@ -220,7 +285,8 @@ def measure_face(landmarks, hair_mask, image_width, image_height, head_pose=None
vertical, source = decide_vertical(landmarks, image_width, image_height, hair_mask)
eyes = measure_seven_eyes(landmarks, image_width, image_height)
px_per_cm = estimate_scale_factor(landmarks, image_width, image_height)
return MeasureResult(vertical, eyes, px_per_cm, source, head_pose)
return MeasureResult(vertical, eyes, px_per_cm, source, head_pose,
landmarks, image_width, image_height)
if __name__ == "__main__":
+10 -1
View File
@@ -50,12 +50,21 @@ def estimate_head_pose(landmarks, image_width, image_height):
[0, 0, 1]], dtype=np.float64)
dist = np.zeros((4, 1)) # 假设无畸变
success, rvec, _tvec = cv2.solvePnP(
success, rvec, tvec = cv2.solvePnP(
_MODEL_POINTS, image_points, cam_matrix, dist,
flags=cv2.SOLVEPNP_ITERATIVE,
)
if not success:
return None
# ITERATIVE 偶发收敛到相机后方的翻转解(tz<0),此时 roll 落在 ±180° 附近,
# 会把真正的正面照误判为 1003。改用 SQPNP 重解正深度解。
if float(tvec[2, 0]) < 0:
ok2, rvec2, tvec2 = cv2.solvePnP(
_MODEL_POINTS, image_points, cam_matrix, dist,
flags=cv2.SOLVEPNP_SQPNP,
)
if ok2 and float(tvec2[2, 0]) > 0:
rvec = rvec2
rot, _ = cv2.Rodrigues(rvec)
# 在「相机坐标系」(x右 y下 z内) 下抽取 Tait-Bryan 欧拉角,物理含义对齐:
# yaw = 绕 Y(竖轴)转 → 左右扭头
+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
+3 -3
View File
@@ -170,10 +170,10 @@
},
"16": {
"inputs": {
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
"weight_dtype": "fp8_e4m3fn"
},
"class_type": "UNETLoader",
"class_type": "UnetLoaderGGUF",
"_meta": {
"title": "UNet加载器"
}
@@ -410,7 +410,7 @@
},
"60": {
"inputs": {
"text": "充遮罩区域的头发,加一点美颜"
"text": "充遮罩区域的头发"
},
"class_type": "JjkText",
"_meta": {
+54 -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
@@ -28,6 +30,19 @@ _REPO = os.path.dirname(os.path.dirname(__file__))
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
_SEED_NODE = "6" # RandomNoise
_PROMPT_NODE = "60" # JjkText:提示词
_UNET_NODE = "16" # UNETLoader / UnetLoaderGGUFFlux 模型加载
_CLIP_NODE = "61" # CLIPLoaderqwen 文本编码器
# Flux 模型 → 配套文本编码器映射。切换 unet 时自动同步编码器,避免维度不匹配。
# 规则:4b 系列配 qwen_3_4b9b 系列(fp8/GGUF)配 qwen_3_8b_fp8mixed。
def _clip_for_unet(unet_name: str) -> str | None:
"""根据 unet 文件名推断配套的文本编码器文件名;无法推断返回 None。"""
low = unet_name.lower()
if "4b" in low and "9b" not in low:
return "qwen_3_4b.safetensors"
if "9b" in low:
return "qwen_3_8b_fp8mixed.safetensors"
return None
_wf_cache: dict[str, dict] = {} # path → workflow JSON
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
@@ -84,11 +99,18 @@ 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,
unet_name: str | None = None) -> bytes:
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
prompt None 时替换工作流节点60(JjkText)的文本None 时用工作流内置默认提示词
workflow_path工作流 JSON 路径None 则用默认 add_hair.json
frontTrue 时任务插到 ComfyUI 队列最前server "front" 字段队列号取负
接口2 对时延敏感用 True避免排在接口3/5 的批量任务后面其余接口保持 False
unet_name None 时改写工作流里的模型加载节点节点16动态切换 Flux 模型
.safetensors 保持 UNETLoader 节点类型不变只替换 unet_name
.gguf 自动把节点类型改成 UnetLoaderGGUF需装 ComfyUI-GGUF 插件
None 时用工作流内置默认模型
"""
path = workflow_path or _WORKFLOW_DEFAULT
output_node = _get_output_node(path)
@@ -103,11 +125,34 @@ 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
if unet_name is not None:
node = wf.get(_UNET_NODE)
if node is not None:
# .gguf 需切换到 ComfyUI-GGUF 插件的 UnetLoaderGGUF 节点;
# .safetensors/.ckpt 保持原 UNETLoader 节点类型不变
if unet_name.lower().endswith(".gguf"):
node["class_type"] = "UnetLoaderGGUF"
else:
node["class_type"] = "UNETLoader"
node["inputs"]["unet_name"] = unet_name
# 同步切换配套文本编码器(4b→qwen_3_4b, 9b→qwen_3_8b),避免维度不匹配
clip_node = wf.get(_CLIP_NODE)
clip_name = _clip_for_unet(unet_name)
if clip_node is not None and clip_name is not None:
clip_node["inputs"]["clip_name"] = clip_name
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
try:
@@ -124,8 +169,11 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
except Exception: # noqa: BLE001
pass
# 3. 提交
r = cli.post("/prompt", json={"prompt": wf, "client_id": client_id})
# 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"]
@@ -144,7 +192,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.
#
+80
View File
@@ -0,0 +1,80 @@
"""直接调 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, unet_name: str | None = None) -> bytes:
"""直接调 ComfyUI 重绘 — 替代 local_test /api/generate。
Args:
image_bytes: 人物图片字节JPG/PNG
mask_bytes: 遮罩图片字节支持红//alpha 遮罩格式
prompt: 提示词None 用默认 "填充遮罩区域的头发"
timeout: ComfyUI 超时秒数
front: True 时任务插到 ComfyUI 队列最前接口2 时延敏感路径用
unet_name: None 时切换 Flux 模型 flux-2-klein-9b-Q5_K_M.ggufNone 用工作流默认
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,
unet_name=unet_name)
+344 -27
View File
@@ -14,21 +14,93 @@ 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 6/7bigflower/clasicalflower)无对应 LoRA
# 走与男性一致的原生生发(ComfyUI add_hair)管线,故不在本表。
_FEMALE_KEY_TO_CHANG = {
"ellipse": "chang_tuoyuan", # 椭圆
"flower": "chang_huaban", # 花瓣
"heart": "chang_xinxing", # 心形
"straight": "chang_zhixian", # 直线
"wave": "chang_bolang", # 波浪
}
# 发际线贴图显式顺序表:决定 hair_style 序号(1-indexed)。
# 不再依赖文件名字母序——字母序会因新增/重命名文件而错位,破坏现有前端/客户端取值。
# key 须与 _gender_key 派生结果一致(已去空格):如 "inverse_arc"(源 man_ inverse_arc.png)、
# "Softpetal"(源 man_Soft petal.png,大写 S 保留)。表外未知 key 兜底排到末尾。
_HAIRSTYLE_ORDER = {
"female": ["ellipse", "flower", "heart", "straight", "wave",
"bigflower", "clasicalflower"], # 1..7
"male": ["ellipse", "inverse_arc", "m", "straight", "heart", "Softpetal"], # 1..6
}
_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 且激活显存把模型挤出。
# 策略:输入图长边 > REDRAW_MAX_SIDE 才等比缩到该长边;≤ 时原图分辨率直送(不放大)。
# 默认 1024:大于 1024 的图压到 1024(画质/速度均衡),≤1024 的小图保持原分辨率重绘。
# 可用 REDRAW_MAX_SIDE 覆盖;0=永不缩图(原图直送)。
_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "1024"))
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0,
max_side=None, unet_name=None):
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
final + 纯红遮罩 PNG返回重绘后的 PNG bytes
失败抛异常调用方负责 try/except 跳过
max_side ComfyUI 前长边压到多少像素None 用全局默认 _REDRAW_MAX_SIDE
unet_name None 时切换 Flux 模型None 用工作流内置默认
"""
from .redraw import run_redraw
eff_side = _REDRAW_MAX_SIDE if max_side is None else max_side
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 eff_side > 0 and m > eff_side:
scale = eff_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, eff_side)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前,避免排在接口3/5 的批量任务后面
out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout,
prompt=_REDRAW_PROMPT, front=True, unet_name=unet_name)
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 +108,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
@@ -69,10 +140,13 @@ def _gender_key(stem: str):
def get_texture_map(level: str = "middle") -> dict:
"""扫描指定档位贴图目录建 {gender: [(key, path)]},按 key 排序、按档位缓存。
"""扫描指定档位贴图目录建 {gender: [(key, path)]},按显式顺序表排序、按档位缓存。
levelmiddle(默认) / high / low分别对应 hairline_texture[/_high|/_low]
文件名规范化去空格 `man_ inverse_arc.png` key `inverse_arc`
排序依据 _HAIRSTYLE_ORDER表内 key 按表序表外未知 key 兜底排到末尾再按字母序
保证新增/重命名文件不会打乱现有 hair_style 序号
"""
if level not in _TEXTURE_DIRS:
raise ValueError(f"hairline_level 必须是 middle/high/low,收到 {level!r}")
@@ -86,7 +160,9 @@ def get_texture_map(level: str = "middle") -> dict:
if gender:
mapping[gender].append((key, path))
for g in mapping:
mapping[g].sort(key=lambda kp: kp[0])
order = _HAIRSTYLE_ORDER.get(g, [])
idx = {k: i for i, k in enumerate(order)}
mapping[g].sort(key=lambda kp: (idx.get(kp[0], len(idx)), kp[0]))
_texture_maps[level] = mapping
return mapping
@@ -100,13 +176,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)
@@ -140,10 +222,11 @@ 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):
workflow_path: str | None = None,
unet_name: str | None = None):
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
hair_styles1-indexed 列表指定生成哪几张发际线按贴图排序female: 1..5male: 1..4
hair_styles1-indexed 列表指定生成哪几张发际线按贴图排序female: 1..7male: 1..6
None 时生成全部兼容旧调用
use_mask默认 True是否启用 inpaint 遮罩用于测试对比同接口3
False 时用**干净原图 + 空遮罩** ComfyUI不烧黑色模板线
@@ -171,9 +254,15 @@ 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,
unet_name=unet_name)
if gsc < 1.0 and shared_grown:
shared_grown = _upscale_png_to(shared_grown, w, h)
except Exception as e: # noqa: BLE001
logger.warning("接口2 生发图失败(无遮罩)%s", e)
@@ -191,9 +280,15 @@ 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,
unet_name=unet_name)
if gsc < 1.0 and grown_png:
grown_png = _upscale_png_to(grown_png, w, h)
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
logger.warning("接口2 生发图失败 type=%s%s", key, e)
@@ -202,6 +297,118 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
return results
def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
redraw_defaults: dict,
redraw_max_side: int | None = None,
unet_name: str | None = None,
prompt: str | None = None):
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results+ 生发图。
生发图来源按发型分两路
- 换发型chang_* 表内1..5female key change_hair chang_* hair_id
face_analysis.hairline_grow.generate_hairline_redraw= 接口12 final 管线参数用
redraw_defaults拿到 final接缝融合基底+ - 纯红遮罩 PNG**后端直接调
ComfyUI**0716add-hair-api.json 工作流完成发际线带重绘
- 原生生发表外6/7 bigflower/clasicalflower 无对应 change_hair LoRA改走与男性
一致的原生生发ComfyUI add_hair _grow_native_one 完成黑模板 + inpaint 遮罩
overlay 仍是发际线曲线透明层 generate_grow_results 完全一致
prompt 仅用于原生生发分支换发型分支的提示词由 redraw 流程内部固定
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 最后放大回原尺寸。
eff_side = _REDRAW_MAX_SIDE if redraw_max_side is None else redraw_max_side
redraw_img = image_bgr
hair_mask_redraw = hair_mask_reuse
if eff_side > 0 and max(h, w) > eff_side:
redraw_img, _rs = _downscale_max_side(image_bgr, eff_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, eff_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:
# 无对应 change_hair LoRA(如 bigflower/clasicalflower)→ 走与男性一致的原生生发
logger.info("接口2 女 key=%s 无 chang_id,走原生生发(ComfyUI add_hair)", key)
grown_png = _grow_native_one(image_bgr, ctx, white_path,
prompt=prompt, unet_name=unet_name)
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,
max_side=redraw_max_side,
unet_name=unet_name)
_tr1 = _t.perf_counter()
_tm = data.get("timings_ms") or {}
logger.info("接口2女 分段计时 type=%s: swapHair管线=%.2fs (mask=%dms swap=%dms blend=%dms), ComfyUI重绘=%.2fs",
key, _ts1 - _ts0,
_tm.get("mask", 0), _tm.get("swap", 0), _tm.get("blend", 0),
_tr1 - _tr0)
if grown_png is None:
logger.warning("接口2 换发型:type=%s 重绘结果为空", key)
elif redraw_img is not image_bgr:
# 管线在降分辨率图上跑,结果放大回原尺寸
grown_png = _upscale_png_to(grown_png, w, h)
except NoFaceError:
logger.warning("接口2 换发型:type=%s 未检出人脸", key)
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
logger.warning("接口2 换发型图失败 type=%s%s", key, e)
results.append({"hairline_type": key, "order": order,
"overlay": overlay, "grown_png": grown_png})
return results
def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
use_mask: bool, prompt: str | None):
"""对单个发际线做生发(ComfyUI)。黑模板固定取 hairline_texture_black/middle),
@@ -218,22 +425,59 @@ 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)
return None
def _grow_native_one(image_bgr: np.ndarray, ctx: dict, white_path: str,
prompt: str | None = None, unet_name: str | None = None):
"""对单个发际线做原生生发(ComfyUI add_hair),与男性 generate_grow_results 一致。
接口2 女性新发型bigflower/clasicalflower 无对应 change_hair LoRA改走此路径
黑模板 build_inpaint_mask 限边_prep_comfy_input comfyui.run(front=True)
失败返回 None不抛异常结果按限边前原图尺寸放大回原尺寸仅展示对齐
"""
try:
h, w = image_bgr.shape[:2]
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(m_s, msk_s).save(buf, format="PNG", compress_level=1)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, front=True, unet_name=unet_name)
if gsc < 1.0 and grown_png:
grown_png = _upscale_png_to(grown_png, w, h)
return grown_png
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
logger.warning("接口2 女原生生发图失败:%s", e)
return None
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
hair_styles: list[int], use_mask: bool = True,
prompt: str | None = None):
prompt: str | None = None,
generate_grow_image: bool = True,
redraw_max_side: int | None = None,
unet_name: str | None = None,
v2_defaults: dict | None = None):
"""接口5:对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。
入参同接口2先选 gender再多选 hair_styles必填1-indexed 按贴图排序
每个选中发型返回三档叠图middle/high/lowRGBA 透明层只含发际线曲线与一张生发图
三档贴图同名生发黑模板固定取自 hairline_texture_black/middle故生发目标固定 middle
use_mask/prompt同接口2 的生发参数
生发机制同接口2按性别分流
- femalegenerate_grow_results_swapswapHair + Flux-2 整帧重绘
- malegenerate_grow_resultsComfyUI add_hair inpaint
redraw_max_side / unet_name / v2_defaultsfemale 路径参数同接口2
male 路径仅用 unet_nameredraw_max_side/v2_defaults male 无意义忽略
use_mask/prompt male 路径生效同接口2 male
generate_grow_image默认 True是否生成生发图最耗时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 取首个选中发型三档各自的发际线中点
@@ -254,10 +498,27 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
# 三档贴图表(同性别、同 key 顺序,因三个文件夹同名)
tex_by_level = {lv: get_texture_map(lv)[gender] for lv in _TEXTURE_DIRS}
# use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用
shared_grown = None
if not use_mask:
shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt)
# 生发图(同接口2,按性别分流):一次性算出所有选中发型的生发图,按 order 对应回叠图。
# female→generate_grow_results_swapswapHair+Flux-2 整帧重绘);
# male→generate_grow_resultsComfyUI add_hair inpaint)。
# generate_grow_image=False 时跳过,grown_by_order 为空 dict(各发型 grown_png 恒 None)。
grown_by_order: dict[int, bytes | None] = {}
if generate_grow_image:
try:
if gender == "female":
items = generate_grow_results_swap(
image_bgr, hair_styles, v2_defaults or {},
redraw_max_side=redraw_max_side, unet_name=unet_name)
else:
items = generate_grow_results(
image_bgr, gender, use_mask, prompt, hair_styles,
unet_name=unet_name)
if items is None:
return None # 无人脸(同接口2 的 None 语义)
for it in items:
grown_by_order[it["order"]] = it.get("grown_png")
except Exception as e: # noqa: BLE001 整批生发失败不拖垮叠图主结果
logger.warning("接口5 生发批量失败(gender=%s):%s", gender, e)
def _center_of(overlay):
"""从某档发际线透明叠图取面部中轴处的发际线中点 (x,y),无像素返回 None。"""
@@ -270,14 +531,13 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
images, best_centers = [], None
for s in hair_styles: # s = 1-indexed 发型序号
key, mid_path = tex_by_level["middle"][s - 1]
key, _mid_path = tex_by_level["middle"][s - 1]
overlays = {}
for lv in _TEXTURE_DIRS:
white = load_texture_rgba(tex_by_level[lv][s - 1][1])
overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
# 生发:固定 middle 黑模板
grown_png = shared_grown if not use_mask else \
_grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt)
# 生发图:从按性别算好的结果里按 order 取(generate_grow_image=False 时缺省 None
grown_png = grown_by_order.get(s)
images.append({"hairline_type": key, "order": s,
"overlays": overlays, "grown_png": grown_png})
# best_centers:首个选中发型三档(middle/high/low)发际线中点
@@ -286,18 +546,73 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
return {"images": images, "best_centers": best_centers}
# 接口3 送 ComfyUI 前限边,降低峰值显存,避免与接口2 切换时把 Flux 挤出。
# 统一 prompt 后 Flux 不再被 CLIP 挤出,接口3 可用较高分辨率。可用 GROW_B_MAX_SIDE 覆盖。
_GROW_B_MAX_SIDE = int(os.getenv("GROW_B_MAX_SIDE", "1024"))
def _downscale_max_side(img_bgr: np.ndarray, max_side: int) -> tuple[np.ndarray, float]:
"""长边超过 max_side 时等比例缩小;返回 (图, scale)scale=新/旧。"""
h, w = img_bgr.shape[:2]
m = max(h, w)
if max_side <= 0 or m <= max_side:
return img_bgr, 1.0
scale = max_side / float(m)
nw = max(1, int(round(w * scale)))
nh = max(1, int(round(h * scale)))
out = cv2.resize(img_bgr, (nw, nh), interpolation=cv2.INTER_AREA)
return out, scale
def _upscale_png_to(png_bytes: bytes, out_w: int, out_h: int) -> bytes:
"""把 Comfy 输出 PNG 双线性拉回原图尺寸(仅展示对齐,不增加推理细节)。"""
arr = np.frombuffer(png_bytes, np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
if img is None:
return png_bytes
if img.shape[1] == out_w and img.shape[0] == out_h:
return png_bytes
resized = cv2.resize(img, (out_w, out_h), interpolation=cv2.INTER_LINEAR)
ok, buf = cv2.imencode(".png", resized)
return buf.tobytes() if ok else png_bytes
def _prep_comfy_input(img_bgr: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray, float]:
"""单段 ComfyUI 生发(接口2男 / 接口3)送图前限边到 GROW_B_MAX_SIDE。
返回 (缩后图, 缩后遮罩, scale)scale<1 时调用方需把结果放大回原尺寸"""
h, w = img_bgr.shape[:2]
if _GROW_B_MAX_SIDE <= 0 or max(h, w) <= _GROW_B_MAX_SIDE:
return img_bgr, mask, 1.0
out, scale = _downscale_max_side(img_bgr, _GROW_B_MAX_SIDE)
nh, nw = out.shape[:2]
msk = cv2.resize(mask, (nw, nh), interpolation=cv2.INTER_NEAREST)
logger.info("接口2男/接口3 缩图送 Comfy: %dx%d%dx%d (max_side=%d)",
w, h, nw, nh, _GROW_B_MAX_SIDE)
return out, msk, scale
def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str = None):
"""接口3:检测医生手绘发际线 → 遮罩 → 送 ComfyUI 生发(仅需划线图一张)。
检测路径只用来**建遮罩**ComfyUI 输入图用 **marked 原图**含医生手绘线
工作流提示词会清除黑线再生发
Comfy 前若长边 > GROW_B_MAX_SIDE默认 896会先等比例缩小降低峰值显存
输出再拉回原图尺寸
use_mask默认 True是否启用自动检测的遮罩用于测试对比
- True检测手绘线 建遮罩 alpha=255mask透明区=重绘区节点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)
@@ -315,8 +630,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"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

@@ -0,0 +1,75 @@
=== 接口2 female 批量对比测试 ===
矩阵: 4图 × 5发型 × 2档 = 40 次
已跳过 0 个已完成项
[1/40] ▶ girl2_ellipse_default896 (side=None)... ✅ 10.8s 出图 1082x1081 (204589B)
[2/40] ▶ girl2_ellipse_origin0 (side=0)... ✅ 13.3s 出图 1088x1088 (232252B)
[3/40] ▶ girl2_flower_default896 (side=None)... ✅ 11.0s 出图 1082x1081 (210875B)
[4/40] ▶ girl2_flower_origin0 (side=0)... ✅ 12.5s 出图 1088x1088 (236073B)
[5/40] ▶ girl2_heart_default896 (side=None)... ✅ 11.9s 出图 1082x1081 (198911B)
[6/40] ▶ girl2_heart_origin0 (side=0)... ✅ 12.2s 出图 1088x1088 (249893B)
[7/40] ▶ girl2_straight_default896 (side=None)... ✅ 12.1s 出图 1082x1081 (211803B)
[8/40] ▶ girl2_straight_origin0 (side=0)... ✅ 12.3s 出图 1088x1088 (235377B)
[9/40] ▶ girl2_wave_default896 (side=None)... ✅ 12.7s 出图 1082x1081 (207736B)
[10/40] ▶ girl2_wave_origin0 (side=0)... ✅ 11.9s 出图 1088x1088 (238081B)
[11/40] ▶ girl5_ellipse_default896 (side=None)... ✅ 9.6s 出图 768x752 (105867B)
[12/40] ▶ girl5_ellipse_origin0 (side=0)... ✅ 7.3s 出图 768x752 (112963B)
[13/40] ▶ girl5_flower_default896 (side=None)... ✅ 8.0s 出图 768x752 (114378B)
[14/40] ▶ girl5_flower_origin0 (side=0)... ✅ 7.2s 出图 768x752 (108495B)
[15/40] ▶ girl5_heart_default896 (side=None)... ✅ 8.1s 出图 768x752 (113874B)
[16/40] ▶ girl5_heart_origin0 (side=0)... ✅ 7.6s 出图 768x752 (103160B)
[17/40] ▶ girl5_straight_default896 (side=None)... ✅ 8.4s 出图 768x752 (104492B)
[18/40] ▶ girl5_straight_origin0 (side=0)... ✅ 7.2s 出图 768x752 (100941B)
[19/40] ▶ girl5_wave_default896 (side=None)... ✅ 8.4s 出图 768x752 (120122B)
[20/40] ▶ girl5_wave_origin0 (side=0)... ✅ 7.7s 出图 768x752 (113282B)
[21/40] ▶ qwer_ellipse_default896 (side=None)... ✅ 10.2s 出图 1288x1678 (261812B)
[22/40] ▶ qwer_ellipse_origin0 (side=0)... ✅ 30.0s 出图 1280x1680 (331013B)
[23/40] ▶ qwer_flower_default896 (side=None)... ✅ 10.2s 出图 1288x1678 (312834B)
[24/40] ▶ qwer_flower_origin0 (side=0)... ✅ 25.0s 出图 1280x1680 (328605B)
[25/40] ▶ qwer_heart_default896 (side=None)... ✅ 11.1s 出图 1288x1678 (331337B)
[26/40] ▶ qwer_heart_origin0 (side=0)... ✅ 25.0s 出图 1280x1680 (355982B)
[27/40] ▶ qwer_straight_default896 (side=None)... ✅ 10.7s 出图 1288x1678 (281665B)
[28/40] ▶ qwer_straight_origin0 (side=0)... ✅ 23.4s 出图 1280x1680 (322183B)
[29/40] ▶ qwer_wave_default896 (side=None)... ✅ 11.1s 出图 1288x1678 (289322B)
[30/40] ▶ qwer_wave_origin0 (side=0)... ✅ 25.8s 出图 1280x1680 (351719B)
[31/40] ▶ asdf_ellipse_default896 (side=None)... ✅ 11.2s 出图 1254x1666 (285362B)
[32/40] ▶ asdf_ellipse_origin0 (side=0)... ✅ 25.7s 出图 1248x1664 (286768B)
[33/40] ▶ asdf_flower_default896 (side=None)... ✅ 10.7s 出图 1254x1666 (266946B)
[34/40] ▶ asdf_flower_origin0 (side=0)... ✅ 22.9s 出图 1248x1664 (353668B)
[35/40] ▶ asdf_heart_default896 (side=None)... ✅ 10.4s 出图 1254x1666 (271975B)
[36/40] ▶ asdf_heart_origin0 (side=0)... ✅ 22.9s 出图 1248x1664 (291983B)
[37/40] ▶ asdf_straight_default896 (side=None)... ✅ 9.8s 出图 1254x1666 (258665B)
[38/40] ▶ asdf_straight_origin0 (side=0)... ✅ 24.0s 出图 1248x1664 (279883B)
[39/40] ▶ asdf_wave_default896 (side=None)... ✅ 11.9s 出图 1254x1666 (286515B)
[40/40] ▶ asdf_wave_origin0 (side=0)... ✅ 24.3s 出图 1248x1664 (295694B)
======================================================================
汇总报告
======================================================================
--- 速度对比(秒,✓=成功 ✗=失败)---
图 发型 默认896 原图0 差值
girl2 ellipse 10.8✓ 13.3✓ +2.5
girl2 flower 11.0✓ 12.5✓ +1.5
girl2 heart 11.9✓ 12.2✓ +0.3
girl2 straight 12.1✓ 12.3✓ +0.2
girl2 wave 12.7✓ 11.9✓ -0.8
girl5 ellipse 9.6✓ 7.3✓ -2.3
girl5 flower 8.0✓ 7.2✓ -0.8
girl5 heart 8.1✓ 7.6✓ -0.5
girl5 straight 8.4✓ 7.2✓ -1.2
girl5 wave 8.4✓ 7.7✓ -0.7
qwer ellipse 10.2✓ 30.0✓ +19.8
qwer flower 10.2✓ 25.0✓ +14.8
qwer heart 11.1✓ 25.0✓ +13.9
qwer straight 10.7✓ 23.4✓ +12.7
qwer wave 11.1✓ 25.8✓ +14.7
asdf ellipse 11.2✓ 25.7✓ +14.5
asdf flower 10.7✓ 22.9✓ +12.2
asdf heart 10.4✓ 22.9✓ +12.5
asdf straight 9.8✓ 24.0✓ +14.2
asdf wave 11.9✓ 24.3✓ +12.4
结果图: /home/ubuntu/hair/image/compare_test/out/
CSV报告: /home/ubuntu/hair/image/compare_test/out/report.csv
JSON明细: /home/ubuntu/hair/image/compare_test/out/report.json
@@ -0,0 +1,41 @@
img,style,side,ok,elapsed_s,out_w,out_h,err
girl2,ellipse,default896,True,10.8,1082,1081,
girl2,ellipse,origin0,True,13.3,1088,1088,
girl2,flower,default896,True,11.0,1082,1081,
girl2,flower,origin0,True,12.5,1088,1088,
girl2,heart,default896,True,11.9,1082,1081,
girl2,heart,origin0,True,12.2,1088,1088,
girl2,straight,default896,True,12.1,1082,1081,
girl2,straight,origin0,True,12.3,1088,1088,
girl2,wave,default896,True,12.7,1082,1081,
girl2,wave,origin0,True,11.9,1088,1088,
girl5,ellipse,default896,True,9.6,768,752,
girl5,ellipse,origin0,True,7.3,768,752,
girl5,flower,default896,True,8.0,768,752,
girl5,flower,origin0,True,7.2,768,752,
girl5,heart,default896,True,8.1,768,752,
girl5,heart,origin0,True,7.6,768,752,
girl5,straight,default896,True,8.4,768,752,
girl5,straight,origin0,True,7.2,768,752,
girl5,wave,default896,True,8.4,768,752,
girl5,wave,origin0,True,7.7,768,752,
qwer,ellipse,default896,True,10.2,1288,1678,
qwer,ellipse,origin0,True,30.0,1280,1680,
qwer,flower,default896,True,10.2,1288,1678,
qwer,flower,origin0,True,25.0,1280,1680,
qwer,heart,default896,True,11.1,1288,1678,
qwer,heart,origin0,True,25.0,1280,1680,
qwer,straight,default896,True,10.7,1288,1678,
qwer,straight,origin0,True,23.4,1280,1680,
qwer,wave,default896,True,11.1,1288,1678,
qwer,wave,origin0,True,25.8,1280,1680,
asdf,ellipse,default896,True,11.2,1254,1666,
asdf,ellipse,origin0,True,25.7,1248,1664,
asdf,flower,default896,True,10.7,1254,1666,
asdf,flower,origin0,True,22.9,1248,1664,
asdf,heart,default896,True,10.4,1254,1666,
asdf,heart,origin0,True,22.9,1248,1664,
asdf,straight,default896,True,9.8,1254,1666,
asdf,straight,origin0,True,24.0,1248,1664,
asdf,wave,default896,True,11.9,1254,1666,
asdf,wave,origin0,True,24.3,1248,1664,
1 img style side ok elapsed_s out_w out_h err
2 girl2 ellipse default896 True 10.8 1082 1081
3 girl2 ellipse origin0 True 13.3 1088 1088
4 girl2 flower default896 True 11.0 1082 1081
5 girl2 flower origin0 True 12.5 1088 1088
6 girl2 heart default896 True 11.9 1082 1081
7 girl2 heart origin0 True 12.2 1088 1088
8 girl2 straight default896 True 12.1 1082 1081
9 girl2 straight origin0 True 12.3 1088 1088
10 girl2 wave default896 True 12.7 1082 1081
11 girl2 wave origin0 True 11.9 1088 1088
12 girl5 ellipse default896 True 9.6 768 752
13 girl5 ellipse origin0 True 7.3 768 752
14 girl5 flower default896 True 8.0 768 752
15 girl5 flower origin0 True 7.2 768 752
16 girl5 heart default896 True 8.1 768 752
17 girl5 heart origin0 True 7.6 768 752
18 girl5 straight default896 True 8.4 768 752
19 girl5 straight origin0 True 7.2 768 752
20 girl5 wave default896 True 8.4 768 752
21 girl5 wave origin0 True 7.7 768 752
22 qwer ellipse default896 True 10.2 1288 1678
23 qwer ellipse origin0 True 30.0 1280 1680
24 qwer flower default896 True 10.2 1288 1678
25 qwer flower origin0 True 25.0 1280 1680
26 qwer heart default896 True 11.1 1288 1678
27 qwer heart origin0 True 25.0 1280 1680
28 qwer straight default896 True 10.7 1288 1678
29 qwer straight origin0 True 23.4 1280 1680
30 qwer wave default896 True 11.1 1288 1678
31 qwer wave origin0 True 25.8 1280 1680
32 asdf ellipse default896 True 11.2 1254 1666
33 asdf ellipse origin0 True 25.7 1248 1664
34 asdf flower default896 True 10.7 1254 1666
35 asdf flower origin0 True 22.9 1248 1664
36 asdf heart default896 True 10.4 1254 1666
37 asdf heart origin0 True 22.9 1248 1664
38 asdf straight default896 True 9.8 1254 1666
39 asdf straight origin0 True 24.0 1248 1664
40 asdf wave default896 True 11.9 1254 1666
41 asdf wave origin0 True 24.3 1248 1664
File diff suppressed because one or more lines are too long
@@ -0,0 +1,562 @@
[
{
"key": "girl2_ellipse_default896",
"img": "girl2",
"style": "ellipse",
"style_idx": 1,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 10.8,
"err": "",
"out_w": 1082,
"out_h": 1081,
"bytes": 204589
},
{
"key": "girl2_ellipse_origin0",
"img": "girl2",
"style": "ellipse",
"style_idx": 1,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 13.3,
"err": "",
"out_w": 1088,
"out_h": 1088,
"bytes": 232252
},
{
"key": "girl2_flower_default896",
"img": "girl2",
"style": "flower",
"style_idx": 2,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 11.0,
"err": "",
"out_w": 1082,
"out_h": 1081,
"bytes": 210875
},
{
"key": "girl2_flower_origin0",
"img": "girl2",
"style": "flower",
"style_idx": 2,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 12.5,
"err": "",
"out_w": 1088,
"out_h": 1088,
"bytes": 236073
},
{
"key": "girl2_heart_default896",
"img": "girl2",
"style": "heart",
"style_idx": 3,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 11.9,
"err": "",
"out_w": 1082,
"out_h": 1081,
"bytes": 198911
},
{
"key": "girl2_heart_origin0",
"img": "girl2",
"style": "heart",
"style_idx": 3,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 12.2,
"err": "",
"out_w": 1088,
"out_h": 1088,
"bytes": 249893
},
{
"key": "girl2_straight_default896",
"img": "girl2",
"style": "straight",
"style_idx": 4,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 12.1,
"err": "",
"out_w": 1082,
"out_h": 1081,
"bytes": 211803
},
{
"key": "girl2_straight_origin0",
"img": "girl2",
"style": "straight",
"style_idx": 4,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 12.3,
"err": "",
"out_w": 1088,
"out_h": 1088,
"bytes": 235377
},
{
"key": "girl2_wave_default896",
"img": "girl2",
"style": "wave",
"style_idx": 5,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 12.7,
"err": "",
"out_w": 1082,
"out_h": 1081,
"bytes": 207736
},
{
"key": "girl2_wave_origin0",
"img": "girl2",
"style": "wave",
"style_idx": 5,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 11.9,
"err": "",
"out_w": 1088,
"out_h": 1088,
"bytes": 238081
},
{
"key": "girl5_ellipse_default896",
"img": "girl5",
"style": "ellipse",
"style_idx": 1,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 9.6,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 105867
},
{
"key": "girl5_ellipse_origin0",
"img": "girl5",
"style": "ellipse",
"style_idx": 1,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 7.3,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 112963
},
{
"key": "girl5_flower_default896",
"img": "girl5",
"style": "flower",
"style_idx": 2,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 8.0,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 114378
},
{
"key": "girl5_flower_origin0",
"img": "girl5",
"style": "flower",
"style_idx": 2,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 7.2,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 108495
},
{
"key": "girl5_heart_default896",
"img": "girl5",
"style": "heart",
"style_idx": 3,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 8.1,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 113874
},
{
"key": "girl5_heart_origin0",
"img": "girl5",
"style": "heart",
"style_idx": 3,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 7.6,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 103160
},
{
"key": "girl5_straight_default896",
"img": "girl5",
"style": "straight",
"style_idx": 4,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 8.4,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 104492
},
{
"key": "girl5_straight_origin0",
"img": "girl5",
"style": "straight",
"style_idx": 4,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 7.2,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 100941
},
{
"key": "girl5_wave_default896",
"img": "girl5",
"style": "wave",
"style_idx": 5,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 8.4,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 120122
},
{
"key": "girl5_wave_origin0",
"img": "girl5",
"style": "wave",
"style_idx": 5,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 7.7,
"err": "",
"out_w": 768,
"out_h": 752,
"bytes": 113282
},
{
"key": "qwer_ellipse_default896",
"img": "qwer",
"style": "ellipse",
"style_idx": 1,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 10.2,
"err": "",
"out_w": 1288,
"out_h": 1678,
"bytes": 261812
},
{
"key": "qwer_ellipse_origin0",
"img": "qwer",
"style": "ellipse",
"style_idx": 1,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 30.0,
"err": "",
"out_w": 1280,
"out_h": 1680,
"bytes": 331013
},
{
"key": "qwer_flower_default896",
"img": "qwer",
"style": "flower",
"style_idx": 2,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 10.2,
"err": "",
"out_w": 1288,
"out_h": 1678,
"bytes": 312834
},
{
"key": "qwer_flower_origin0",
"img": "qwer",
"style": "flower",
"style_idx": 2,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 25.0,
"err": "",
"out_w": 1280,
"out_h": 1680,
"bytes": 328605
},
{
"key": "qwer_heart_default896",
"img": "qwer",
"style": "heart",
"style_idx": 3,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 11.1,
"err": "",
"out_w": 1288,
"out_h": 1678,
"bytes": 331337
},
{
"key": "qwer_heart_origin0",
"img": "qwer",
"style": "heart",
"style_idx": 3,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 25.0,
"err": "",
"out_w": 1280,
"out_h": 1680,
"bytes": 355982
},
{
"key": "qwer_straight_default896",
"img": "qwer",
"style": "straight",
"style_idx": 4,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 10.7,
"err": "",
"out_w": 1288,
"out_h": 1678,
"bytes": 281665
},
{
"key": "qwer_straight_origin0",
"img": "qwer",
"style": "straight",
"style_idx": 4,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 23.4,
"err": "",
"out_w": 1280,
"out_h": 1680,
"bytes": 322183
},
{
"key": "qwer_wave_default896",
"img": "qwer",
"style": "wave",
"style_idx": 5,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 11.1,
"err": "",
"out_w": 1288,
"out_h": 1678,
"bytes": 289322
},
{
"key": "qwer_wave_origin0",
"img": "qwer",
"style": "wave",
"style_idx": 5,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 25.8,
"err": "",
"out_w": 1280,
"out_h": 1680,
"bytes": 351719
},
{
"key": "asdf_ellipse_default896",
"img": "asdf",
"style": "ellipse",
"style_idx": 1,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 11.2,
"err": "",
"out_w": 1254,
"out_h": 1666,
"bytes": 285362
},
{
"key": "asdf_ellipse_origin0",
"img": "asdf",
"style": "ellipse",
"style_idx": 1,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 25.7,
"err": "",
"out_w": 1248,
"out_h": 1664,
"bytes": 286768
},
{
"key": "asdf_flower_default896",
"img": "asdf",
"style": "flower",
"style_idx": 2,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 10.7,
"err": "",
"out_w": 1254,
"out_h": 1666,
"bytes": 266946
},
{
"key": "asdf_flower_origin0",
"img": "asdf",
"style": "flower",
"style_idx": 2,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 22.9,
"err": "",
"out_w": 1248,
"out_h": 1664,
"bytes": 353668
},
{
"key": "asdf_heart_default896",
"img": "asdf",
"style": "heart",
"style_idx": 3,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 10.4,
"err": "",
"out_w": 1254,
"out_h": 1666,
"bytes": 271975
},
{
"key": "asdf_heart_origin0",
"img": "asdf",
"style": "heart",
"style_idx": 3,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 22.9,
"err": "",
"out_w": 1248,
"out_h": 1664,
"bytes": 291983
},
{
"key": "asdf_straight_default896",
"img": "asdf",
"style": "straight",
"style_idx": 4,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 9.8,
"err": "",
"out_w": 1254,
"out_h": 1666,
"bytes": 258665
},
{
"key": "asdf_straight_origin0",
"img": "asdf",
"style": "straight",
"style_idx": 4,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 24.0,
"err": "",
"out_w": 1248,
"out_h": 1664,
"bytes": 279883
},
{
"key": "asdf_wave_default896",
"img": "asdf",
"style": "wave",
"style_idx": 5,
"side": "default896",
"side_val": null,
"ok": true,
"elapsed": 11.9,
"err": "",
"out_w": 1254,
"out_h": 1666,
"bytes": 286515
},
{
"key": "asdf_wave_origin0",
"img": "asdf",
"style": "wave",
"style_idx": 5,
"side": "origin0",
"side_val": 0,
"ok": true,
"elapsed": 24.3,
"err": "",
"out_w": 1248,
"out_h": 1664,
"bytes": 295694
}
]
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""接口2 female 批量对比测试:4图 × 5发型 × 3分辨率档位 = 60次请求。
串行执行记录耗时与成败结果图按 图_发型_档位 命名保存
用法: python3 batch_test.py
支持断点续跑progress.json新增档位时只会补跑未完成项
"""
import base64, csv, json, os, sys, time, traceback
import requests
API = "http://127.0.0.1:8187/api/v1/hair/grow"
TOKEN = "dev-shared-secret-2026"
TIMEOUT = 600
OUT = "/home/ubuntu/hair/image/compare_test/out"
PROGRESS = "/home/ubuntu/hair/image/compare_test/progress.json"
IMG_DIR = "/home/ubuntu/hair/image"
IMAGES = [
("girl2", f"{IMG_DIR}/girl_img/girl2.jpg"),
("girl5", f"{IMG_DIR}/girl_img/girl5.jpg"),
("qwer", f"{IMG_DIR}/qwer.jpg"),
("asdf", f"{IMG_DIR}/asdf.jpg"),
]
# female: 1=ellipse 2=flower 3=heart 4=straight 5=wave
STYLES = [
(1, "ellipse"), (2, "flower"), (3, "heart"), (4, "straight"), (5, "wave"),
]
# 三档: 默认896 / 显式1024 / 原图直送0
SIDES = [
("default896", None), # 不传 → 后端默认896
("side1024", 1024), # 长边压到 1024
("origin0", 0), # 原图直送
]
SIDE_LABEL = {
"default896": "默认896",
"side1024": "1024",
"origin0": "原图0",
}
def load_progress():
if os.path.exists(PROGRESS):
try:
return json.load(open(PROGRESS))
except Exception:
pass
return {"done": [], "results": []}
def save_progress(prog):
json.dump(prog, open(PROGRESS, "w"), ensure_ascii=False, indent=1)
def run_one(img_name, img_path, style_idx, style_key, side_name, side_val):
"""跑单次请求,返回 dict 结果。"""
key = f"{img_name}_{style_key}_{side_name}"
with open(img_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
data = {
"image_base64": "data:image/jpeg;base64," + img_b64,
"gender": "female",
"hair_style": str(style_idx),
}
if side_val is not None:
data["redraw_max_side"] = str(side_val)
t0 = time.time()
rec = {"key": key, "img": img_name, "style": style_key, "style_idx": style_idx,
"side": side_name, "side_val": side_val, "ok": False,
"elapsed": 0.0, "err": "", "out_w": 0, "out_h": 0}
try:
r = requests.post(API, data=data, headers={"X-Internal-Token": TOKEN}, timeout=TIMEOUT)
rec["elapsed"] = round(time.time() - t0, 1)
d = r.json()
if d.get("code") != 0:
rec["err"] = f"code={d.get('code')} {d.get('message','')}"[:200]
return rec
results = (d.get("data") or {}).get("results") or []
if not results:
rec["err"] = "空结果"
return rec
it = results[0]
grown = it.get("grown_image_base64")
if not grown:
rec["err"] = "无生发图(grown_png=None, 重绘失败)"
return rec
raw = base64.b64decode(grown)
from PIL import Image
import io as _io
im = Image.open(_io.BytesIO(raw))
rec["out_w"], rec["out_h"] = im.size
out_path = f"{OUT}/{key}.jpg"
with open(out_path, "wb") as fo:
fo.write(raw)
rec["ok"] = True
rec["bytes"] = len(raw)
except requests.exceptions.Timeout:
rec["elapsed"] = round(time.time() - t0, 1)
rec["err"] = f"超时(>{TIMEOUT}s)"
except Exception as e:
rec["elapsed"] = round(time.time() - t0, 1)
rec["err"] = f"{type(e).__name__}: {str(e)[:180]}"
return rec
def main():
os.makedirs(OUT, exist_ok=True)
prog = load_progress()
done_keys = set(prog["done"])
total = len(IMAGES) * len(STYLES) * len(SIDES)
print(f"=== 接口2 female 批量对比测试 ===")
print(f"矩阵: {len(IMAGES)}× {len(STYLES)}发型 × {len(SIDES)}档 = {total}")
print(f"已跳过 {len(done_keys)} 个已完成项\n")
idx = 0
for img_name, img_path in IMAGES:
for style_idx, style_key in STYLES:
for side_name, side_val in SIDES:
idx += 1
key = f"{img_name}_{style_key}_{side_name}"
if key in done_keys:
print(f"[{idx}/{total}] ⏭ 跳过已完成 {key}")
continue
print(f"[{idx}/{total}] ▶ {key} (side={side_val})...", end=" ", flush=True)
rec = run_one(img_name, img_path, style_idx, style_key, side_name, side_val)
prog["results"].append(rec)
prog["done"].append(key)
save_progress(prog)
if rec["ok"]:
print(f"{rec['elapsed']}s 出图 {rec['out_w']}x{rec['out_h']} ({rec['bytes']}B)")
else:
print(f"{rec['elapsed']}s {rec['err']}")
time.sleep(2) # 串行间隔,避免队列堆积
# 汇总
print("\n" + "="*70)
print("汇总报告")
print("="*70)
write_report(prog["results"])
print(f"\n结果图: {OUT}/")
print(f"CSV报告: {OUT}/report.csv")
print(f"JSON明细: {OUT}/report.json")
def write_report(results):
# CSV
csv_path = f"{OUT}/report.csv"
with open(csv_path, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["img","style","side","ok","elapsed_s","out_w","out_h","err"])
for r in results:
w.writerow([r["img"],r["style"],r["side"],r["ok"],r["elapsed"],
r["out_w"],r["out_h"],r["err"]])
# JSON
json.dump(results, open(f"{OUT}/report.json","w"), ensure_ascii=False, indent=1)
# 控制台速度对比表
print("\n--- 速度对比(秒,✓=成功 ✗=失败)---")
headers = ["", "发型"] + [SIDE_LABEL[s] for s, _ in SIDES]
print(f"{headers[0]:<8}{headers[1]:<10}" + "".join(f"{h:>12}" for h in headers[2:]))
for img_name, _ in IMAGES:
for _, style_key in STYLES:
cells = []
for side_name, _ in SIDES:
r = next((x for x in results if x["img"]==img_name and x["style"]==style_key and x["side"]==side_name), None)
cells.append(f"{r['elapsed']}{'' if r['ok'] else ''}" if r else "-")
print(f"{img_name:<8}{style_key:<10}" + "".join(f"{c:>12}" for c in cells))
if __name__ == "__main__":
main()
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""把 report.json + 结果图生成为自包含 HTML 报告(支持 896 / 1024 / 原图0 三档)。"""
import json, os, html
OUT = "/home/ubuntu/hair/image/compare_test/out"
REPORT_HTML = os.path.join(OUT, "report.html")
results = json.load(open(os.path.join(OUT, "report.json")))
IMAGES = ["girl2", "girl5", "qwer", "asdf"]
STYLES = ["ellipse", "flower", "heart", "straight", "wave"]
SIDES = [
("default896", "默认 896", "#2563eb", "d"),
("side1024", "1024", "#10b981", "m"),
("origin0", "原图直送 0", "#f59e0b", "o"),
]
IMG_LONGSIDE = {"girl2": 1082, "girl5": 767, "qwer": 1678, "asdf": 1666}
STYLE_CN = {"ellipse": "椭圆", "flower": "花瓣", "heart": "心形", "straight": "直线", "wave": "波浪"}
def get(img, style, side):
for r in results:
if r["img"] == img and r["style"] == style and r["side"] == side:
return r
return None
# 总览统计(>60s 视为冷启动/异常,不进均值)
total = len(results)
ok = sum(1 for r in results if r["ok"])
COLD_S = 60.0
def avg(seq):
seq = [x for x in seq if x is not None]
return sum(seq) / len(seq) if seq else 0
def times(side, img=None):
out = []
for r in results:
if not r["ok"] or r["side"] != side:
continue
if img is not None and r["img"] != img:
continue
if r["elapsed"] >= COLD_S:
continue
out.append(r["elapsed"])
return out
cold_n = sum(1 for r in results if r["ok"] and r["elapsed"] >= COLD_S)
side_avgs = {sid: avg(times(sid)) for sid, *_ in SIDES}
all_times = [t for sid, *_ in SIDES for t in times(sid)]
max_bar = max(all_times + [1])
# 构造速度对比图数据:每图 × 三档
chart_rows = []
for img in IMAGES:
chart_rows.append((img, IMG_LONGSIDE[img], [avg(times(sid, img)) for sid, *_ in SIDES]))
def bar_svg():
bar_h = 22
gap = 12
label_w = 78
chart_w = 760
n_bars = len(SIDES)
rows = len(chart_rows)
h = rows * (bar_h * n_bars + gap) + 40
parts = [f'<svg viewBox="0 0 {label_w + chart_w + 80} {h}" class="chart">']
y = 10
for img, longside, avgs in chart_rows:
base = avgs[0] if avgs and avgs[0] else 1
for i, ((sid, label, color, _), a) in enumerate(zip(SIDES, avgs)):
yi = y + i * bar_h
w = int(a / max_bar * chart_w) if a else 0
warn = "" if (i > 0 and a > base * 1.8) else ""
parts.append(f'<rect x="{label_w}" y="{yi}" width="{w}" height="{bar_h-4}" rx="3" fill="{color}"/>')
parts.append(f'<text x="{label_w + w + 6}" y="{yi + bar_h - 10}" class="barlabel">{a:.1f}s {warn}</text>')
parts.append(f'<text x="{label_w-8}" y="{yi + bar_h - 10}" class="rowlabel" text-anchor="end">{html.escape(label)}</text>')
parts.append(f'<text x="0" y="{y + bar_h - 2}" class="imglabel">{img}<tspan class="imgside">长边{longside}</tspan></text>')
y += bar_h * n_bars + gap
parts.append("</svg>")
return "".join(parts)
def img_cell(r, cls):
if not r:
return f'<td class="{cls} fail">缺失</td>'
fname = r["key"] + ".jpg"
status = "" if r["ok"] else ""
t = f'{r["elapsed"]}s'
dim = f'{r["out_w"]}×{r["out_h"]}'
err = f'<div class="err">{html.escape(r["err"])}</div>' if r["err"] else ""
img_tag = (f'<img loading="lazy" src="{fname}" onclick="openImg(this.src)" alt="{html.escape(r["key"])}">'
if r["ok"] else '<div class="noimg">无图</div>')
return (f'<td class="{cls}"><div class="thumb">{img_tag}</div>'
f'<div class="meta">{status} {t} · {dim}</div>{err}</td>')
def compare_cards():
out = []
for img in IMAGES:
out.append(f'<div class="card"><div class="card-h">📷 {html.escape(img)} <span class="tag">原图长边 {IMG_LONGSIDE[img]}px</span></div><div class="card-b">')
heads = "".join(f'<th class="{cls}">{html.escape(label)}</th>' for _, label, _, cls in SIDES)
out.append(f'<table class="cmp"><thead><tr><th>发型</th>{heads}</tr></thead><tbody>')
for style in STYLES:
sc = STYLE_CN[style]
cells = "".join(img_cell(get(img, style, sid), cls) for sid, _, _, cls in SIDES)
out.append(f'<tr><td class="sname">{html.escape(style)}<span>{sc}</span></td>{cells}</tr>')
out.append("</tbody></table></div></div>")
return "".join(out)
# 结论
small = [img for img in IMAGES if IMG_LONGSIDE[img] <= 1100]
large = [img for img in IMAGES if IMG_LONGSIDE[img] > 1100]
def ratio_range(num_side, den_side="default896"):
ratios = []
for img in large:
den = avg(times(den_side, img))
num = avg(times(num_side, img))
if den:
ratios.append(num / den)
if not ratios:
return 0, 0
return min(ratios), max(ratios)
r1024_lo, r1024_hi = ratio_range("side1024")
r0_lo, r0_hi = ratio_range("origin0")
cold_note = f"冷启动 {cold_n} 次(≥{COLD_S:.0f}s)已从均值剔除。" if cold_n else ""
conclusion = (
f"原图长边 ≤ 1100{'/'.join(small)})时三档耗时接近;"
f"长边 > 1600{'/'.join(large)})时相对默认896"
f"<b>1024 约 {r1024_lo:.1f}~{r1024_hi:.1f}×</b>"
f"<b>原图直送约 {r0_lo:.1f}~{r0_hi:.1f}×</b>。"
f"{cold_note}画质对比见下方三列并排,点击可放大。"
)
avg896 = side_avgs.get("default896", 0)
avg1024 = side_avgs.get("side1024", 0)
avg0 = side_avgs.get("origin0", 0)
ratio1024 = avg1024 / avg896 if avg896 else 0
ratio0 = avg0 / avg896 if avg896 else 0
legend = "".join(
f'<span><i style="background:{color}"></i>{html.escape(label)}</span>'
for _, label, color, _ in SIDES
)
html_doc = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口2 分辨率对比测试报告</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f3f4f6; color: #1f2937; line-height: 1.5; padding: 20px; }}
.wrap {{ max-width: 1400px; margin: 0 auto; }}
h1 {{ font-size: 24px; margin-bottom: 4px; }}
.sub {{ color: #6b7280; font-size: 13px; margin-bottom: 20px; }}
.summary {{ display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 24px; }}
.stat {{ background: #fff; border-radius: 10px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }}
.stat .num {{ font-size: 26px; font-weight: 700; }}
.stat .lbl {{ font-size: 12px; color: #6b7280; margin-top: 2px; }}
.stat.d .num {{ color: #2563eb; }}
.stat.m .num {{ color: #10b981; }}
.stat.o .num {{ color: #f59e0b; }}
.card {{ background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.07); margin-bottom: 20px; overflow: hidden; }}
.card-h {{ padding: 12px 18px; background: #fafafa; border-bottom: 1px solid #f0f0f0; font-weight: 700; font-size: 15px; display: flex; align-items: center; gap: 8px; }}
.card-h .tag {{ font-size: 11px; font-weight: 500; color: #6b7280; background: #f3f4f6; padding: 2px 8px; border-radius: 10px; }}
.card-b {{ padding: 16px; }}
.chart {{ width: 100%; height: auto; max-width: 920px; }}
.barlabel {{ font-size: 12px; fill: #374151; font-weight: 600; }}
.rowlabel {{ font-size: 11px; fill: #6b7280; }}
.imglabel {{ font-size: 14px; fill: #111; font-weight: 700; }}
.imgside {{ font-size: 10px; fill: #9ca3af; font-weight: 400; }}
table.cmp {{ width: 100%; border-collapse: collapse; table-layout: fixed; }}
table.cmp th {{ font-size: 12px; color: #6b7280; font-weight: 600; padding: 8px; text-align: center; border-bottom: 2px solid #f0f0f0; }}
table.cmp td {{ padding: 8px; border-bottom: 1px solid #f6f6f6; vertical-align: top; text-align: center; }}
table.cmp th.d, table.cmp td.d {{ background: #eff6ff; }}
table.cmp th.m, table.cmp td.m {{ background: #ecfdf5; }}
table.cmp th.o, table.cmp td.o {{ background: #fffbeb; }}
td.sname {{ font-weight: 600; text-align: left; width: 90px; }}
td.sname span {{ display: block; font-size: 11px; color: #9ca3af; font-weight: 400; }}
.thumb {{ background: #222; border-radius: 6px; overflow: hidden; margin-bottom: 4px; cursor: zoom-in; }}
.thumb img {{ width: 100%; height: 200px; object-fit: contain; display: block; }}
.noimg {{ color: #d1d5db; font-size: 12px; padding: 40px 0; }}
.meta {{ font-size: 11px; color: #6b7280; }}
.err {{ font-size: 10px; color: #dc2626; margin-top: 2px; }}
.note {{ background: #fef3c7; border-left: 3px solid #f59e0b; padding: 12px 16px; border-radius: 6px; font-size: 13px; margin-bottom: 20px; }}
.legend {{ display: flex; gap: 20px; font-size: 12px; color: #6b7280; margin-bottom: 12px; flex-wrap: wrap; }}
.legend span {{ display: inline-flex; align-items: center; gap: 5px; }}
.legend i {{ width: 12px; height: 12px; border-radius: 2px; display: inline-block; }}
.overlay {{ display: none; position: fixed; inset: 0; background: rgba(0,0,0,.9); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; padding: 30px; }}
.overlay.active {{ display: flex; }}
.overlay img {{ max-width: 95%; max-height: 95%; object-fit: contain; border-radius: 4px; }}
@media (max-width: 900px) {{
.summary {{ grid-template-columns: repeat(2, 1fr); }}
.thumb img {{ height: 160px; }}
}}
</style>
</head>
<body>
<div class="wrap">
<h1>接口2 生发 · 分辨率对比测试报告</h1>
<p class="sub">POST /api/v1/hair/grow · female · 4 张图 × 5 发型 × 3 档分辨率 = {total} · 串行</p>
<div class="summary">
<div class="stat"><div class="num">{ok}/{total}</div><div class="lbl">成功 / 总数</div></div>
<div class="stat d"><div class="num">{avg896:.1f}s</div><div class="lbl">默认896 平均</div></div>
<div class="stat m"><div class="num">{avg1024:.1f}s</div><div class="lbl">1024 平均 · ×{ratio1024:.2f}</div></div>
<div class="stat o"><div class="num">{avg0:.1f}s</div><div class="lbl">原图直送 平均 · ×{ratio0:.2f}</div></div>
<div class="stat"><div class="num" style="color:{'#dc2626' if ratio0>1.3 else '#16a34a'}">×{ratio0:.2f}</div><div class="lbl">原图/默认 倍率</div></div>
</div>
<div class="note">
<b>结论</b>{conclusion}
</div>
<div class="card">
<div class="card-h">平均耗时对比按图分组单位</div>
<div class="card-b">
<div class="legend">{legend}</div>
{bar_svg()}
</div>
</div>
<h2 style="font-size:18px;margin:8px 0 14px">画质对比默认896 · 1024 · 原图直送</h2>
{compare_cards()}
</div>
<div class="overlay" id="overlay" onclick="this.classList.remove('active')">
<img id="overlayImg" src="">
</div>
<script>
function openImg(src) {{
document.getElementById('overlayImg').src = src;
document.getElementById('overlay').classList.add('active');
}}
</script>
</body>
</html>
"""
with open(REPORT_HTML, "w") as f:
f.write(html_doc)
# 同步首页
with open(os.path.join(OUT, "index.html"), "w") as f:
f.write(html_doc)
print(f"已生成: {REPORT_HTML}")
print(f"图片目录: {OUT}")

Some files were not shown because too many files have changed in this diff Show More