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
xsl 4001df2c34 save code 2026-07-15 01:16:32 +08:00
xsl 9386f84c88 docs: 同步默认提示词「加一点美颜」到文档/接入页/测试页
- docs/接口文档.md:接口2/3/5/7 参数表统一更新默认提示词,接口2/3/7
  补全此前缺失的 prompt 参数行;顺带修正接口7 输出表 image_url 描述
  为透明 PNG(上一轮透明 PNG 改动的遗漏)
- static/integration.html:接口2 描述句 + 接口7 字段表同步透明 PNG 描述
- static/test_interface2/3/7.html:prompt 输入框默认值同步更新
2026-07-13 23:46:40 +08:00
xsl d5397a6d6f chore: 接口2/3/5/7 生发 ComfyUI 默认提示词加「加一点美颜」
将接口2(/api/v1/hair/grow)、接口3(/api/v1/hair/grow-b)、
接口5(/api/v1/hairline/generate)、接口7(/api/v1/hair/grow-v2)的
prompt 参数默认值从「补充遮罩区域的头发」改为「补充遮罩区域的头发,加一点美颜」。
2026-07-13 23:41:42 +08:00
xsl 95c6a2d929 feat: 接口2/5 发际线叠图改为透明 PNG(仅曲线),生发图不变
接口2(/api/v1/hair/grow)的 image_url 和接口5(/api/v1/hairline/generate)
的 image_middle/high/low_url 从「原图+白线合成 JPG」改为「透明底 PNG(仅含
发际线曲线)」,前端需叠加原图显示。grown_image_url 生发图保持不变(ComfyUI
完整人像照片)。

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

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

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

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

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

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

- generate_hairline_grow: 删除 mask_type/blend_method/feather_px/color_match 参数,
  内部固定 mask_type=pushed、blend_method=multiband
- app.py 接口11/12: 删除 mask_type/blend_method/feather_px/color_match Form 参数,
  调用处改关键字传参;grow_v2 只传 image+hairline_id 即默认走最新算法
- 前端两个测试页: 删除遮罩/融合下拉选项及相关联动,固定展示 pushed 步骤
- 文档: 更新为"固定 pushed + multiband,移除其他选项"
2026-07-11 23:31:19 +08:00
xsl 1b9f3fdb6f 终于修改对了,还是opus 2026-07-11 23:14:07 +08:00
xsl 41bb164a52 接口11/12:新增发际线外推遮罩(pushed)模式 + multiband融合修复 + 调试日志 + 对比报告脚本
发际线生发遮罩算法(mask_type=pushed):
- _extract_hairline:提取头发/皮肤交界线(逐列头发下沿),用 baseline 水平 y 线截断(无竖线)
- _pushed_mask:以眉心(151点)为圆心逐点径向外推 push_cm,与 baseline 组闭合区域
- 径向归并锯齿用插值填补,避免遮罩碎裂
- pushed 模式过程可视化(①-f 交界线 / ①-g 外推+遮罩),eroded/closed 不展示无关步骤

multiband 金字塔融合修复(hairline_grow.py):
- mb_levels 按层数膨胀外缘 keep 区,让过渡带随层数变宽(旧硬二值钳回导致 mb_levels 形同虚设)

接口12 grow_v2(固定参数精简版):
- 固定 multiband/mb_levels=5/erode_cm=0.6,仅返回 final_base64
- 支持 mask_type=pushed + hairline_push_cm/hairline_edge

调试支持:
- 调试页 test_interface11_debug.html(前后端日志面板 + 下载日志按钮)
- hairline_grow.log 全链路日志(按 rid 关联),/api/v1/debug/hairline_log 下载接口
- 遮罩计算过程可视化(baseline/upper/头发分割/交界线/外推/最终遮罩)

文档与脚本:
- docs/发际线生发遮罩算法_pushed模式.md 算法说明
- scripts/batch_grow_v2.py 批量调用、gen_report_hairline_v2.py 对比报告生成
2026-07-11 22:23:57 +08:00
Ubuntu de2cecf0d5 save code 2026-07-10 00:38:39 +08:00
xslandClaude Opus 4.8 f8e30ad32e 接口5:改为多选发型 + 每发型返回 middle/high/low 三档叠图(去掉 hairline_level 入参)
- 去掉 hairline_level 入参;middle/high/low 三档都返回
- 入参改为同接口2:gender + hair_style(逗号分隔多选,必填),缺失/越界返回 1007
- 每个选中发型返回 image_{middle,high,low}_base64 三档叠图 + grown_image_base64 生发图,
  按发型分组;order = 发型序号,含 hairline_type
- 生发黑模板仍固定 middle(hairline_texture_black/),每发型 1 张生发图
- best_hairline_center_point 取首个选中发型的 middle 档
- 同步更新测试页(三档并排展示)、integration.html、stub_worker、接口文档/实现说明、test_api

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:19:08 +08:00
xslandClaude 864d7f969a fix: torch.load 添加 weights_only=False 以兼容新版 PyTorch
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 23:44:48 +08:00
xslandClaude Opus 4.8 9675e147a0 接口5:新增 hairline_level 档位(middle/high/low)贴图 + 集成接口2生发能力
- hairline_level: 可选 middle(默认)/high/low,选用不同高度档位发际线贴图;
  新增 hairline_texture_high/low 两套贴图,get_texture_map 改为按档位缓存
- hair_style: 可选逗号分隔序号,对选中发际线类型同步生发(ComfyUI),
  结果合并进 hairline_images[].grown_image_base64(未选中为 null);
  生发黑模板固定取 middle(hairline_texture_black/),与档位无关
- 新增 use_mask/prompt 生发控制参数(同接口2)
- 测试页/接口文档同步更新

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:30:21 +08:00
xslandClaude Opus 4.8 0ddfa83743 接口11:发际线生发(接口9遮罩 + change_hair换发型/区域生发 + 按遮罩羽化贴回)+ 分步可视化测试页
- 端点 POST /api/v1/hairline/grow(app.py,纯新增,不影响接口1-5)
- 编排模块 face_analysis/hairline_grow.py:复用接口9 遮罩 → HTTP 调 change_hair(8801) → 按遮罩贴回
- 双生成后端 gen_backend:swaphair(换发型LoRA) / hairgrow(区域生发inpaint)
- swap_mode:ext_mask(接口9遮罩作换发型遮罩) / as_is;融合 feather/alpha_gradient/seamless
- 参数全在测试页可调;可视化按算法文档4步:最终遮罩→生成全帧→严格贴回→接缝融合
- 附启停脚本 scripts/restart_if11_backends.sh、算法文档、测试图

注:change_hair 侧 swapHair 的 ext_mask/denoising_strength 改造在 change_hair 仓库,向后兼容。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:06:17 +08:00
xslandClaude Opus 4.8 3eb60bddc5 接口10:头部外缘膨胀带遮罩 + 分步可视化
新增 POST /api/v1/head/band(worker + 网关代理)与测试页 test_interface10.html:
- 先和接口9 一样得到内缩后的基准遮罩(含额头的闭合区域外缘朝151内缩 erode_cm、
  底线不动,默认1.2cm),在此基础上取外轮廓线,去掉贴着底部分割线的那一段。
- 把外轮廓线膨胀成带子(总宽 dilate_cm,默认2cm;半径=总宽/2,虹膜标定换算)。
- 裁到分割线以上(不越过底线)。BiSeNet/SegFormer 两套并排对比。
- 两个可调参数 erode_cm + dilate_cm(页面数字框+滑块+localStorage)。
- 复用 head_mask 的构件,无新依赖;纯新增,不改动既有接口。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:32:45 +08:00
xslandClaude Opus 4.8 9774997035 接口9:头发遮罩生成 + 分步可视化
新增 POST /api/v1/head/mask(worker + 网关代理)与测试页 test_interface9.html:
- MediaPipe 关键点连成额头分割线(21,68,104,69,108,151,337,299,333,298,251,
  左端21/右端251 水平延伸到图片边缘),分割线以上为上半区。
- 头发分割 BiSeNet 与 SegFormer 两套并排对比;每列从最顶端头发向下填充到分割线,
  得到含额头的闭合区域(不从发际线割断)。
- 外缘朝中心点151内缩 erode_cm(默认1.2cm,页面可调,虹膜标定换算像素)、底线不动。
- 复用现有 detector/hair_segmenter/SegFormer 单例(只读推理),无新依赖;纯新增,
  不改动既有接口。

顺带修复接口2 遗留测试 test_grow_female_returns_5:hair_style 自 cb1989c 起必填,
补上 hair_style=1,2,3,4,5。全套 42 passed。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:09:29 +08:00
Ubuntu aa981229c0 添加统计请求的功能 2026-07-02 23:28:49 +08:00
Ubuntu 5d94edfc1a save code 2026-06-25 20:25:49 +08:00
xslandClaude Opus 4.8 a177dc2583 去除上传图片的分辨率与文件大小限制
- app.py: 删除全部 MAX_FILE_BYTES(≤1MB→1006) 与 MIN_SHORT_SIDE/MIN_LONG_SIDE
  (分辨率→1002) 校验及对应常量; 同步清理 File 描述、图片要求说明、
  错误码表(移除1002/1006)与过时示例
- gateway/app.py: 删除注释掉的 1006 大小校验块与描述里的 ≤1MB
- run_worker.sh / hair-worker.service: 删除临时放开限制的环境变量
- tests: 移除已过时的 test_oversize_1006 / test_lowres_1002 及 oversize_file fixture

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:21:39 +08:00
xslandClaude Opus 4.8 a9439e4975 接口1: 标注图人头最左/最右竖线改用耳朵分割外缘
- 最左/最右竖线由「头发轮廓」改为同一 BiSeNet 的耳朵类(7/8)外缘,
  看不到耳朵(被头发/侧脸遮挡→掩膜空)则该侧不画线
- 外耳轮廓常被误标成头发: 从耳朵外缘沿紧邻前景(耳∪发)按脸宽自适应
  外扩回收(上限 face_w*0.045, 遇背景间隙即停)
- 先按人脸包围盒裁剪再分割: BiSeNet 在紧裁人脸上训练, 整张全身/街拍图
  脸偏小会严重欠分割、丢耳朵; 裁剪后映射回原图, 耳朵稳定可分
- segment_hair_and_ears() 单次推理同出 hair_mask + ear_mask

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:36:23 +08:00
Ubuntu 5fd7d05d26 save code 2026-06-24 20:49:34 +08:00
Ubuntu e58ece0ada save code 2026-06-23 23:30:24 +08:00
xsl 57d1a876cc save code 2026-06-23 23:26:40 +08:00
xsl 6e8113b3ff save code 2026-06-23 22:46:24 +08:00
xsl 85f1ca521d 修改画线 2026-06-23 22:29:41 +08:00
xslandClaude 8cd44848d6 feat(接口6): 复刻接口1,新增 /api/v1/face/measure-v2
- 抽取 _face_measure_impl() 共用实现,接口1/6 零逻辑差异
- 接口6 路径 POST /api/v1/face/measure-v2
- 入参/出参与接口1 完全一致
- 文档同步更新(接口文档、实现说明、网关待改动)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 20:27:51 +08:00
UbuntuandClaude 76bae98073 feat(接口2/7): hair_style改为多选复选框,新增接口7测试页,网关注册v2路由
- 接口2/7测试页:发型从单选下拉框改为多选复选框+全选/全不选,提交时逗号分隔
- 新建static/test_interface7.html(基于接口2测试页,紫色主题,调用/api/v1/hair/grow-v2)
- gateway/app.py:注册/api/v1/hair/grow-v2代理路由 + 首页索引
- integration.html:新增接口7完整文档卡片(入参/出参/测试页链接)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 23:43:39 +08:00
xslandClaude 9678b54267 feat(接口2/7): hair_style 支持逗号分隔多选,如 1,2,3
- 参数类型从 int 改为 string(逗号分隔),自动解析去重排序
- 越界/非法值返回 1007
- _parse_hair_styles() 辅助函数:解析 + 去重 + 范围校验
- service 层 hair_style 参数改为 hair_styles: list[int]
- 接口2 和 接口7 同步更新
- 服务已重启,smoke test 通过

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 23:36:13 +08:00
xsl 1d23414d21 save code 2026-06-22 23:23:46 +08:00
xslandClaude cb1989c042 feat(接口2/7): 接口2加hair_style参数选单张发型;新增接口7用add_hair2工作流
接口2 变更:
- 新增必填 hair_style(int) 参数,按序号只生成一张(不再全量)
- female:1-5 male:1-4,越界返回1007

接口7 新增:
- POST /api/v1/hair/grow-v2,功能与接口2一致
- 使用 add_hair2.json 工作流(Flux-2 Klein 9b)
- SaveImage输出节点自动检测(75)

comfyui.py 重构:
- run() 支持 workflow_path 参数,多工作流按路径缓存
- SaveImage 输出节点自动检测,不再硬编码
- 输入/种子/提示词节点ID两个工作流相同(26/6/60)

文档:
- 接口文档、实现说明、网关待改动 三份同步更新
- 网关只需加一行路由,base64→URL改写无需改动

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 23:22:25 +08:00
xslandClaude Sonnet 4.6 28a6062fea feat(接口4): 精简输出为固定6个英文字段,缩短提示词
- face_features.py:_PROMPT 只问6项特征(+有无人脸),analyze_features
  只返回6个英文字段;无人脸返回 None(不再依赖 has_face 在外部判定)
- gateway/app.py:无脸判定改为 feats is None,去掉 has_face import
- app.py / docs / test_interface4.html:Swagger/文档/示例/测试页同步

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:19:00 +08:00
xsl be81567261 save code 2026-06-17 23:36:01 +08:00
Ubuntu 24da4eae6f save code 2026-06-17 23:27:34 +08:00
Ubuntu c15a3c2e15 save code 2026-06-17 23:04:49 +08:00
xslandClaude Opus 4.8 5311c8d7c7 feat(接口2/3): 加 use_mask 开关用于遮罩对比;网关回退纯透传
- 接口3 generate_grow_b 增加 use_mask(默认 true):false 跳过检测、
  直接送划线图(空遮罩)
- 接口2 generate_grow_results 增加 use_mask:false 用干净原图+空遮罩,
  只跑一次 ComfyUI、N 项复用
- app.py 接口2/3 增加 use_mask form 参数并透传
- gateway/app.py 回退为纯透传(移除 OpenAPI schema 注入,请求体原样转发)
- docs 补 use_mask 说明

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:01:12 +08:00
xsl 52f1bb4c0b 修改接口3 加入是否用遮罩接口 2026-06-17 22:27:06 +08:00
xsl 01397c414e save code 2026-06-16 21:59:13 +08:00
xsl 8d68ee323a save code 2026-06-16 21:16:44 +08:00
Ubuntu d2642b66f8 save code 2026-06-16 21:15:19 +08:00
xslandClaude Opus 4.8 a86125246e docs: 网关侧改动清单(JPG落盘扩展名嗅探等)
汇总 worker 近期变更里与网关有关的点供网关侧应用:
- [功能必需] base64→url 落盘按内容嗅探扩展名(PNG/JPG),因接口2/3/5 改 JPG(已在 forward.py 改)
- [建议] 生发接口超时≥120s
- [确认] 递归改写覆盖数组内字段/可空 null
- [可选] OpenAPI 表单 gender/grow-b 声明
- 已完成项:接口4 网关实现/200状态/接口3去original

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:44:41 +08:00
xslandClaude Opus 4.8 4c7681b338 perf(图片): 接口2/3/5 返回 JPG(体积~9×↓),接口1 标注图仍 PNG(透明)
- app.py: 接口2(预览+生发)/3(生发)/5(发际线叠图) 编码改 JPG(质量90,env JPG_QUALITY);
  接口1 annotated_image 含透明仍 PNG。_png_to_jpg_b64 把 ComfyUI 的 PNG 重编码为 JPG(无法解码则透传)
- gateway/forward.py: 落盘按内容嗅探扩展名(PNG头→.png 否则.jpg),原先硬编码 .png
- 测试/文档同步;实测接口5 一张 59KB(JPG) vs 548KB(PNG)。pytest 44 全绿

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:35:24 +08:00
xslandClaude Opus 4.8 e3c67fc8cf feat(comfyui): ComfyUI 改 8188 + HTTP Basic Auth
本机 ComfyUI 开了 Basic Auth(user admin),端口 8182→8188:
- hairline/comfyui.py: 默认 URL 8188;所有 httpx 请求带 auth=(user,password)
  密码来源 环境变量 COMFYUI_PASSWORD → worker_config.json.comfyui_password → password.txt
- password.txt 入 .gitignore(含密码不入 git);worker_config.example 加 comfyui_password
- 实现说明文档同步(8188 + Basic Auth)
实测:ping + 实跑一张生发图均通过(带鉴权)。pytest 44 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:10:09 +08:00
Ubuntu a3f4c822cb feat: 补接口4测试页,更新索引和接入说明
- static/test_interface4.html: 上传照片 → 英文优先字段 + 全部42项特征表格 + 原始JSON
- gateway/app.py: 首页索引增加 if4_features 测试页
- static/integration.html: 接入说明补充接口4测试页链接
2026-06-15 21:36:40 +08:00
Ubuntu bee4bdae10 feat: 前端接入说明页 + 网关首页索引
- static/integration.html: 5接口入参/出参表、fetch示例、错误码、在线测试页链接
- gateway/app.py: 首页 / 增加 test_pages 和 integration_guide 索引
2026-06-15 21:26:56 +08:00
xslandClaude Opus 4.8 76f7c06905 review+docs: 接口4 回收到网关、对齐错误码、合并文档为一份实现说明
代码 review 后的清理:
- 接口4 由网关本机实现,worker app.py 的 /face/features 回退 Mock(保持 worker 无外网依赖);
  worker requirements 标注 volcengine 改为网关侧;移除 worker 的接口4 测试(随实现挪到网关)
- 网关接口4 业务错误 HTTP 状态统一改 200(与其余接口/worker 约定一致,原为400/503)
- 接口文档:gender 非法码 1004(原误写1008);修正指向已删文档的链接

文档合并:把各接口技术方案/开发任务书/系统架构/网关任务书 合并成 docs/实现说明.md(简要总览),
删除原 7 份分散文档,README 收敛为索引(实现说明/接口文档/需求/OFFLINE_ASSETS)。

pytest 44 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:58:33 +08:00
Ubuntu 68fe1c1406 feat(网关): 接口4 改由网关本机直接调豆包视觉模型,不再代理到 worker
- gateway/app.py: 接口4 handler 改为直接调用 face_features 模块
- face_features.py: API Key 读取支持 gateway/config.json
- gateway/config.example.json: 增加 ark_api_key 配置项
2026-06-15 20:46:12 +08:00
xslandClaude Opus 4.8 043a4c0603 docs: 接口4 网关侧实现方案(供网关开发照做)
接口4 不碰本地GPU/模型,仅调外网豆包→放网关本地实现更合理。文档含:
路由改本地处理(不转发)、httpx调方舟(OpenAI兼容,无需SDK)、prompt原文、
base64 data URI喂图、字段映射(6英文+全部中文)、无人脸→1001、配置(ark密钥入网关config)、
错误码、worker侧回收步骤、自测。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:33:50 +08:00
xslandClaude Opus 4.8 3008552331 docs(网关): 接口4 已实现说明(无图片字段透传+外网豆包调用耗时)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:51:02 +08:00
xslandClaude Opus 4.8 0e71830bb6 feat(接口4): 用户特征-火山方舟豆包视觉模型(替换Mock)
接口4 改用远程视觉大模型(算法来源 /home/xsl/fuyan),一次返回几十项面部特征。

- face_features.py: 火山方舟 Ark 客户端(单例)+doubao-seed-1-6-vision prompt(移植fuyan)
  +base64 data URI喂图(实测doubao接受)+解析JSON+映射6英文优先字段(face_shape等)
  并保留doubao全部中文字段;has_face 据"图片是否有人脸"判定
- app.py: /face/features 真实实现,三选一图(image_url直传doubao,file/base64转dataURI),
  features 为 JSON 字符串;无人脸→1001;重活线程池
- 配置: API Key 走 worker_config.json.ark_api_key / 环境变量 ARK_API_KEY(不入git);
  worker_config.example 加占位; requirements 加 volcengine-python-sdk[ark]
- 文档: 接口文档/OpenAPI 更新为豆包实现+几十项字段+1001
- 测试: mock doubao 的成功/无人脸/多参,47全绿

⚠️ 唯一调外网云模型的接口,worker 需可达 ark.cn-beijing.volces.com(已实测可达)。
实测 frontal: 42字段, 鹅蛋脸/平眉/18-25岁/静态型/女/少年型。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:50:17 +08:00
Ubuntu 795de68985 save code 2026-06-15 10:33:15 +08:00
xslandClaude Opus 4.8 bf76923591 refactor(接口3): 简化为只需划线图一张,去掉 original + best_hairline
按需求方意见——B端只需上传一张已画好发际线的图,用不着原图:
- 入参去掉 original_image_*,只保留 marked_image_*(三选一)
- 输出去掉 best_hairline_image_url,只返回 hair_growth_image_url + hairline_type
- ComfyUI 输入图改用 marked 划线图原样(add_hair.json 本就是"画了线的照片",
  提示词清除黑线);检测路径只用于建遮罩,不再重画干净线/不需对齐原图
- service.generate_grow_b 签名改 (marked_bgr) 单参
- 同步文档:接口文档/接口3技术方案/网关映射表(去掉接口3 best_hairline 行)
- 测试更新:grow-b 只传 marked,断言无 best_hairline 字段,44全绿

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:28:13 +08:00
Ubuntu cd4ca278d4 完成接口1 和接口2,测试页面也完成 2026-06-15 10:10:52 +08:00
xslandClaude Opus 4.8 d4e6a794d8 docs(接口2): 标注生发图已实现,清理"只做预览"过期文案
接口2 的生发后图片(§10 ComfyUI/Flux)已实现,更新早期"本期只做预览/不做生发"的描述:
- 技术方案:标题/§0/§9.6 改为"预览+生发两步均已实现",§9.6 列出后续优化方向
- 接口文档:接口2 说明改为"每方案返回 image_url(预览)+grown_image_url(生发图)"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:47:42 +08:00
xslandClaude Opus 4.8 147fef6ca6 docs(网关): 补全 base64→url 映射表(接口1/2/3/5) + 嵌套数组/超时提醒
接口 1/2/3/5 已真实实现,补齐网关需要的字段映射,供网关开发参考:
- 网关任务书 §6:完整映射表(annotated_image / results[].image / results[].grown_image /
  best_hairline_image / hair_growth_image / hairline_images[].image);推荐"凡 *_base64 递归改写"
  通用实现;可空字段(生发图)保留 null;gender 等入参网关透传无需改造
- 网关任务书 §9:接口4 仍 mock;生发接口(2/3) ComfyUI 同步出图慢,request_timeout 调大≥120s
- 架构 §9:标注两个易漏点(数组内字段需递归、生发图可空)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:29:33 +08:00
xslandClaude Opus 4.8 38161d1b50 feat(接口5): 发际线PNG生成(真实实现,替换Mock)
复用接口2 预览管线:gender 必填 → 该性别全部发际线叠加图(同接口2预览) +
最佳(order=1)发际线曲线的面部中间点坐标。无生发(不调 ComfyUI)。

- hairline/service.py: generate_hairline_pngs——N张发际线叠图 +
  best_center(面部中轴眉心x × order1曲线在该处的y)
- app.py: /hairline/generate 真实实现,新增 gender 必填(非法→1004),
  返回 hairline_images[].image_base64 + best_hairline_center_point;
  无人脸→1001;重活线程池
- 接口文档/OpenAPI: 接口5 新增 gender 入参 + 输出改 base64(网关改url)
- 测试: test_api 接口5(gender必填/N张/center点),44全绿

实测(5090): female 5张叠图 + center{x,y},~2.5s(无Flux)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:20:56 +08:00
xslandClaude Opus 4.8 ce95a508c1 feat(接口3): B端生发-马克笔发际线检测+生发(替换Mock)
医生在额头用马克笔画规划发际线 → 检测该线 → 生发。检测算法源自 /home/xsl/headmark。

- hairline/marker_detect.py: 黑帽响应图(MORPH_BLACKHAT)+鬓角锚点(MediaPipe 21/251吸附)
  +skimage route_through_array 最小路径检测画线;路径平均响应阈值拒识无画线
  (headmark 调研:全局灰度阈值不可用,黑帽+Dijkstra 实测误差≤0.5px)
- hairline/mask.py: 抽出 mask_from_curve(曲线+ROI闭合),接口2/3共用
- hairline/service.py: generate_grow_b——检测→遮罩→原图重画干净线→ComfyUI生发
- app.py: /hair/grow-b 真实实现,marked+original各三选一+校验;输出
  best_hairline_image_base64(=原图)/hair_growth_image_base64/hairline_type="custom";
  无人脸或未检测到画线→1001;重活进线程池
- requirements: scikit-image==0.24.0 (⚠️锁0.24,0.25+强依赖numpy>=2会顶掉mediapipe的numpy<2)
- 文档: docs/接口3-B端生发-技术实现方案.md
- 测试: test_marker.py(检测/拒识/辅助) + test_api grow-b(mock ComfyUI),42全绿

实测(5090): grow-b ~6.4s,生发图把额头发际线补到医生画线、清除划线、人物保持。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:08:05 +08:00
xslandClaude Opus 4.8 94ad95850e feat(接口2): 新增生发后图片(ComfyUI/Flux inpaint)
在发际线预览基础上,每种发际线再出一张「植发3个月」生发图:

- hairline/mask.py: headmark 5步法遮罩(额头上部区域∩SegFormer头部=ROI,
  取发际线曲线以上闭合区域);用 hairline_texture_black 渲染黑线替代手绘检测;
  compose_comfy_rgba 合成 RGBA(alpha=255-mask, 透明=重绘区, 对齐 ComfyUI mask=1-alpha)
- hairline/comfyui.py: ComfyUI 客户端(默认8182),/upload/image+/prompt(改节点26+随机seed)
  +轮询/history+/view 取回生发图
- hairline/render.py: 抽出 build_overlay_layer 供遮罩取曲线像素
- hairline/service.py: extract_context 一次出 landmarks/parse_map/502点;
  generate_grow_results 每种=预览+生发图(同步串行N张,单张ComfyUI失败则grown置空不拖垮整请求)
- app.py: /hair/grow 返回 results[].grown_image_base64;重活放线程池避免卡事件循环
- add_hair.json 工作流 + hairline_texture_black/ 黑贴图入库
- 测试: test_mask.py(遮罩几何) + test_api mock ComfyUI 验 grown 字段,35 全绿

实测(5090): female 5张生发图同步约18s;预览/生发图人物五官服饰背景保持、黑线已清除。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:42:30 +08:00
xslandClaude Opus 4.8 4c69bb4623 docs(接口2): 新增生发图设计(§10 ComfyUI+Flux) + headmark遮罩算法
- 技术方案 §10:生发图生成管线(add_hair.json 工作流解读、遮罩算法、
  ComfyUI 客户端、契约变更、M5~M8 步骤、风险)
- 遮罩算法参考 /home/xsl/headmark 5步法,用 hairline_texture_black 渲染黑线
  替代手绘检测:额头上部区域 ∩ 头部分割 = ROI,取发际线曲线以上闭合区域
- 接口文档:results[] 新增 grown_image_url(生发后图片) + 同步/超时说明

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:32:21 +08:00
xslandClaude Opus 4.8 554b64a916 feat(接口2): C端生发发际线预览(真实实现,替换Mock)
第一步:按性别把发际线类型贴图渲染到照片,输出 N 张发际线叠加预览图。

- hairline/render.py: 解析 face_ext.obj(502 UV + 64 ribbon扩展面) + OpenCV 逐三角
  仿射 warp 渲染器;关键修复——face_ext.obj 是 OBJ序,用 INDEX_MAP_468 把 MP序
  502点重排成 OBJ序后再投影,否则 ribbon 会错贴到中脸
- hairline/service.py: FaceLandmarker+SegFormer 单例 + 性别贴图映射(扫描去空格)
  + generate_previews 管线(female5/male4)
- 集成点修复: face_landmarks DEFAULT_MODEL_PATH 改 hairline/models/;
  constants HF_FACE_PARSER_MODEL 改本地路径(离线)
- app.py: /api/v1/hair/grow 接真实实现,gender 必填(非法→1004),返回
  results[].image_base64(不落盘),校验/鉴权同接口1;lifespan 预热接口2单例;
  补 logging.basicConfig
- 依赖: transformers==4.45.2;SegFormer 权重走 hf-mirror 下载(见 OFFLINE_ASSETS)
- 测试: tests/test_hairline.py(mesh/重排/贴图映射) + test_api 接口2用例,31 全绿

注:SegFormer 受 5090/torch 限制走 CPU(~2.5s/张),换 cu128 可 SEG_DEVICE=cuda。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:31:41 +08:00
xslandClaude Opus 4.8 891bc0da8b feat(annotation): 线名移到右侧 + 线条只覆盖人脸范围
- 5 条横线的线名(头顶/发际线/眉心/鼻翼下缘/下巴尖)从左侧移到右侧
- 四庭 cm(顶庭/上庭/中庭/下庭)留在左侧,右对齐贴脸盒左缘
- 横线只覆盖脸宽(左右脸颊)、竖线只覆盖脸高(头顶→下巴),渐变消失
- 七眼段宽标注移到脸盒上端/下端(配合新线长),相邻段上下错行防重叠

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:58:12 +08:00
xslandClaude Opus 4.8 10488171eb feat(annotation): 标注图改版——横线标名+四庭cm,竖线标七眼段宽(顶/底)
按需求重排标注图:
- 去掉右侧全脸总高标尺,去掉单眼宽度/两眼间距/脸宽 三条横向标注
- 横向 5 条线标注线名(头顶/发际线/眉心/鼻翼下缘/下巴尖) + 保留左侧四庭 cm
- 新增纵向 6 条渐变竖线(左脸颊/左右眼内外角/右脸颊),切脸宽为 5 段
- 每段宽度在顶部和底部各标一次(只标 X.XXcm),相邻段上下错行防重叠
- 新增 draw_gradient_vertical_line 竖向渐变线
- test_api 用模块级 ACCEPT_PASSWORDS 固定测试密码,不依赖 worker_config.json

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:37:09 +08:00
xsl 9391408088 Merge branch 'main' of http://git.xiangsilian.com:3000/xsl/hair 2026-06-14 18:23:25 +08:00
Ubuntu eaafbc97ea save code 2026-06-14 18:23:08 +08:00
xslandClaude Opus 4.8 023bb2e6fd feat(worker): start.sh 改为后台服务开关(start/stop/restart/status)
- ./start.sh 现支持 start/stop/restart/status 子命令,无参默认 start
- 后台运行 + PID 文件(worker.pid) + 日志(worker.log),手动控制开关
- 前台热重载开发仍用 run_worker.sh
- gitignore 补 worker.pid / worker.log

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:47:49 +08:00
xslandClaude Opus 4.8 0eaaf05ac3 fix(annotation): 标注图重排,8 个量全标注且不重叠
- 修复七眼三个横向量(单眼宽度/两眼间距/脸宽)叠在同一条眼睛线上的重叠问题,
  改为上下错开三个高度,各自虚线带箭头 + 紧贴标签
- 新增「全脸总高」标注:右侧竖向标尺(虚线双箭头,头顶→下巴)+ 标签
- 四庭(顶/上/中/下庭)仍在左侧各段中点
- 标签用全名:单眼宽度/两眼间距/脸宽/全脸总高
- 新增 tests/test_annotation.py 防回归

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:33:58 +08:00
xsl 78226ae0b9 chore: SegFormer 模型不入库,改为手动下载
- .gitignore 排除 hairline/models/face-parsing/model.safetensors(~323MB)
- OFFLINE_ASSETS.md 更新:区分已入库文件和需手动下载文件,补充 SegFormer sha256
2026-06-14 17:19:24 +08:00
xsl e827b23c1d Merge branch 'main' of http://git.xiangsilian.com:3000/xsl/hair 2026-06-14 17:08:16 +08:00
xsl db32fa12e5 feat(接口2): 移植head3d发际线管线 + 接口2实现方案文档
- 从head3d复制发际线检测管线到 hairline/ 包:MediaPipe Tasks + SegFormer分割
  + 17锚点射线检测 + 502点mesh(face_ext.obj)+UV
- 复制模型:face_landmarker.task(3.7MB)、SegFormer config/preprocessor
  (model.safetensors 340MB 单独下载中)
- 新增 docs/接口2-C端生发-技术实现方案.md:第一步=发际线曲线叠加预览图,
  新增gender必填参数,按性别贴图数量输出(female5/male4),hairline_type英文key,
  服务端cv2逐三角形warp渲染器(head3d只有浏览器端Three.js渲染)
- 接口文档.md 接口2章节同步:gender参数、输出语义、错误码说明
- hairline_texture/ 9张发际线贴图入库
2026-06-14 16:59:39 +08:00
xsl ad1b95df4e Merge branch 'main' of http://git.xiangsilian.com:3000/xsl/hair 2026-06-14 16:49:39 +08:00
xslandClaude Opus 4.8 8d3b145111 feat(worker): 接口1 四庭七眼测量真实实现(替换 Mock)
worker 侧从 Mock 替换为真实算法:
- face_analysis 包:detector(MediaPipe 478点) / pose(solvePnP 姿态) /
  calibration(虹膜直径法) / hair_segmenter+bisenet_model(方案B 头发分割) /
  measure(方案A兜底+B/A决策+七眼+换算) / annotation(numpy渐变线+中文标注)
- app.py:/api/v1/face/measure 接真实实现,返回 annotated_image_base64
  (不落盘不拼URL,落盘由网关做);加 X-Internal-Token 鉴权、/health 就绪态、
  可配置分辨率门槛、异常兜底
- 部署:start.sh/run_worker.sh/hair-worker.service 监听 8187;worker_config 示例
- 测试 tests/:Tier1合成真值<1e-6 + Tier2缩放不变 + Tier3叠加 + 错误码集成 +
  数值回归,pytest 24 项全绿
- 文档补实测基线表 + RTX5090/torch 说明

注:worker 为 RTX 5090(sm_120),pinned torch 2.2.2(cu121) 只到 sm_90,
BiSeNet 已自动回退 CPU(方案B 正常);要用 GPU 需换 torch cu128(≥2.7)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:07:28 +08:00
xsl 5e513c7006 添加发型图片 2026-06-14 15:31:29 +08:00
Ubuntu 3b706ec0ef 网关开发完成 2026-06-14 15:04:34 +08:00
xsl bf68a32d9f docs: 拆分网关与worker文档,按机器分别开发
- 新增 网关-开发任务书.md: 独立自包含的网关侧任务书(骨架/健康检查派发/
  鉴权转发/base64改URL/静态托管/部署/DoD),在外网机开发
- worker任务书 v1.5: 移除§16网关章节,顶部标注本机职责,DoD只留worker侧,
  指向独立网关任务书
- 新增 docs/README.md: 文档索引,标清worker机/网关机/共享分别读哪些
2026-06-14 14:29:48 +08:00
xsl 2e789d4efa docs: 架构拆分为外网网关+高性能worker(GPU)
- 新增 系统架构-网关与高性能后端.md: 网关薄代理(健康检查/空闲派发/鉴权/
  base64落盘改URL/无后端→1007) + worker(GPU跑完整app)职责划分、配置文件、
  鉴权(共享密码可轮换)、标注图base64流程、部署、安全注意
- 关键约束: 接口文档不变; 每worker并发=1, 总并发=健康worker数; 多worker可配置
- 技术方案: 加运行位置横幅; torch改GPU(CUDA); handler返回annotated_image_base64
- 任务书v1.4: 标注差异说明; 阶段八返base64+鉴权中间件; 阶段十worker(GPU)部署;
  新增§16网关工作流; DoD拆分worker侧/网关侧
- OFFLINE_ASSETS: 区分worker(GPU torch)与网关(轻量)依赖
2026-06-14 13:55:45 +08:00
500 changed files with 27473 additions and 1505 deletions
+70
View File
@@ -4,7 +4,77 @@ __pycache__/
.env
# 网关配置(含密码,不入 git
gateway/config.json
# worker 配置(含鉴权密码,不入 git)
worker_config.json
# ComfyUI Basic Auth 密码(不入 git
password.txt
# worker 运行期文件(PID / 日志)
worker.pid
worker.log
# 请求日志(运行时生成,不入 git)
gateway/request_log.jsonl
gateway/request_log.jsonl.1
# SegFormer 模型权重(~323MB,体积过大,不入 git,见 OFFLINE_ASSETS.md
hairline/models/face-parsing/model.safetensors
# 生成的标注图、测试输出
static/annotations/*
!static/annotations/.gitkeep
tests/output/
# 本地临时遮罩测试页(不入 git)
test_local.py
# 运行期日志 / uvicorn 日志(不入 git
log/
uvicorn.log
uvicorn*.log
# ZCode 工具目录(不入 git
.zcode/
# 临时响应文件(不入 git
_grow*_resp.json
# 测试素材图(体积大,不入 git)
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
+41 -16
View File
@@ -1,19 +1,44 @@
# 离线资产清单(内网部署用)
开发/部署机在内网无法联网,以下模型权重与字体**已预先下载**放在仓库对应目录。
这些文件**不进 git**(见 `.gitignore`),通过文件拷贝(U盘/内网共享)随项目一起带到内网机即可。
## 已入库文件(git clone 后自动到位)
> 拷贝到内网机后,用本文件末尾的 sha256 校验完整性,确认未损坏。
## 文件清单
以下文件体积适中,已直接提交进 git,clone 仓库即可:
| 文件 | 路径 | 大小 | 用途 |
|------|------|------|------|
| BiSeNet 主权重 | `face_analysis/weights/79999_iter.pth` | 53,289,463 B (~53MB) | 人脸解析分割(方案 B,取真实发际线/头顶) |
| resnet18 骨干 | `face_analysis/weights/resnet18-5c106cde.pth` | 46,827,520 B (~45MB) | BiSeNet 骨干网络**内网必需**(见下) |
| 中文字体 | `face_analysis/fonts/NotoSansCJKsc-Regular.otf` | 16,437,364 B (~16MB) | 标注图中文渲染= 思源黑体,同一套字体) |
| BiSeNet 主权重 | `face_analysis/weights/79999_iter.pth` | ~53MB | 人脸解析分割(接口1方案B,取真实发际线/头顶) |
| resnet18 骨干 | `face_analysis/weights/resnet18-5c106cde.pth` | ~45MB | BiSeNet 骨干网络 |
| 中文字体 | `face_analysis/fonts/NotoSansCJKsc-Regular.otf` | ~16MB | 标注图中文渲染 |
| MediaPipe 模型 | `hairline/models/face_landmarker.task` | ~3.7MB | 468点人脸关键点检测(接口2) |
## sha256 校验
## 需手动下载文件(体积过大,不入 git)
### SegFormer 人脸分割模型(接口2必需)
| 文件 | 路径 | 大小 |
|------|------|------|
| model.safetensors | `hairline/models/face-parsing/model.safetensors` | 338,580,732 B (~323MB) |
下载命令(国内用 hf-mirror 镜像,快很多):
```bash
# 国内镜像(推荐)
curl -L -o hairline/models/face-parsing/model.safetensors \
"https://hf-mirror.com/jonathandinu/face-parsing/resolve/main/model.safetensors"
# 官方源
# curl -L -o hairline/models/face-parsing/model.safetensors \
# "https://huggingface.co/jonathandinu/face-parsing/resolve/main/model.safetensors"
```
sha256 校验:
```
c2bec795a8c243db71bd95be538fd62559003566466c71237e45c99b920f4b62 hairline/models/face-parsing/model.safetensors
```
```bash
sha256sum hairline/models/face-parsing/model.safetensors
```
## sha256 校验(已入库文件)
```
468e13ca13a9b43cc0881a9f99083a430e9c0a38abd935431d1c28ee94b26567 face_analysis/weights/79999_iter.pth
@@ -21,19 +46,13 @@
2c76254f6fc379fddfce0a7e84fb5385bb135d3e399294f6eeb6680d0365b74b face_analysis/fonts/NotoSansCJKsc-Regular.otf
```
校验命令:
```bash
# Linux/macOS
sha256sum -c <<'EOF'
468e13ca13a9b43cc0881a9f99083a430e9c0a38abd935431d1c28ee94b26567 face_analysis/weights/79999_iter.pth
5c106cde386e87d4033832f2996f5493238eda96ccf559d1d62760c4de0613f8 face_analysis/weights/resnet18-5c106cde.pth
2c76254f6fc379fddfce0a7e84fb5385bb135d3e399294f6eeb6680d0365b74b face_analysis/fonts/NotoSansCJKsc-Regular.otf
EOF
```
```powershell
# Windows PowerShell
Get-FileHash face_analysis\weights\79999_iter.pth -Algorithm SHA256
```
> resnet18 文件名内嵌的 `5c106cde` 即其官方 sha256 前 8 位(torchvision 命名惯例),与上表一致 = 官方权重无误。
@@ -55,11 +74,17 @@ BiSeNet 初始化时会调用 `torch.utils.model_zoo` / `torchvision` **联网
79999_iter.pth https://huggingface.co/ManyOtherFunctions/face-parse-bisent/resolve/main/79999_iter.pth
resnet18-5c106cde.pth https://download.pytorch.org/models/resnet18-5c106cde.pth
NotoSansCJKsc-Regular.otf https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf
model.safetensors https://huggingface.co/jonathandinu/face-parsing/resolve/main/model.safetensors
```
## 还差什么(pip 依赖)
模型已就位,但**内网机还需要 Python 依赖的离线 wheel 包**`mediapipe`/`opencv-python`/`torch` CPU 版等),否则 `pip install` 在内网无法联网安装。这部分**与目标机的操作系统Python 版本强相关**,需确认后单独打包:见仓库提交说明或联系下载方补充 `wheels/` 目录。
模型已就位,但**内网机还需要 Python 依赖的离线 wheel 包**,否则 `pip install` 在内网无法联网安装。这部分**与目标机的操作系统Python 版本、CUDA 版本强相关**,需确认后单独打包:
- **workerGPU 机)**`mediapipe` / `opencv-python` / `numpy<2` / `Pillow` / **`torch`+`torchvision` 的 CUDA 版**(按 GPU 的 CUDA 版本选 cu118/cu121 等)/ `transformers`(接口2 SegFormer+ FastAPI/uvicorn 全家桶。
- **网关机**:很轻,只需 FastAPI/uvicorn/httpx 等代理依赖,**不需要 torch/mediapipe**。
> 架构已拆分(见 `docs/实现说明.md`):算法依赖只装在 worker,网关保持轻量。
---
+450
View File
@@ -0,0 +1,450 @@
{
"1": {
"inputs": {
"scheduler": "simple",
"steps": 4,
"denoise": 1,
"model": [
"2",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "基本调度器"
}
},
"2": {
"inputs": {
"max_shift": 1.15,
"base_shift": 0.5,
"width": [
"14",
0
],
"height": [
"14",
1
],
"model": [
"16",
0
]
},
"class_type": "ModelSamplingFlux",
"_meta": {
"title": "采样算法(Flux"
}
},
"3": {
"inputs": {
"vae_name": "flux2-vae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "加载VAE"
}
},
"5": {
"inputs": {
"conditioning": [
"19",
0
],
"latent": [
"13",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "参考Latent"
}
},
"6": {
"inputs": {
"noise_seed": 217742615722421
},
"class_type": "RandomNoise",
"_meta": {
"title": "随机噪波"
}
},
"7": {
"inputs": {
"width": [
"14",
0
],
"height": [
"14",
1
],
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": {
"title": "空Latent图像(SD3"
}
},
"8": {
"inputs": {
"sampler_name": "euler"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "K采样器选择"
}
},
"9": {
"inputs": {
"noise": [
"6",
0
],
"guider": [
"20",
0
],
"sampler": [
"8",
0
],
"sigmas": [
"1",
0
],
"latent_image": [
"7",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "自定义采样器(高级)"
}
},
"10": {
"inputs": {
"samples": [
"9",
0
],
"vae": [
"3",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE解码"
}
},
"13": {
"inputs": {
"pixels": [
"44",
0
],
"vae": [
"3",
0
]
},
"class_type": "VAEEncode",
"_meta": {
"title": "VAE编码"
}
},
"14": {
"inputs": {
"image": [
"44",
0
]
},
"class_type": "GetImageSize+",
"_meta": {
"title": "🔧 Get Image Size"
}
},
"16": {
"inputs": {
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
"weight_dtype": "fp8_e4m3fn_fast"
},
"class_type": "UnetLoaderGGUF",
"_meta": {
"title": "UNet加载器"
}
},
"17": {
"inputs": {
"filename_prefix": "ComfyUI",
"images": [
"62",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "保存图像"
}
},
"19": {
"inputs": {
"guidance": 1,
"conditioning": [
"22",
0
]
},
"class_type": "FluxGuidance",
"_meta": {
"title": "Flux引导"
}
},
"20": {
"inputs": {
"model": [
"2",
0
],
"conditioning": [
"5",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "基本引导器"
}
},
"22": {
"inputs": {
"text": [
"60",
0
],
"clip": [
"61",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP文本编码"
}
},
"26": {
"inputs": {
"image": "clipspace/clipspace-painted-masked-1781655058499.png [input]"
},
"class_type": "LoadImage",
"_meta": {
"title": "加载图像"
}
},
"31": {
"inputs": {
"image": [
"26",
0
]
},
"class_type": "easy imageSize",
"_meta": {
"title": "图像尺寸"
}
},
"32": {
"inputs": {
"aspect_ratio": "custom",
"proportional_width": [
"31",
0
],
"proportional_height": [
"31",
1
],
"fit": "letterbox",
"method": "lanczos",
"round_to_multiple": "8",
"scale_to_side": "None",
"scale_to_length": 1024,
"background_color": "#000000",
"image": [
"26",
0
],
"mask": [
"37",
0
]
},
"class_type": "LayerUtility: ImageScaleByAspectRatio V2",
"_meta": {
"title": "LayerUtility: ImageScaleByAspectRatio V2"
}
},
"33": {
"inputs": {
"masks": [
"26",
1
]
},
"class_type": "Mask Fill Holes",
"_meta": {
"title": "Mask Fill Holes"
}
},
"36": {
"inputs": {
"masks": [
"33",
0
]
},
"class_type": "Convert Masks to Images",
"_meta": {
"title": "Convert Masks to Images"
}
},
"37": {
"inputs": {
"method": "intensity",
"image": [
"39",
0
]
},
"class_type": "Image To Mask",
"_meta": {
"title": "Image To Mask"
}
},
"39": {
"inputs": {
"upscale_method": "nearest-exact",
"width": [
"31",
0
],
"height": [
"31",
1
],
"crop": "disabled",
"image": [
"36",
0
]
},
"class_type": "ImageScale",
"_meta": {
"title": "缩放图像"
}
},
"44": {
"inputs": {
"mask_opacity": 1,
"mask_color": "FFFF00",
"pass_through": true,
"image": [
"32",
0
],
"mask": [
"32",
1
]
},
"class_type": "ImageAndMaskPreview",
"_meta": {
"title": "ImageAndMaskPreview"
}
},
"45": {
"inputs": {
"images": [
"44",
0
]
},
"class_type": "PreviewImage",
"_meta": {
"title": "预览图像"
}
},
"53": {
"inputs": {
"rgthree_comparer": {
"images": [
{
"name": "A",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_zuixa_00119_.png&type=temp&subfolder=&rand=0.7926013627811991"
},
{
"name": "B",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_zuixa_00120_.png&type=temp&subfolder=&rand=0.43692946883795813"
}
]
},
"image_a": [
"62",
0
],
"image_b": [
"26",
0
]
},
"class_type": "Image Comparer (rgthree)",
"_meta": {
"title": "Image Comparer (rgthree)"
}
},
"60": {
"inputs": {
"text": "填充遮罩区域的头发"
},
"class_type": "JjkText",
"_meta": {
"title": "Text"
}
},
"61": {
"inputs": {
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
"type": "flux2",
"device": "cpu"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "加载CLIP"
}
},
"62": {
"inputs": {
"method": "mkl",
"strength": 1,
"multithread": true,
"image_ref": [
"26",
0
],
"image_target": [
"10",
0
]
},
"class_type": "ColorMatch",
"_meta": {
"title": "Color Match"
}
}
}
+714
View File
@@ -0,0 +1,714 @@
{
"1": {
"inputs": {
"scheduler": "simple",
"steps": 6,
"denoise": 0.9,
"model": [
"2",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "基本调度器"
}
},
"2": {
"inputs": {
"max_shift": 1.15,
"base_shift": 0.5,
"width": [
"14",
0
],
"height": [
"14",
1
],
"model": [
"16",
0
]
},
"class_type": "ModelSamplingFlux",
"_meta": {
"title": "采样算法(Flux"
}
},
"3": {
"inputs": {
"vae_name": "flux2-vae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "加载VAE"
}
},
"5": {
"inputs": {
"conditioning": [
"19",
0
],
"latent": [
"64",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "参考Latent"
}
},
"6": {
"inputs": {
"noise_seed": 176834207514884
},
"class_type": "RandomNoise",
"_meta": {
"title": "随机噪波"
}
},
"7": {
"inputs": {
"width": [
"14",
0
],
"height": [
"14",
1
],
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": {
"title": "空Latent图像(SD3"
}
},
"8": {
"inputs": {
"sampler_name": "euler"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "K采样器选择"
}
},
"9": {
"inputs": {
"noise": [
"6",
0
],
"guider": [
"20",
0
],
"sampler": [
"8",
0
],
"sigmas": [
"1",
0
],
"latent_image": [
"64",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "自定义采样器(高级)"
}
},
"10": {
"inputs": {
"samples": [
"9",
0
],
"vae": [
"3",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE解码"
}
},
"14": {
"inputs": {
"image": [
"44",
0
]
},
"class_type": "GetImageSize+",
"_meta": {
"title": "🔧 Get Image Size"
}
},
"16": {
"inputs": {
"unet_name": "flux-2-klein-4b-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn"
},
"class_type": "UNETLoader",
"_meta": {
"title": "UNet加载器"
}
},
"19": {
"inputs": {
"guidance": 1.5,
"conditioning": [
"22",
0
]
},
"class_type": "FluxGuidance",
"_meta": {
"title": "Flux引导"
}
},
"20": {
"inputs": {
"model": [
"2",
0
],
"conditioning": [
"5",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "基本引导器"
}
},
"22": {
"inputs": {
"text": [
"60",
0
],
"clip": [
"61",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP文本编码"
}
},
"26": {
"inputs": {
"image": "clipspace/clipspace-painted-masked-1781933242779.png [input]"
},
"class_type": "LoadImage",
"_meta": {
"title": "加载图像"
}
},
"31": {
"inputs": {
"image": [
"26",
0
]
},
"class_type": "easy imageSize",
"_meta": {
"title": "图像尺寸"
}
},
"32": {
"inputs": {
"aspect_ratio": "custom",
"proportional_width": [
"31",
0
],
"proportional_height": [
"31",
1
],
"fit": "letterbox",
"method": "bicubic",
"round_to_multiple": "8",
"scale_to_side": "None",
"scale_to_length": 1024,
"background_color": "#000000",
"image": [
"26",
0
],
"mask": [
"89",
0
]
},
"class_type": "LayerUtility: ImageScaleByAspectRatio V2",
"_meta": {
"title": "图层工具:按宽高比缩放 V2"
}
},
"33": {
"inputs": {
"masks": [
"26",
1
]
},
"class_type": "Mask Fill Holes",
"_meta": {
"title": "Mask Fill Holes"
}
},
"36": {
"inputs": {
"masks": [
"33",
0
]
},
"class_type": "Convert Masks to Images",
"_meta": {
"title": "Convert Masks to Images"
}
},
"37": {
"inputs": {
"method": "intensity",
"image": [
"39",
0
]
},
"class_type": "Image To Mask",
"_meta": {
"title": "Image To Mask"
}
},
"39": {
"inputs": {
"upscale_method": "nearest-exact",
"width": [
"31",
0
],
"height": [
"31",
1
],
"crop": "disabled",
"image": [
"36",
0
]
},
"class_type": "ImageScale",
"_meta": {
"title": "缩放图像"
}
},
"44": {
"inputs": {
"mask_opacity": 1,
"mask_color": "FFFF00",
"pass_through": true,
"image": [
"32",
0
],
"mask": [
"32",
1
]
},
"class_type": "ImageAndMaskPreview",
"_meta": {
"title": "ImageAndMaskPreview"
}
},
"45": {
"inputs": {
"images": [
"44",
0
]
},
"class_type": "PreviewImage",
"_meta": {
"title": "预览图像"
}
},
"53": {
"inputs": {
"rgthree_comparer": {
"images": [
{
"name": "A",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_plnpv_00023_.png&type=temp&subfolder=&rand=0.8949967564686747"
},
{
"name": "B",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_plnpv_00024_.png&type=temp&subfolder=&rand=0.6013833933740399"
}
]
},
"image_a": [
"73",
0
],
"image_b": [
"26",
0
]
},
"class_type": "Image Comparer (rgthree)",
"_meta": {
"title": "Image Comparer (rgthree)"
}
},
"60": {
"inputs": {
"text": "补充遮罩内区域的头发,区域内填充满头发,不要保留皮肤,发际线下移填充头发。自然的头发生长方向,逼真的头发质感,自然发质。"
},
"class_type": "JjkText",
"_meta": {
"title": "Text"
}
},
"61": {
"inputs": {
"clip_name": "qwen_3_4b.safetensors",
"type": "flux2",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "加载CLIP"
}
},
"64": {
"inputs": {
"grow_mask_by": 3,
"vae": [
"3",
0
],
"images": [
"44",
0
],
"masks": [
"32",
1
]
},
"class_type": "VAEEncodeForInpaint_Frames",
"_meta": {
"title": "VAE Encode For Inpaint Frames ♾️Mixlab"
}
},
"68": {
"inputs": {
"sampler_name": "euler"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "K采样器选择"
}
},
"69": {
"inputs": {
"vae_name": "flux2-vae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "加载VAE"
}
},
"70": {
"inputs": {
"cfg": 1,
"model": [
"86",
0
],
"positive": [
"85",
0
],
"negative": [
"80",
0
]
},
"class_type": "CFGGuider",
"_meta": {
"title": "CFG引导器"
}
},
"71": {
"inputs": {
"noise_seed": 1105558556688843
},
"class_type": "RandomNoise",
"_meta": {
"title": "随机噪波"
}
},
"72": {
"inputs": {
"noise": [
"71",
0
],
"guider": [
"70",
0
],
"sampler": [
"68",
0
],
"sigmas": [
"77",
0
],
"latent_image": [
"81",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "自定义采样器(高级)"
}
},
"73": {
"inputs": {
"samples": [
"72",
0
],
"vae": [
"69",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE解码"
}
},
"75": {
"inputs": {
"filename_prefix": "ComfyUI",
"images": [
"73",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "保存图像"
}
},
"76": {
"inputs": {
"rgthree_comparer": {
"images": [
{
"name": "A",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_ssjll_00009_.png&type=temp&subfolder=&rand=0.8998219800410978"
},
{
"name": "B",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_ssjll_00010_.png&type=temp&subfolder=&rand=0.2100153657131264"
}
]
},
"image_a": [
"73",
0
],
"image_b": [
"26",
0
]
},
"class_type": "Image Comparer (rgthree)",
"_meta": {
"title": "Image Comparer (rgthree)"
}
},
"77": {
"inputs": {
"steps": 6,
"width": [
"83",
1
],
"height": [
"83",
1
]
},
"class_type": "Flux2Scheduler",
"_meta": {
"title": "Flux2调度器"
}
},
"78": {
"inputs": {
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
"type": "flux2",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "加载CLIP"
}
},
"79": {
"inputs": {
"conditioning": [
"87",
0
]
},
"class_type": "ConditioningZeroOut",
"_meta": {
"title": "条件零化"
}
},
"80": {
"inputs": {
"conditioning": [
"79",
0
],
"latent": [
"81",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "参考Latent"
}
},
"81": {
"inputs": {
"pixels": [
"10",
0
],
"vae": [
"69",
0
]
},
"class_type": "VAEEncode",
"_meta": {
"title": "VAE编码"
}
},
"82": {
"inputs": {
"width": [
"83",
0
],
"height": [
"83",
1
],
"batch_size": 1
},
"class_type": "EmptyFlux2LatentImage",
"_meta": {
"title": "空Latent图像(Flux2"
}
},
"83": {
"inputs": {
"image": [
"10",
0
]
},
"class_type": "GetImageSize",
"_meta": {
"title": "获取图像尺寸"
}
},
"85": {
"inputs": {
"conditioning": [
"87",
0
],
"latent": [
"81",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "参考Latent"
}
},
"86": {
"inputs": {
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "UNet加载器"
}
},
"87": {
"inputs": {
"text": "去掉头发接缝的黄色痕迹,头发完美融合,保持发型不变,发色不变。其他不变。",
"clip": [
"78",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP文本编码"
}
},
"89": {
"inputs": {
"left": 5,
"top": 15,
"right": 5,
"bottom": 15,
"mask": [
"37",
0
]
},
"class_type": "FeatherMask",
"_meta": {
"title": "羽化遮罩"
}
}
}
+1436 -115
View File
File diff suppressed because it is too large Load Diff
+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
+14
View File
@@ -0,0 +1,14 @@
# 文档索引
旷视五接口(四庭七眼测量 / C端生发 / B端生发 / 用户特征 / 发际线PNG)。系统拆成
**外网网关 + 高性能 worker(GPU)** 两台机器、一个仓库。
| 文档 | 作用 |
|------|------|
| [实现说明.md](实现说明.md) | **实现总览**:架构、五个接口怎么实现、base64→URL 映射、错误码、部署/环境要点。先读这份。 |
| [接口文档.md](接口文档.md) | 对外 API 契约(字段 / 错误码)。字段命名的唯一权威。 |
| [旷视具体需求.md](旷视具体需求.md) | 原始需求。 |
| [../OFFLINE_ASSETS.md](../OFFLINE_ASSETS.md) | worker 离线模型权重/字体清单(内网部署)。 |
> 原先分散的「各接口技术方案 / 开发任务书 / 系统架构 / 网关任务书」已合并进 `实现说明.md`
> (细节可查 git 历史)。
+96
View File
@@ -0,0 +1,96 @@
# GPU Worker 改动说明
测试页面已新增字段,worker 侧需对应接收并使用。
## 涉及接口
| 接口 | 路径 | 新增字段 |
|------|------|----------|
| 接口 2C端生发) | `POST /api/v1/hair/grow` | `prompt` |
| 接口 3B端生发) | `POST /api/v1/hair/grow-b` | `prompt` |
## 1. 新增 `prompt` 参数
### 接口 2`/api/v1/hair/grow`
Handler 签名新增:
```python
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
```
透传链路:
```
handler → generate_grow_results(image, gender, use_mask, prompt)
→ comfyui.run(buf, prompt=prompt)
```
### 接口 3`/api/v1/hair/grow-b`
Handler 签名新增:
```python
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
```
透传链路:
```
handler → generate_grow_b(marked, use_mask, prompt)
→ comfyui.run(buf, prompt=prompt)
```
### `hairline/comfyui.py` 改动
1. 新增节点常量:
```python
_PROMPT_NODE = "60" # JjkText:提示词
```
2. `run()` 函数签名改为:
```python
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None) -> bytes:
```
3.`run()` 内部,注入图片和 seed 之后(约第81行后)加入:
```python
if prompt is not None:
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
```
### `hairline/service.py` 改动
两个服务函数各加一个 `prompt` 参数,透传给 `comfyui.run()`
- `generate_grow_results(image_bgr, gender, use_mask=True, prompt=None)` — 两处 `comfyui.run(buf.getvalue())` 都改为 `comfyui.run(buf.getvalue(), prompt=prompt)`
- `generate_grow_b(marked_bgr, use_mask=True, prompt=None)``comfyui.run(buf.getvalue())` 改为 `comfyui.run(buf.getvalue(), prompt=prompt)`
## 2. 已有字段回顾
`use_mask``prompt` 测试页面均已就绪,表单字段一览:
| 接口 | 字段 | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| 2 | `image_file` | file | — | 用户照片(三选一) |
| 2 | `image_url` | string | — | 同上 |
| 2 | `image_base64` | string | — | 同上 |
| 2 | `gender` | string | `female` | `male` / `female` |
| 2 | `use_mask` | bool | `true` | inpaint 遮罩开关 |
| 2 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
| 3 | `marked_image_file` | file | — | 划线图(三选一) |
| 3 | `marked_image_url` | string | — | 同上 |
| 3 | `marked_image_base64` | string | — | 同上 |
| 3 | `use_mask` | bool | `true` | 画发际线开关 |
| 3 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
## 3. 当前工作流提示词节点
`add_hair.json` 中节点 **60**`JjkText`)为提示词节点,当前硬编码文本:
> 补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头
`comfyui.run()` 收到 `prompt` 参数后将替换此文本。
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)(需手动在图上画发际线后作为划线图上传)
+7
View File
@@ -0,0 +1,7 @@
第一步 使用接口9 头发遮罩生成 的算法获取 mask
第二部 改造 /home/xsl/change_hair 换发型的工作流, 换发型的参考文档在这里 /home/xsl/change_hair/docs/换发型集成文档.md
1、原始换发型工作的遮罩用第一步算出来的遮罩
2、然后换发型得到遮罩区域发际线的图片。
3、严格按照遮罩区域把图片贴回到原图上面。
4、贴图的时候融合贴图边缘和原图的接缝,可以采用羽化算法或者渐变alpha混合的算法,目的就是边缘要和原图过渡自然。 这里通过传入各种参数可以控制选哪种算法和控制过渡细节。
最后一步返回生成特定样式的图片。
@@ -0,0 +1,115 @@
# 发际线生发遮罩算法(pushed 模式)
> 对应接口11 `/api/v1/hairline/grow`、接口12 `/api/v1/hairline/grow_v2`。
> 遮罩算法固定为 pushed;融合算法默认 multiband(多频段金字塔),接口11 可切换 seamless/two_stage/feather。
> 代码:`face_analysis/hairline_grow.py``_extract_hairline` / `_pushed_mask` / `compute_mask` / `_composite`)。
## 概述
pushed 是发际线生发的**唯一**遮罩算法。融合算法默认 multiband(多频段金字塔),接口11 暴露 `blend_method` 可切换为 seamless(泊松)/two_stage(泊松→多频段两段式)/feather(羽化),便于对比调优。它从头发分割结果中提取「头发/皮肤交界线」(发际线),以眉心为圆心逐点径向外推一段距离,与 baseline 组成闭合区域作为最终遮罩。这样遮罩顶部会覆盖现有头发下沿一小段,贴回生发结果时顶部与真头发重叠、过渡自然。
> 接口12 `/api/v1/hairline/grow_v2` 只需传 `image` + `hairline_id`,遮罩和融合全部固定,无需任何算法选择参数。
## 算法流程(5 步)
```
①-a baseline 分割线 ← 眉骨/glabella 关键点折线(含 151 中心点)
①-b 上半区 upper ← baseline 以上的区域(裁剪范围)
①-c 头发分割 hair_mask ← segformer/bisenet 的原始头发像素
①-f 头发内轮廓线 ← 取头发轮廓中朝脸一侧的那段(额头弧+两侧到下颌),有序折线
①-g 径向外推 + 成带 ← 以 151 点为圆心把内轮廓逐点向外推 push_px,内轮廓↔外推线之间的带 = 最终遮罩
```
> ①-d(填充到基线 top_fill)、①-e(闭合区域 closed)是旧 eroded/closed 模式的中间产物,pushed 模式不走这条流程,前端不展示。
### ①-a baseline 分割线
MediaPipe 人脸关键点 `[21,68,104,69,108,151,337,299,333,298,251]` 连成折线(左端 21 → 中心 151 → 右端 251),再向左右边缘水平延长。151 点(glabella/眉心)是后续径向外推的圆心。代码 `_baseline_points` / `_draw_baseline`
### ①-b 上半区 upper
baseline 折线以上的多边形区域(`_upper_region_mask`)。作为后续裁剪范围,保证遮罩不越界到下半脸。
### ①-c 头发分割 hair_mask
segformer(默认)或 bisenet 得到的头发二值掩码。
### ①-f 头发内轮廓线(核心改动)
代码 `_extract_hairline`。目标是提取「头发区域朝脸一侧的内轮廓线」:额头弧 + 左右两侧鬓角/脸颊边界,一直向下到下颌,是一条**有序折线**(不再是逐列一个 y 的数组,因为两侧近乎竖直、一个 x 对多个 y)。
1. **取头发轮廓**`hair_mask` 最大连通域,`cv2.findContours(RETR_EXTERNAL, CHAIN_APPROX_NONE)` 取稠密、保序的外轮廓点。
2. **内侧判定**:轮廓同时含「朝背景的外侧剪影」和「朝脸的内轮廓」。对每个轮廓点,朝脸中心 151 方向采样 `sample_px`(≈0.4cm)像素,落点若是**非头发**像素 → 该点朝向脸(内轮廓);否则是外侧剪影,丢弃。
3. **取最长连续内侧段**:内轮廓点在闭合轮廓上本是一段连续弧,先做 1D 环形闭运算填掉判定抖动的小缝,再取最长连续 True 段并保序。
4. **下颌截断**:丢掉 y > `chin_y`(下巴关键点 152 的 y)的点,把两侧末端截到下颌一带 → 得到「环脸」内轮廓弧。
**关键点**:不再用 baseline 做水平截断、也不再逐列取下沿;截断改为「朝脸内侧」判定 + 下颌 y 截断,因此能同时拿到额头弧和两侧竖直边界。
### ①-g 径向外推 + 闭合区域(最终遮罩)
代码 `_pushed_mask`
1. **逐点径向外推**:圆心 = 151 点 (cx, cy)。对内轮廓上每个点 (x, y),沿「从圆心指向它」的单位向量 `(ux, uy)` **向外**(远离脸中心 = 推进现有头发)外推 `push_px`,得到外推线(黄线)点 `(x + ux·push_px, y + uy·push_px)``push_px = hairline_push_cm × px_per_cm`(默认 1cm)。
2. **逐列归并**:只取外推线中落在 baseline 以上的点,逐列取最靠上的 y 作为遮罩顶界 `pushed_y[x]`;空列线性插值填补。两侧鬓角落到 baseline 以下的段落自然被排除。
3. **与 baseline 组闭合区域**:逐列从 `pushed_y[x]` 填充到 `baseline_y[x]`(仅 `pushed_y < baseline_y` 的列),`& upper` 去越界、`_largest_cc` 保留最大连通域。
最终遮罩 = **外推发际线(①-g 黄线)与 baseline 分割线(①-a)组成的闭合区域**:顶界=外推发际线(覆盖现有头发约 push_cm),底界=baseline。与旧逻辑一致,区别只是 `pushed_y` 现在来自修正后的整条内轮廓,额头弧已延伸到两侧鬓角,额头遮罩宽度不再被截短。
## 关键参数
遮罩算法(pushed)固定。融合算法接口11 通过 `blend_method` 可切换(默认 multiband),其余融合参数均可调:
| 参数 | 默认 | 说明 |
|---|---|---|
| `hairline_push_cm` | 1.0 | 内轮廓径向外推距离(厘米),= push_px / px_per_cm。`px_per_cm` 由虹膜直径标定 |
| `hairline_edge` | `column` | 兼容保留的入参;内轮廓提取(轮廓+内侧判定)不再按它分支,取值不影响结果 |
| `mb_levels` | 5 | 多频段金字塔层数(2~6,越大低频色差抹得越宽)|
| `blend_method` | `multiband` | 接缝融合:multiband(多频段金字塔) / seamless(泊松) / two_stage(泊松→多频段,大色差) / feather(羽化) / alpha_gradient。接口12 固定 multiband |
| `color_match` | `true` | 融合前 Reinhard 颜色迁移消除整体色差(multiband/feather/alpha_gradient 生效;seamless/two_stage 自带调色故跳过)|
| `color_match_strength` | 1.0 | 颜色迁移强度(0~1<1 只迁移部分,防 Reinhard 过度改色)|
| `mb_feather_px` | 1 | 多频段最细层掩码轻羽化像素(0=不羽化),消除发丝边缘锯齿 |
| `transition_band_px` | -1 | keep-region 过渡带边距(-1=自动按层数 `2**n`;>=0 用绝对像素与层数解耦)|
| `edge_erode_px` | 3 | 贴图前遮罩内缩像素(防边缘露皮/光晕)|
| `erode_cm` | 0.6(接口12 固定)| baseline 参考内缩距离,对 pushed 影响很小 |
| `redraw` | `false` | 发际线带重绘开关:开启后用 final(④融合图)在「外推线↔发际线」带重绘,swapHair/Flux-2 两路对比,结果单独展示(不替换 final)|
| `inpainting_fill` | 1 | change_hair 重绘填充:0=保留原图(治染绿) / 1=填充噪声(默认) / 2=纯色 / 3=潜变量噪声 |
| `mask_blur` | 11 | change_hair 遮罩边缘模糊像素(越大颜色越易从边缘渗透)|
| `mask_dilate_scale` | 1.0 | change_hair 遮罩膨胀核缩放(1.0=原始,<1 收缩防越界)|
| `comfyui_prompt` | `null` | redraw Flux-2 路提示词,null 用默认「补充遮罩区域的头发,加一点美颜」|
> 接口12 `/api/v1/hairline/grow_v2` 只需传 `image` + `hairline_id`,遮罩和融合全部用默认值(multiband + color_match=true),不暴露算法选择参数。
### 融合方法选择建议
- **multiband**(默认):常规首选。低频抹色差、高频保发丝。需配合 `color_match=true` 消除整体色差。
- **two_stage**:生成图与原图色差大时用。先泊松克隆统一色调,再多频段贴细节,兼顾调色与保发丝。比纯 seamless 更不易溢色。
- **seamless**:纯泊松梯度域调和,色调统一干净,但可能整体改色/边缘溢色。
- **feather / alpha_gradient**:单层 alpha 过渡,最轻量,但过渡带内色差不会被抹平,仅适合色差极小的场景。
## 发际线带重绘(redraw,接口11 可选)
`redraw=true` 时,在主流程(④接缝融合 final)之后额外跑一条重绘分支,结果单独展示(`steps.redraw_a` / `redraw_c`),**不替换** final。
**重绘区域** = ①-g 外推发际线(`outer_pts`)与 ①-f 发际线(`inner_pts`)两条折线端点相连组成的带状闭合区域(宽度 ≈ `hairline_push_cm`,只覆盖发际线交界处)。
**两路后端对比**(输入图 + 融合基底都用 final):
- **swapHair 路**`redraw_a`):final + 带遮罩调 change_hair → final 走 multiband 融合
- **Flux-2 路**`redraw_c`):final + 带遮罩调 ComfyUI`hair_repaint.json` 工作流)→ final 走 multiband 融合。Flux-2 经 reference latent + ColorMatch 双重保色,**不易染绿**
> `inpainting_fill` / `mask_blur` / `mask_dilate_scale` 透传 change_hair 服务端(仅影响 swapHair 路)。`comfyui_prompt` 仅影响 Flux-2 路。
> 两路独立容错:任一路失败只跳过该路,不影响另一路和主 final。
> ⚠️ Flux-2 路需 ComfyUI8188)在跑;swapHair 路需 change_hair8801)在跑。
## 与旧模式(eroded/closed,已移除)的区别
| | eroded/closed(已移除) | pushed(当前唯一) |
|---|---|---|
| 遮罩来源 | top_fill(头发向下填充含额头)外缘内缩 | 头发内轮廓线 径向外推成带 |
| 截断方式 | 用 baseline 组上半区 upper | 内侧判定 + 下颌 y 截断(不再用 baseline |
| 遮罩形状 | 整个额头闭合区域 | 沿内轮廓的环脸带(额头弧+两侧,压住现有头发 push_cm) |
## 调试
- 调试页:`http://<host>:8187/static/test_interface11_debug.html`(带前后端日志面板、下载日志按钮)
- 后端日志:`/home/xsl/hair/log/hairline_grow.log`(按 `[rid]` 关联一次请求),下载接口 `/api/v1/debug/hairline_log?rid=<id>&tail=500`
- 可视化步骤:①-a baseline / ①-b upper / ①-c 头发分割 / ①-f 交界线 / ①-g 外推+遮罩 / 最终遮罩 / 生成 / 贴回 / 融合
+118
View File
@@ -0,0 +1,118 @@
# 旷视五接口 — 实现说明(总)
> 把原先分散的「各接口技术方案 + 开发任务书 + 系统架构 + 网关任务书」合并成这一份**简要总览**。
> 对外 API 契约以 [`接口文档.md`](接口文档.md) 为唯一权威;原始需求见 [`旷视具体需求.md`](旷视具体需求.md);
> 离线模型清单见 [`../OFFLINE_ASSETS.md`](../OFFLINE_ASSETS.md)。
---
## 1. 架构
两台机器、一个仓库:
```
客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py)
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI)
└ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/6/7
```
- **worker**`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,**纯本地、无外网依赖**。
`/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json``accept_passwords`);
`/health` 模型就绪才返回 200。监听 **8187**`./start.sh` 控制开关,`./run_worker.sh` 热重载)。
- **网关**`gateway/`):薄反向代理,健康检查/派发/鉴权/把 worker 的 `*_base64` 落盘改成 `*_url`
唯一例外是**接口4 在网关本机直接实现**(调外网豆包,不转发 worker)。
- **图片三选一**:所有接口图片入参 `image_file`/`image_url`/`image_base64` 严格三选一(接口3 是 `marked_image_*`)。
- **响应信封**`{code, message, request_id, data}`;业务错误用 `code`HTTP 一律 200)。
### base64 → URL 映射(网关落盘改写,递归进数组、可空保留 null)
| 接口 | worker 字段(内部) | 对外字段 |
|------|--------------------|----------|
| 1 | `annotated_image_base64` | `annotated_image_url` |
| 6 | `annotated_image_base64` | `annotated_image_url` |
| 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
| 3 | `hair_growth_image_base64`(可空) | `hair_growth_image_url` |
| 5 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`(可空) | `hairline_images[].image_{middle,high,low}_url` / `grown_image_url` |
| 7 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
> **图片格式**:接口1 标注图含透明用 **PNG**;接口2/3/5 是不透明照片用 **JPG**(小很多,~9×)。
> 网关落盘按内容嗅探扩展名(PNG 头→`.png`,否则 `.jpg`)。
### 错误码
`1001` 无法识别人像 `1002` 分辨率过低 `1003` 非正面 `1004` gender 必填/非法(接口2/5/7)|
`1006` >1MB `1007` 图片参数错误(0或多个)/未预期异常 | `1008` 格式不支持。
---
## 2. 五个接口实现简述
### 接口1 四庭七眼测量 `/api/v1/face/measure`worker
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、一张透明底标注 PNG。
- **怎么实现**`face_analysis/`):MediaPipe Face Mesh 468+虹膜点 → solvePnP 姿态校验(非正面 1003) →
虹膜直径法定标(px→cm) → **眉心以下实测****眉心以上**用 BiSeNet 头发分割取真实发际线/头顶(方案B),
失败回退比例推算(方案A`hairline_source` 透出)。标注图 numpy 向量化渐变线 + 思源黑体。返回 `annotated_image_base64`
- 门槛可配:`MIN_SHORT_SIDE`/`MIN_LONG_SIDE`(默认600/800)、姿态阈值 `FRONTAL_*_THR`(默认30°)。
### 接口6 四庭七眼测量 v2 `/api/v1/face/measure-v2`worker)—— 接口1 的去顶庭变体
- **做什么**:基于接口 1,**去顶庭**:不画头顶横线、不返回顶庭数据(`four_courts` 仅上/中/下庭,`landmarks``hair_top``face_total_height_cm` 为三庭之和)。
- **与接口1 的标注差异**`create_annotated_image(variant="v6")`):①竖线范围改为发际线→下巴尖;②不画人头最左/最右端线(仅七眼 6 点 5 段,接口1 为 8 线 7 段);③左侧只标上/中/下庭。箭头/虚线/字体等与接口1 一致。
- **怎么实现**:与接口 1 共用 `_face_measure_impl(variant="v6")`;v6 时标注走变体分支、数据由 app.py 边界删顶庭字段并重算三庭比例。
- **网关改动**:新增路由 `POST /api/v1/face/measure-v2`,转发到 worker 同路径;base64→URL 改写无需改动。
### 接口2 C端生发 `/api/v1/hair/grow`worker)—— 预览 + 生发图
- **做什么**:正面照 + `gender`(必填) + `hair_style`(必填,逗号分隔多选,如 `1,2,3`) → 指定发际线类型 **N 组**:**预览图**(发际线叠在照片上) + **生发后图**(植发3个月效果)。
- **怎么实现**`hairline/`):移植 head3d——MediaPipe(Tasks) + SegFormer 分割 + 17 锚点射线检测 → 502 点 mesh,
`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=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 组预览+生发图)。
- **与接口 2 的唯一区别**:ComfyUI 工作流使用 `add_hair2.json`Flux-2 Klein 9b),输入/遮罩节点同为 26,
SaveImage 输出节点为 75(自动检测)。其他参数、响应结构、错误码**完全相同**。
- **网关改动**:在 `gateway/app.py` 新增路由 `POST /api/v1/hair/grow-v2`,转发到 worker 同路径即可(盲转发,
base64→URL 改写逻辑无需改动,数组内图片字段已覆盖)。详见 [`网关待改动.md`](网关待改动.md)。
### 接口3 B端生发 `/api/v1/hair/grow-b`worker)—— 马克笔发际线
- **做什么**:医生用马克笔在额头画好发际线,**只传这一张划线图** → 检测线 → 生发图。输出 `hair_growth_image_url` + `hairline_type="custom"`
- **怎么实现**:检测算法源自 headmark——**黑帽响应图 + 鬓角锚点(MediaPipe 21/251) + Dijkstra 最小路径**(scikit-image)
比全局阈值鲁棒;路径平均响应过低→拒识(1001)。检测路径建遮罩,划线图原样送 ComfyUI(提示词清除黑线)。
### 接口4 用户特征 `/api/v1/face/features`**网关本机**
- **做什么**:照片 → 几十项面部特征(脸型/眉形/肤色/三庭五眼/四季色彩季型/量感/基因风格/性别…)。`data.features` 是 JSON 字符串。
- **怎么实现**`gateway/`,逻辑参考 worker `face_features.py` / `/home/xsl/fuyan`):调**火山方舟 豆包视觉模型**
`doubao-seed-1-6-vision`(OpenAI 兼容,base64 data URI 喂图),解析 JSON + 映射 6 个英文优先字段并保留全部中文。
无人脸→1001。**唯一调外网的接口**:网关需可达 `ark.cn-beijing.volces.com`API Key 走网关配置(不入 git)。
### 接口5 发际线PNG生成 `/api/v1/hairline/generate`worker
- **做什么**:入参同接口2`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
- **怎么实现**:复用接口2 的 502 点渲染管线,三档分别用 `hairline_texture[/_high|/_low]` 同名贴图渲染叠图;生发同接口2(ComfyUI inpaint),**黑模板固定取 `hairline_texture_black/`(middle)**,每发型 1 张生发图。`best_hairline_center_point`=眉心 x × 首个选中发型 middle 档发际线 y。
---
## 3. 部署 / 环境要点
**workerGPU 机)**
- Python **3.12**(系统 3.13 无 mediapipe/torch wheel);venv 在 `./venv`,依赖 `requirements.txt`
- `numpy<2`(1.26.4)`scikit-image==0.24.0`**别升 0.25+,会顶 numpy≥2 顶崩 mediapipe**)。
- ⚠️ 本机 **RTX 5090(sm_120)**pinned `torch 2.2.2(cu121)` 只到 sm_90 → GPU 算子报 "no kernel image"
代码已自动**回退 CPU**BiSeNet/SegFormer CPU 推理可用)。要用 5090 GPU 需换 torch cu128(≥2.7)。
- 模型权重/字体见 [`../OFFLINE_ASSETS.md`](../OFFLINE_ASSETS.md)BiSeNet/SegFormer/face_landmarker.task 本地。
- 生发接口依赖本机 **ComfyUI(8188)**Flux-2,它自带支持 5090 的 torch);worker 只调其 HTTP API,不跑 Flux。
ComfyUI 开了 **HTTP Basic Auth**user `admin` + 密码);密码放 `password.txt`(不入 git) /
`worker_config.json.comfyui_password` / 环境变量 `COMFYUI_PASSWORD`URL 用 `COMFYUI_URL`
- `worker_config.json`(不入 git)`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token`
**网关机**
- 很轻:FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
- 不装 torch/mediapipe/opencv。配置 `gateway/config.json`(不入 git)`workers` 列表、`shared_password`
`ark` 的 api_key/base_url/model、`public_base_url`、超时(**生发接口慢,`request_timeout_seconds` 调大 ≥120s**)。
- 托管 `/static/annotations/`(落盘的图),定期清理。
---
> 维护:本文为简要总览;字段以 `接口文档.md` 为准。各接口更细的算法推导可查 git 历史中已合并的旧技术方案文档。
@@ -1,479 +0,0 @@
# 接口 1:四庭七眼测量 — 开发任务书(AI Agent 执行版)
> 配套技术方案:[`接口1-四庭七眼测量-技术实现方案.md`](接口1-四庭七眼测量-技术实现方案.md)
> 执行者:AI coding agent。请**严格按阶段顺序**执行,每个阶段完成后运行该阶段的「验证方法」,**通过后再进入下一阶段**。
---
## 0. 背景与目标
把现有 `/api/v1/face/measure` 接口从 **Mock**(返回硬编码数据)替换为**真实算法实现**。
- 输入:单人正面人像图(multipart 上传 / URL / base64,三选一,≤1MB)。
- 输出:四庭(顶/上/中/下庭)、七眼(眼宽/脸宽/两眼间距)的 cm 值与占比,5 个关键点像素坐标,以及一张**仅含标注图层、透明底**的 PNG。
- 保持现有统一响应结构 `{code, message, request_id, data}` 与错误码 10011008 不变。
**核心算法策略**(见技术方案 §1.1):
- 眉心以下(中/下庭、七眼):MediaPipe Face Mesh 468 点直接实测。
- 眉心以上(上/顶庭,即发际线/头顶):**方案 B(BiSeNet 头发分割,主)** → **方案 A(比例推算,兜底)**
- 尺度换算:虹膜直径法(11.7mm)。
- 姿态校验:`cv2.solvePnP` 解算真实欧拉角。
---
## 1. 总体约束(所有阶段通用)
1. **不破坏现有接口契约**:响应外层结构、错误码、三选一图片输入规则、`ok()`/`err()` 帮助函数沿用 `app.py` 现有实现。
2. **新增逻辑全部放在 `face_analysis/` 包内**`app.py` 只做编排(读图→校验→调用→返回),保持单文件 app 的薄控制器风格。
3. **依赖锁版本**`numpy<2`(用 1.26.4)。torch 用 CPU 版。安装走 `pip.conf` 里的腾讯云镜像(torch 需用官方 CPU index)。
4. **模型权重不入 git**`face_analysis/weights/*.pth` 写进 `.gitignore`,由 §2 的下载脚本拉取。
5. **中文字体**`face_analysis/fonts/NotoSansCJKsc-Regular.otf`= 思源黑体,已预下载,见 `OFFLINE_ASSETS.md`)。
6. **每个模块都要能单独 import 且有 `if __name__ == "__main__"` 自测入口**,方便分阶段验证。
7. 代码风格、注释密度与 `app.py` 保持一致;注释用中文。
8. **不要 mock 兜底**:算法失败时返回对应错误码,**不得**回退成硬编码示例数据。
---
## 2. 阶段一:环境与依赖
**开发步骤**
1. 更新 `requirements.txt`,新增:`mediapipe==0.10.14``opencv-python==4.10.0``Pillow==11.0.0``numpy==1.26.4``torch==2.2.2``torchvision==0.17.2`
2. 在 venv 安装依赖。torch 用 CPU index
`./venv/bin/pip install torch==2.2.2 torchvision==0.17.2 --index-url https://download.pytorch.org/whl/cpu`
其余走现有 `pip.conf` 镜像。
3. 创建目录骨架:`face_analysis/{__init__.py,fonts/,weights/}``static/annotations/`
4. **权重/字体已预下载到位**(内网无需联网,见根目录 `OFFLINE_ASSETS.md` 的 sha256 清单):
- `face_analysis/weights/79999_iter.pth`BiSeNet 主权重 ~53MB
- `face_analysis/weights/resnet18-5c106cde.pth`(骨干 ~45MB
- `face_analysis/fonts/NotoSansCJKsc-Regular.otf`(中文字体 ~16MB
仍需写 `scripts/download_weights.sh`(供联网环境/生产机重建),但内网执行时跳过此步、直接用已有文件。
⚠️ **resnet18 骨干**:BiSeNet 初始化会尝试联网下载骨干,内网会失败——需把 `resnet18-5c106cde.pth` 拷到 `~/.cache/torch/hub/checkpoints/` 或改 BiSeNet 代码从 `weights/` 本地加载。
5. 更新 `.gitignore`:忽略 `face_analysis/weights/*.pth``static/annotations/*`(保留 `.gitkeep`)。
**交付物**
- 更新后的 `requirements.txt``.gitignore`
- `scripts/download_weights.sh`
- 目录骨架
**验证方法**
```bash
./venv/bin/python -c "import mediapipe, cv2, torch, numpy, PIL; \
print('numpy', numpy.__version__); print('mediapipe', mediapipe.__version__)"
```
- 必须无 import 错误;`numpy.__version__``1.26` 开头。
- `ls face_analysis/weights/79999_iter.pth` 存在且 >40MB`resnet18-5c106cde.pth` 存在。
- `ls face_analysis/fonts/NotoSansCJKsc-Regular.otf` 存在。
**完成标准**:上述命令全部通过,无报错。
---
## 3. 阶段二:MediaPipe 关键点检测封装
**开发步骤**
1. `face_analysis/face_mesh_landmarks.py`:定义所有关键点索引常量(见技术方案 §2.3):眉心 9/151、鼻翼下缘 94、下巴 152、眼角 33/133/263/362、脸颊 234/454、鼻尖 1/4、虹膜 468477、solvePnP 用的 61/291。
2. `face_analysis/detector.py`:实现 `FaceMeshDetector` 单例(技术方案 §8.2),`static_image_mode=True, max_num_faces=1, refine_landmarks=True``detect(image_bgr)` 返回 landmarks 或 None。
**交付物**`face_mesh_landmarks.py``detector.py`
**验证方法**
- 准备一张正面人像测试图 `tests/fixtures/frontal.jpg`(agent 若无素材,用一张公开 CC0 正面人像;记录来源)。
- 自测脚本:加载图 → `detector.detect()` → 断言返回非 None 且 landmark 数 ≥ 478(含虹膜)。
```bash
./venv/bin/python -m face_analysis.detector tests/fixtures/frontal.jpg
# 期望输出:detected landmarks: 478
```
**完成标准**:能稳定检测出 478 点。
---
## 4. 阶段三:姿态校验(solvePnP
**开发步骤**
1. `face_analysis/pose.py`:实现 `estimate_head_pose(landmarks, w, h)` 返回 `(yaw, pitch, roll)``check_frontal_face(...)` 返回 bool(技术方案 §9)。
2. 阈值用初始值 15°,定义为模块常量便于后续标定。
**交付物**`pose.py`
**验证方法**
- 用正面图:`check_frontal_face` 返回 True,三个角绝对值均 < 15。
- `hard_longhair.jpg` 略带角度,打印其 yaw/pitch/roll,确认角度比 frontal 大(用于观察姿态评分是否合理)。
- 仓库未提供明显侧脸图;若要测 `frontal=False` 的拒绝路径,agent 自备一张明显侧脸图存为 `tests/fixtures/profile.jpg`(公开 CC0,记录来源),否则在测试中用 mock landmarks 构造大 yaw 验证阈值逻辑。
```bash
./venv/bin/python -m face_analysis.pose tests/fixtures/frontal.jpg # frontal=True, 三角接近 0
./venv/bin/python -m face_analysis.pose tests/fixtures/hard_longhair.jpg # 打印角度,观察是否偏大
```
**完成标准**:正面图判定为 True 且三角接近 0;阈值拒绝逻辑(大 yaw→False)有测试覆盖。
---
## 5. 阶段四:尺度校准(虹膜直径法)
**开发步骤**
1. `face_analysis/calibration.py`
- `normalized_to_pixel``pixel_distance`(技术方案 §3.2)。
- `estimate_scale_factor(landmarks, w, h)` 返回 `px_per_cm`,用虹膜左右边缘点(469/471、474/476)求直径,左右取平均,除以 `AVG_IRIS_DIAMETER_CM=1.17`(技术方案 §3.3)。
- 虹膜点缺失时降级用眼宽(外→内眼角,均值 2.85cm)。
**交付物**`calibration.py`
**验证方法**
- 自测:对正面图算 `px_per_cm`,断言为正且落在合理范围(例如 1080×1920 的人像,px_per_cm 通常在 20120 之间,agent 实测后记录实际值作为回归基线)。
```bash
./venv/bin/python -m face_analysis.calibration tests/fixtures/frontal.jpg
# 期望输出:px_per_cm: <正数>
```
**完成标准**:输出正数且量级合理;故意传一张无虹膜(refine 关闭模拟)能走眼宽降级不报错。
---
## 6. 阶段五:头发分割(方案 B)+ 兜底(方案 A)
**开发步骤**
1. `face_analysis/hair_segmenter.py`
- 封装 BiSeNet face-parsing:加载 `weights/79999_iter.pth`,输入 BGR 图,输出 `hair_mask`H×W boolTrue=头发)。预处理 resize 到 512×512,推理后 resize 回原图尺寸。CPU 推理。单例加载,避免每次请求重载权重。
- `locate_hairline_by_segmentation(hair_mask, brow_center_x, h)` 返回 `(hairline_y, hair_top_y)` 或 None(技术方案 §4.0)。
2. `face_analysis/measure.py`(先做方案 A 部分):
- `estimate_vertical_landmarks(...)`(方案 A,技术方案 §4.3)作为兜底。
3.`measure.py` 里实现**决策逻辑**:先尝试方案 B,合理性校验(头顶在发际线上方、发际线在眉心上方、各庭为正)通过则用 B 并标 `hairline_source="segmentation"`,否则回退 A 标 `"estimated"`(技术方案 §4 决策流程)。
**交付物**`hair_segmenter.py``measure.py`(含纵向定位 + 决策)
**验证方法**
- 自测分割:对 `frontal.jpg` 输出 `hair_mask`,断言 `hair_mask.sum() > 0`dump 一张 mask 预览 PNG 到 `tests/output/`,目视确认头发区域正确。
- 自测定位:方案 B 返回的 `hairline_y < brow_center_y`(发际线在眉心上方,y 向下为正)、`hair_top_y < hairline_y`
- **困难样本** `hard_longhair.jpg`:长发遮挡两侧,确认要么中分缝定位合理、要么合理性校验不过自动回退方案 A(`hairline_source=="estimated"`),**两种都算通过,关键是不报错、不输出离谱坐标**。
- **降级路径**:把 mask 置空(`None`)模拟光头/分割失败,断言决策回退方案 A、`hairline_source=="estimated"`、不报错。
```bash
./venv/bin/python -m face_analysis.hair_segmenter tests/fixtures/frontal.jpg
# 期望:hair pixels: <正数>, hairline_y < brow_y, hair_top_y < hairline_y
./venv/bin/python -m face_analysis.hair_segmenter tests/fixtures/hard_longhair.jpg
# 期望:能跑通,输出分割结果或明确的回退标记
```
**完成标准**:正常头发图走分割且坐标自洽;长发/无头发图自动降级不报错。
---
## 7. 阶段六:四庭七眼测量计算
**开发步骤**
1. `measure.py` 补全:
- `measure_seven_eyes(...)`(技术方案 §5):眼宽(左右均值)、脸宽、两眼间距像素值。
- 整合主函数 `measure_face(landmarks, hair_mask, w, h)`
- 调 §4 决策得 5 个纵向点 + 各庭像素长。
- 调七眼测量。
-`estimate_scale_factor` 得 px_per_cm,全部像素 → cm。
- 算占比:四庭各段/全脸高,眼宽/脸宽、间距/脸宽。
- 返回结构化结果对象(含 cm、ratios、5 点像素坐标、hairline_source、head_pose)。
2. 结果对象提供 `to_response()` 方法,输出与现有 Mock 的 `data` 字段**完全同构**(字段名对齐 `docs/接口文档.md`)。
**交付物**:完整 `measure.py`
**验证方法**
- 对正面图跑 `measure_face`,断言:
- 四庭 ratio 之和 ≈ 1.0(±0.02)。
- 所有 cm 值为正且量级合理(全脸高度通常 18–24cm)。
- 眼宽 ratio 在 0.150.25 之间(七眼理论 ≈ 0.2)。
- 返回字段名与 `docs/接口文档.md` 定义逐一对齐(写一个字段对比断言)。
```bash
./venv/bin/python -m face_analysis.measure tests/fixtures/frontal.jpg
# 打印完整 data dict
```
**完成标准**:数值自洽、字段对齐文档。
---
## 8. 阶段七:标注图生成
**开发步骤**
1. `face_analysis/annotation.py`(技术方案 §6):
- 用打包中文字体绝对路径加载(**不静默降级**,缺字体直接抛错)。
- `draw_gradient_horizontal_line`:**numpy 向量化**实现(技术方案 §6 修订版),全程在 `np.zeros((h,w,4))` 缓冲上画,最后 `Image.fromarray`
- 四庭水平分界线(渐变消失)+ 左侧四庭 cm 数值。
- 七眼标注(上下穿插)。
- 虚线带箭头 `draw_dashed_line_with_arrows`
- 规格:线/字色 `#FFFFFF`、字体 10pt、线宽 1pt、透明底 RGBA。
2. `create_annotated_image(image_bgr, measure_result)` 返回 PIL RGBA Image。
**交付物**`annotation.py`
**验证方法**
- 对正面图生成标注 PNG,保存到 `tests/output/annotated.png`,断言:
- 模式为 `RGBA`,尺寸 == 原图尺寸。
- 存在透明像素(A==0)也存在不透明像素(A>0)。
- 中文渲染正常(人工/agent 目视 dump 图,确认"顶庭/上庭/中庭/下庭"非方块)。
- 性能:生成耗时记录,单张应 < 1s(验证 numpy 渐变线没有退化成逐像素)。
```bash
./venv/bin/python -m face_analysis.annotation tests/fixtures/frontal.jpg tests/output/annotated.png
```
**完成标准**:PNG 透明底正确、中文正常、生成快。
---
## 9. 阶段八:接入 app.py
**开发步骤**
1.`app.py` 替换 `/api/v1/face/measure` 的 Mock 实现:
- 解析三选一图片输入(沿用现有 URL/base64/file 处理;URL 需下载,base64 需去前缀解码)。
- 校验:大小 ≤1MB(1006)、可解码(1008)、分辨率用**短边/长边**判断(1002,技术方案 §8.3 修订版)。**门槛做成可配置**:读环境变量 `MIN_SHORT_SIDE`(默认 1080)、`MIN_LONG_SIDE`(默认 1920),不要硬编码(见 §14 分辨率门槛说明)。
- `detector.detect` → None 则 1001。
- `check_frontal_face` → False 则 1003。
- `hair_segmenter` 取 mask(失败传 None,由 measure 内部兜底)。
- `measure_face``create_annotated_image` → 保存到 `static/annotations/{uuid}.png` → 拼出 URL(用 `SAMPLE_IMAGE_URL` 同源的 base,即 `https://hair.xiangsilian.com/static/annotations/{uuid}.png`)。
- `return ok(result.to_response())`data 内含 `annotated_image_url`
2. 模型单例在模块加载时初始化(detector、segmenter),避免每请求重建。
3. 异常兜底:未预期异常返回 `err(1007, ...)`(按文档错误码定义对齐)。
**交付物**:更新后的 `app.py`
**验证方法**(本地起服务)
> 默认门槛已是 600/800`frontal.jpg` 直接放行,无需绕过校验。
```bash
./venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 &
F=http://127.0.0.1:8000/api/v1/face/measure
# 0) 正常图 → code==0data 含 four_courts/seven_eyes/annotated_image_url/hairline_source/head_pose
curl -s -X POST $F -F image_file=@tests/fixtures/frontal.jpg | python -m json.tool
# 1002) 低分辨率
curl -s -X POST $F -F image_file=@tests/fixtures/lowres.png
# 1001) 非人脸风景
curl -s -X POST $F -F image_file=@tests/fixtures/landscape.jpg
# 1008) 损坏文件
curl -s -X POST $F -F image_file=@tests/fixtures/corrupt.bin
# 1006) 超大图(>1MB,临时生成不入库)
head -c 1100000 /dev/urandom > /tmp/oversize.bin
curl -s -X POST $F -F image_file=@/tmp/oversize.bin
# 1003) 侧脸:仓库无素材,用 mock 大 yaw 在单测中覆盖
```
- 访问返回的 `annotated_image_url` 对应的本地文件存在。
- `/docs` Swagger 正常加载,该接口 schema 未破坏。
**完成标准**0/1002/1001/1008/1006 五类用例返回正确 code(有现成夹具);1003 用 mock 覆盖;正常用例 data 结构与文档一致。
---
## 10. 阶段九:测试套件与回归
**开发步骤**
1. `tests/test_face_measure.py`pytest):
- 各模块单元测试(detector/pose/calibration/segmenter/measure/annotation)。
- 接口集成测试:用 FastAPI `TestClient` 跑 §9 的错误码用例。
- **精度验证:见 §15 三层策略(合成真值 / 缩放不变性 / 可视化)**——这是误差验证的核心,必做。
- 数值回归:把 `frontal.jpg` 首次跑出的四庭/七眼 cm 值记为基线,断言后续运行偏差 < 1%(防止重构回归)。
2. `tests/fixtures/` 素材已就位(见 §14),无需再准备。
3.`docs/接口1-四庭七眼测量-技术实现方案.md` §11 待确认事项旁,补一份「实测基线数值表」。
**交付物**`tests/` 目录、`pytest.ini`(或 pyproject 配置)、基线数值表
**验证方法**
```bash
./venv/bin/python -m pytest tests/ -v
```
- 全绿。
**完成标准**`pytest` 全部通过。
---
## 11. 阶段十:部署与冒烟
**开发步骤**
1. 确认 `hair.service`(systemd)无需改动即可加载新依赖;若新增 torch 导致启动变慢,记录冷启动耗时。
2. 部署脚本补一步 `scripts/download_weights.sh`(生产机拉权重)。
3. 重启服务,跑线上冒烟。
**交付物**:更新的部署说明(写进 `CLAUDE.md``docs/`
**验证方法**
```bash
sudo systemctl restart hair && sudo systemctl status hair
curl -s -X POST https://hair.xiangsilian.com/api/v1/face/measure \
-F image_file=@tests/fixtures/frontal.jpg | python -m json.tool
# 期望 code==0annotated_image_url 可公网访问(curl -I 返回 200)
journalctl -u hair -n 50 # 无 ERROR/Traceback
```
**完成标准**:线上接口返回真实数据,标注图可访问,日志无异常。
---
## 12. 总交付清单(Definition of Done
- [ ] `requirements.txt` / `.gitignore` / `scripts/download_weights.sh`
- [ ] `face_analysis/``detector.py``pose.py``calibration.py``hair_segmenter.py``measure.py``annotation.py``face_mesh_landmarks.py``fonts/``weights/`
- [ ] `app.py``/api/v1/face/measure` 真实实现(移除该接口 Mock
- [ ] `tests/`fixtures + 单元 + 集成 + 数值回归,`pytest` 全绿
- [ ] 线上冒烟通过,标注图可公网访问
- [ ] 文档:实测基线数值表 + 部署说明更新
- [ ] 返回 data 含新增字段 `hairline_source``head_pose`,其余字段与 `docs/接口文档.md` 对齐
---
## 13. 风险与降级开关(提醒 agent)
1. **torch 装不上 / 太重**:若环境受限,先交付「方案 A only」版本(跳过阶段五的分割,`hairline_source` 恒为 `"estimated"`),把方案 B 标记为 TODO,但**其余阶段照常**。在交付说明里明确写出。
2. **数值不合理**(如 cm 量级离谱):优先怀疑 px_per_cm(虹膜点是否检出)和分辨率方向判断,而非盲目调比例常数。
3. **不确定字段命名**:以 `docs/接口文档.md` 为唯一权威,冲突时以文档为准并在 PR 说明里指出。
---
## 14. 测试素材清单(已就位于 `tests/fixtures/`
以下夹具**已全部创建完毕**,agent 直接使用即可,无需再拷贝/生成:
| 文件 | 尺寸(W×H) | 大小 | 来源 | 用途 |
|------|-----------|------|------|------|
| `frontal.jpg` | 682×811 | 94KB | 真实样本(原 `image/test.jpg` | **主用例**:阶段二~八全部功能验证 + 数值基线 |
| `hard_longhair.jpg` | 864×1152 | 131KB | 真实样本(原 `image/qwerqwe.jpg` | **困难样本**:分割鲁棒性、`max_num_faces=1` 只取最大脸、姿态 |
| `lowres.png` | 406×571 | 226KB | 真实样本(原 `image/image.png`,已带标注线) | **1002 拒绝用例**(短边 406 < 600);勿当干净输入 |
| `landscape.jpg` | 1000×1200 | 114KB | 程序生成(非人脸风景) | **1001 用例**:无法识别人像 |
| `corrupt.bin` | — | 2KB | 程序生成(伪 PNG 头 + 垃圾字节) | **1008 用例**:无法解码 |
| _(1006 超大图)_ | — | >1MB | **测试时动态生成,不入库** | **1006 用例**:超过 1MB |
> **1006 超大图不提交进 git**(避免仓库膨胀,内容是随机噪声无信息量)。在 `tests/conftest.py` 里用 pytest fixture 临时生成;测 1006 仅看字节数、无需合法图片:
> ```python
> @pytest.fixture
> def oversize_file(tmp_path):
> p = tmp_path / "oversize.bin"
> p.write_bytes(b"\x00" * (1_100_000)) # 1.1MB,刚过 1MB 红线
> return p
> ```
> 手动 curl 验证时临时造一个即可:`head -c 1100000 /dev/urandom > /tmp/oversize.bin`
> 仍缺:明显侧脸图(测 1003)。无合规素材,agent 用 mock landmarks 构造大 yaw 验证阈值逻辑即可(见 §4 阶段三)。
### 分辨率门槛(已放宽,可配置)
- **默认门槛下调为:短边 ≥ 600、长边 ≥ 800**(环境变量 `MIN_SHORT_SIDE=600``MIN_LONG_SIDE=800`,技术方案 §8.3 已同步)。
- 该门槛下:`frontal.jpg`(682×811)、`hard_longhair.jpg`(864×1152) 放行;`lowres.png`(406×571) 被 1002 拒绝——正好作拒绝用例,**功能测试无需再绕过校验**。
- **门槛必须做成可配置,不要硬编码**:生产可通过环境变量随时调整,无需改代码。
**待确认事项(提交给需求方,不阻塞开发)**
1. 600/800 是否合适?过低会牺牲测量精度(虹膜/关键点像素太少),过高会拒掉大量真实上传图。建议上线后按实际拒绝率/精度反馈再调。
2. `hard_longhair.jpg` 这类长发遮挡发际线的图,方案 B 大概率只能定位到中分缝;若分割结果不可靠应自动回退方案 A(`hairline_source="estimated"`)——确认这是可接受行为。
---
## 15. 精度 / 误差验证策略(三层)
> **核心认知**:管线分两层——**测量数学**(landmarks+尺度→cm)可以构造精确真值验证;**MediaPipe 检测**(图→landmarks 落点)无法合成真值,只能人工标注或间接验证。绝大多数可控 bug 在数学层,务必重点覆盖。
### Tier 1 — 合成真值,精确验证测量数学(必做,核心)
自己构造一组「已知真值」的关键点:坐标和 `px_per_cm` 都由测试设定,因此每一段的 cm/占比真值已知,算出来必须**分毫不差**(误差仅来自浮点,断言 < 1e-6)。这能精确验证 `calibration` / `measure_seven_eyes` / 方案A 推算 / 占比公式。
```python
# tests/test_geometry_truth.py
import numpy as np
class _LM: # 模拟 MediaPipe landmark.x/.y/.z
def __init__(self, x, y, z=0.0): self.x, self.y, self.z = x, y, z
def build_synthetic_landmarks(px_per_cm=50.0, W=1000, H=1000):
"""按已知 cm 几何摆放关键点,返回 (landmarks_list, ground_truth_dict)"""
cx = W / 2
def Y(cm_from_top): # cm → 归一化 y
return (cm_from_top * px_per_cm) / H
def X(px):
return px / W
# 设定真值(cm):从头顶往下
gt = {"top_court_cm": 4.0, "upper_court_cm": 5.0,
"middle_court_cm": 6.0, "lower_court_cm": 5.0,
"eye_width_cm": 3.0, "inter_eye_cm": 3.4, "face_width_cm": 14.0,
"px_per_cm": px_per_cm}
y_hairtop = 2.0
y_hairline = y_hairtop + gt["top_court_cm"]
y_brow = y_hairline + gt["upper_court_cm"]
y_nose = y_brow + gt["middle_court_cm"]
y_chin = y_nose + gt["lower_court_cm"]
lm = {i: _LM(X(cx), 0.0) for i in range(478)} # 占位
# 纵向中轴点
lm[9] = _LM(X(cx), Y(y_brow)); lm[151] = _LM(X(cx), Y(y_brow))
lm[94] = _LM(X(cx), Y(y_nose))
lm[152]= _LM(X(cx), Y(y_chin))
# 七眼横向点(按真值 px 摆位,y 任意取眉下一行)
ew = gt["eye_width_cm"] * px_per_cm
ie = gt["inter_eye_cm"] * px_per_cm
fw = gt["face_width_cm"] * px_per_cm
eye_y = Y(y_brow + 2.0)
lm[133] = _LM(X(cx - ie/2), eye_y); lm[33] = _LM(X(cx - ie/2 - ew), eye_y)
lm[362] = _LM(X(cx + ie/2), eye_y); lm[263] = _LM(X(cx + ie/2 + ew), eye_y)
lm[234] = _LM(X(cx - fw/2), eye_y); lm[454] = _LM(X(cx + fw/2), eye_y)
# 虹膜边缘点:直径 = 1.17cm * px_per_cm,使尺度可被精确反解
d = 1.17 * px_per_cm
lm[469] = _LM(X(cx - ie/2 - ew/2 - d/2), eye_y); lm[471] = _LM(X(cx - ie/2 - ew/2 + d/2), eye_y)
lm[474] = _LM(X(cx + ie/2 + ew/2 - d/2), eye_y); lm[476] = _LM(X(cx + ie/2 + ew/2 + d/2), eye_y)
return [lm[i] for i in range(478)], gt
def test_scale_factor_exact():
lm, gt = build_synthetic_landmarks(px_per_cm=50.0)
from face_analysis.calibration import estimate_scale_factor
assert abs(estimate_scale_factor(lm, 1000, 1000) - gt["px_per_cm"]) < 1e-6
def test_seven_eyes_exact():
lm, gt = build_synthetic_landmarks()
from face_analysis.measure import measure_seven_eyes
r = measure_seven_eyes(lm, 1000, 1000)
pc = gt["px_per_cm"]
assert abs(r["eye_width_px"]/pc - gt["eye_width_cm"]) < 1e-6
assert abs(r["face_width_px"]/pc - gt["face_width_cm"]) < 1e-6
assert abs(r["inter_eye_distance_px"]/pc - gt["inter_eye_cm"]) < 1e-6
# 方案A 推算、四庭占比同理,用 gt 的中/下庭做输入,断言推算的上/顶庭与 gt 关系一致
```
> 注意:方案 A 因为是「按比例推算」,它推出的上/顶庭**不会**等于任意设定的真值——Tier 1 对方案 A 只验证「推算公式按既定比例正确执行」(给定中下庭,输出符合 0.25/0.22 比例关系),而非验证它贴近真实脸。这正是方案 A 循环论证局限的体现,文档已说明。方案 B 的真值验证用合成 mask(已知头发区域上沿)走 `locate_hairline_by_segmentation`。
### Tier 2 — 缩放不变性,真实图上可运行(必做)
用真实 `frontal.jpg` 跑完整管线,再把图**等比放大 2×** 重跑。物理量应满足:
- **占比(ratio)完全不变**(±0.5%)——放大不改变比例。
- **cm 值基本不变**(±2%)——因为 px_per_cm 也随之放大,虹膜法自洽。
这用**真实 MediaPipe 输出**验证尺度处理无 bug,不需要人工真值。若放大后 cm 值漂移大,说明尺度链路有问题。
```python
def test_scale_invariance():
import cv2
img = cv2.imread("tests/fixtures/frontal.jpg")
big = cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)
r1 = run_measure(img); r2 = run_measure(big)
for k in ["top","upper","middle","lower"]:
assert abs(r1.ratio[k] - r2.ratio[k]) < 0.005 # 占比不变
assert abs(r1.cm[k] - r2.cm[k]) / r1.cm[k] < 0.02 # cm 近似不变
```
### Tier 3 — 检测落点定性评估(人工真值,抽样)
MediaPipe 落点准不准没有合成真值,只能:
1. **可视化叠加**:把 5 个纵向点 + 眼角点画回原图存 PNG,人工/agent 目视确认落点正确(眉心在眉间、下巴在下颌最低点等)。
2. **抽样人工标注**:对 2~3 张图手工标注真值关键点像素坐标存 `tests/fixtures/*_truth.json`,断言 MediaPipe 输出与标注的像素偏差 < 全脸高度的 3%。
```python
def test_landmark_overlay():
"""生成叠加图供人工核验,并断言关键点落在图像合理区域内"""
# 画点存 tests/output/frontal_landmarks.png,断言各点坐标在 [0,W]/[0,H] 且顺序自上而下
```
### 误差预期对照(写进基线表)
| 误差来源 | 验证手段 | 预期 |
|----------|----------|------|
| 测量数学(尺度/占比/七眼/脸宽) | Tier 1 合成真值 | ≈ 0< 1e-6 |
| 尺度链路一致性 | Tier 2 缩放不变性 | 占比 < 0.5%cm < 2% |
| MediaPipe 落点 | Tier 3 人工标注抽样 | < 3% 全脸高 |
| 虹膜个体差异 + 透视 | 无法消除,文档声明 | cm ±5~15%(离虹膜平面越远越大) |
| 方案 A 推算上/顶庭 | 固有局限 | 真实脸偏差可达 ±15%,故优先方案 B |
**完成标准(补充到阶段九)**Tier 1 全部断言 < 1e-6Tier 2 通过;Tier 3 叠加图人工确认 OK。
---
> **任务书版本**: v1.3 **创建日期**: 2026-06-13v1.3:新增 §15 三层精度验证策略 + 合成真值生成器)| 配套技术方案 v2.0
@@ -1,837 +0,0 @@
# 接口 1:四庭七眼测量 — 技术实现方案
> 基于 MediaPipe Face Mesh468 关键点)测量「眉心以下」+ 人脸解析分割(BiSeNet)获取「真实发际线/头顶」+ 人脸比例先验作为兜底
---
## 1. 模型选型
### 1.1 调研结论
调研了以下人脸关键点检测模型:
| 模型 | 关键点数 | 覆盖范围 | Python 支持 | 备注 |
|------|----------|----------|-------------|------|
| **MediaPipe Face Mesh** | 468 / 478 | 额头中部 → 下巴(不含发际线以上) | ✅ `mediapipe` 包 | Google 官方,实时性能好 |
| dlib 68-point (300-W) | 68 | 眉毛 → 下巴 | ✅ `dlib` | 经典方法,无额头覆盖 |
| WFLW 98-point | 98 | 眉毛 → 下巴(额头仅 2 点) | ⚠️ 需额外模型 | 仍无头顶/发际线 |
| 3DDFA_V2 | 68+ 3D mesh | 类似 MediaPipe | ⚠️ 推理较慢 | 3D 重建更完整 |
| SPIGA | 68 | 眉毛 → 下巴 | ✅ | 实时性不如 MediaPipe |
**结论:没有任何「关键点检测模型」能直接给出「头顶」和「真实发际线」坐标**——所有关键点模型在额头以上方向都有盲区。
但「**人脸解析 / 头发分割模型**」可以直接把头发区域分割出来,从而得到**真实**的发际线与头顶位置(详见 §1.4 与 §4 方案 B)。因此本方案采用**双策略**:
- **眉心以下(中庭、下庭、七眼)**MediaPipe Face Mesh 468 点直接实测,精度高。
- **眉心以上(上庭、顶庭,即发际线与头顶)**:
- **方案 B(主)**:人脸解析分割(BiSeNet)提取真实发际线/头顶 —— 这两庭是**真实测量值**。
- **方案 A(兜底)**:当分割失败、光头、或被帽子/刘海遮挡时,退化为「人脸比例推算」。
> ⚠️ **重要**:旧版本仅用方案 A,存在「循环论证」缺陷 —— 用三庭标准比例反推发际线、再据此算占比,输出的顶庭/上庭占比几乎等于输入常数,不反映真实脸型。引入方案 B 后,顶上两庭才成为真正的测量结果。方案 A 仅作降级使用。
### 1.2 为什么用 468 点而非 478 点
478 点比 468 点多出 10 个虹膜(iris)关键点(索引 468–477),仅用于眼球追踪。四庭七眼测量不需要虹膜数据,468 点完全满足需求。使用经典 `mp.solutions.face_mesh` API,模型内置于 pip 包中,无需单独下载 `.task` 文件。
### 1.3 国内安装方式
```bash
# 使用清华镜像安装 mediapipe 及依赖
pip install mediapipe opencv-python pillow numpy -i https://pypi.tuna.tsinghua.edu.cn/simple/
```
经典 Solutions API 模型文件已打包在 wheel 包内(路径:`mediapipe/modules/face_landmark/`),安装后直接可用,无需额外下载。
> ⚠️ **版本兼容性坑**MediaPipe 0.10.x 对 numpy 2.x 支持不稳定,常出现 import 崩溃。**必须锁定 `numpy<2`(推荐 1.26.x**,详见 §10。
### 1.4 发际线 / 头顶分割模型(方案 B 依赖)
关键点模型够不到的额头以上区域,用**人脸解析(face parsing)**模型补齐。这类模型对整张脸做像素级语义分割,类别中包含 `hair`(头发):
| 模型 | 训练集 | 类别数 | 体积 | Python 支持 | 备注 |
|------|--------|--------|------|-------------|------|
| **BiSeNet (face-parsing.PyTorch)** | CelebAMask-HQ | 19(含 hair/skin/眉眼鼻嘴等) | ~50 MB | ✅ PyTorch | 最常用,CPU 可跑(~0.31s/张) |
| MODNet | 人像 matting | 前景/背景 | ~25 MB | ✅ | 只分前景,不区分头发 |
| SegFormer-b0 face-parsing | CelebAMask-HQ | 19 | ~15 MB | ✅ HuggingFace | 更轻,需 transformers |
**选型:BiSeNetface-parsing.PyTorch**,社区成熟、权重易得、19 类直接含 `hair`
拿到分割 mask 后:
- **真实发际线** = 沿面部中轴线(用 §4 的 `brow_center_x` 作为 x),从上往下扫描,**头发区域 → 皮肤区域**的第一个交界 y 坐标。
- **头顶** = 头发 mask 的**最高点**(最小 y)。
> 权重需单独下载,放入 `face_analysis/weights/`,不入 git(写进 `.gitignore`):
> - `79999_iter.pth`~53 MB)— BiSeNet 主权重。
> - `resnet18-5c106cde.pth`~45 MB)— BiSeNet 用的 resnet18 骨干。**离线/内网环境必须预放**:BiSeNet 初始化时会尝试用 `torch.utils.model_zoo` 联网下载该骨干,内网会失败。需把它放进 torch hub 缓存(`~/.cache/torch/hub/checkpoints/`)或改代码从本地路径加载。
>
> **本仓库已预先下载好上述权重 + 字体**(见根目录 `OFFLINE_ASSETS.md` 的 sha256 清单),内网机器无需联网,直接使用。
---
## 2. 关键点索引映射
MediaPipe Face Mesh 对 468 个点按固定拓扑编号,以下是四庭七眼测量所需的关键索引:
### 2.1 四庭纵向关键点
```
★ 头顶 (hair_top) ← 方案A推算,非MediaPipe直接检测
│ 顶庭 (~22%)
★ 发际线 (hairline) ← 方案A推算,非MediaPipe直接检测
│ 上庭 (~25%)
★ 眉心 (brow_center) ← 索引 9 或 151glabella,双眉间)
│ 中庭 (~28%)
★ 鼻翼下缘 (nose_bottom) ← 索引 94subnasale / 人中顶部)
│ 下庭 (~25%)
★ 下巴尖 (chin_tip) ← 索引 152menton
```
| 测量点 | MediaPipe 索引 | 说明 |
|--------|----------------|------|
| 头顶 | **无直接索引** | 由发际线 + 顶庭比例向上推算 |
| 发际线 | **无直接索引** | 由眉心 + 上庭比例向上推算 |
| 眉心 | **9****151** | glabella,双眉间中心点;两个点取中点 |
| 鼻翼下缘 | **94** | subnasale,鼻小柱底部与人中交界处 |
| 下巴尖 | **152** | menton,下颌最低点 |
### 2.2 七眼横向关键点
```
左脸 左眼外角 左眼内角 右眼内角 右眼外角 右脸
│ │ │ │ │ │
234 ←────── 33 ───── 133 ──── 两眼间距 ──── 362 ───── 263 ──────→ 454
│ │← 眼宽 →│ ← 两眼间距 → │← 眼宽 →│ │
│←──────────────── 脸宽 ──────────────────────────────→│
```
| 测量项目 | 左端索引 | 右端索引 | 说明 |
|----------|----------|----------|------|
| 左眼宽度 | 33(外眼角) | 133(内眼角) | 水平距离 |
| 右眼宽度 | 263(外眼角) | 362(内眼角) | 水平距离 |
| 两眼间距 | 133(左内眼角) | 362(右内眼角) | 内眦间距 |
| 脸宽 | 234(左颧弓) | 454(右颧弓) | 面部最宽处水平距离 |
> 注:脸宽使用 face oval 轮廓上颧弓高度对应的点。索引 234(左)和 454(右)位于 cheekbone 高度,是 face oval 路径 `...→234→127→162→21→...` 和 `...→454→356→389→251→...` 上的点。
### 2.3 参考索引速查表
| 索引 | 解剖位置 | 所属区域 |
|------|----------|----------|
| 4 | 鼻尖 (nose tip) | 鼻子 |
| 9, 151 | 眉间 / glabella | 眉心 |
| 10 | 额头顶端 (forehead top) — 不是发际线 | 额头 |
| 33 | 左眼外眼角 | 左眼 |
| 94 | 鼻翼下缘 / subnasale | 鼻子底部 |
| 133 | 左眼内眼角 | 左眼 |
| 152 | 下巴尖 / menton | 下巴 |
| 234 | 左脸颧弓处 | 面部轮廓 |
| 263 | 右眼外眼角 | 右眼 |
| 362 | 右眼内眼角 | 右眼 |
| 454 | 右脸颧弓处 | 面部轮廓 |
**Face Oval 连通路径**(面部轮廓线,用于验证脸宽点选择):
```
10→338→297→332→284→251→389→356→454→323→361→288→397→365→379→378→400→377→152→148→176→149→150→136→172→58→132→93→234→127→162→21→54→103→67→109→(回到10)
```
---
## 3. 厘米换算方案
### 3.1 转换原理
MediaPipe 输出的关键点坐标是 **归一化像素坐标**
- `x ∈ [0, 1]`,归一化于图像宽度
- `y ∈ [0, 1]`,归一化于图像高度
- `z` 为相对深度(以头部中心为零点,向镜头方向为负)
需要将归一化坐标转为像素坐标,再通过**尺度参照物**转为厘米。
### 3.2 像素坐标恢复
```python
def normalized_to_pixel(landmark, image_width, image_height):
"""归一化坐标 → 像素坐标"""
x_px = landmark.x * image_width
y_px = landmark.y * image_height
return x_px, y_px
def pixel_distance(p1, p2):
"""两点像素距离"""
return ((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2) ** 0.5
```
### 3.3 尺度校准:虹膜直径法
**原理**:人类虹膜直径高度稳定,成人平均 **11.7 mm**(标准差 ≈ 0.5 mm,约 4%),可作为天然标尺。
```python
AVG_IRIS_DIAMETER_CM = 1.17 # 11.7 mm
# MediaPipe 虹膜关键点(开启 refine_landmarks=True 后可用)
IRIS_LEFT_CENTER = 468 # 左眼虹膜中心
IRIS_RIGHT_CENTER = 473 # 右眼虹膜中心
# 虹膜边界点(取上/下或左/右两个边缘点计算直径)
IRIS_LEFT_LEFT = 469 # 左虹膜左边缘
IRIS_LEFT_RIGHT = 471 # 左虹膜右边缘
IRIS_RIGHT_LEFT = 474 # 右虹膜左边缘
IRIS_RIGHT_RIGHT = 476 # 右虹膜右边缘
def estimate_scale_factor(landmarks, image_width, image_height):
"""通过虹膜直径估算 px → cm 缩放因子
Returns:
px_per_cm: 每厘米对应多少像素
"""
# 左眼虹膜像素直径
iris_left_l = normalized_to_pixel(landmarks[IRIS_LEFT_LEFT], image_width, image_height)
iris_left_r = normalized_to_pixel(landmarks[IRIS_LEFT_RIGHT], image_width, image_height)
iris_left_diameter_px = pixel_distance(iris_left_l, iris_left_r)
# 右眼虹膜像素直径
iris_right_l = normalized_to_pixel(landmarks[IRIS_RIGHT_LEFT], image_width, image_height)
iris_right_r = normalized_to_pixel(landmarks[IRIS_RIGHT_RIGHT], image_width, image_height)
iris_right_diameter_px = pixel_distance(iris_right_l, iris_right_r)
# 取平均,减少误差
avg_iris_diameter_px = (iris_left_diameter_px + iris_right_diameter_px) / 2
px_per_cm = avg_iris_diameter_px / AVG_IRIS_DIAMETER_CM
return px_per_cm
```
> **注意**:虹膜关键点(索引 468477)需要 `FaceMesh(refine_landmarks=True)` 才会输出。如果不启用 `refine_landmarks`,可用**眼宽**(外眼角→内眼角)作为替代标尺,人类平均眼裂宽度约 **27–30 mm**,精度略低。
> ⚠️ **透视局限(务必在 API 文档/返回里注明)**:虹膜法得到的 `px_per_cm` 只在**虹膜所在的深度平面**精确。下巴、额头、头顶与虹膜不共面,2D 照片存在透视投影,因此纵向(四庭)的 cm 换算会带系统误差,离虹膜平面越远(如头顶)误差越大。返回的 cm 值应理解为**近似值**,而非全脸恒定尺度下的精确测量。比例(ratio)受透视影响小于绝对 cm 值,建议前端优先展示比例。
### 3.4 备用校准:人脸比例法
若虹膜数据不可用,也可用 460 点基础模型的脸宽比例估算:
```python
# 基于三庭五眼理想比例
# 脸宽 (234→454 px) ≈ 5 眼宽 ≈ 5 × (脸宽的 1/5)
# 已知脸宽距离的像素值,参考人脸统计平均脸宽 ~14 cm (女性) ~15 cm (男性)
# 得 px_per_cm = face_width_px / 14.5 (粗略)
```
此方法误差较大(±15%),建议优先使用虹膜法。对测量误差要求不严格的场景可接受。
---
## 4. 头顶 & 发际线定位(方案 B 主 / 方案 A 兜底)
> **决策流程**:先跑方案 B(分割)。若分割成功且发际线/头顶落在合理范围(发际线在眉心上方、头顶在发际线上方、各庭长度为正),用方案 B 结果;否则记录 `hairline_source = "estimated"` 并回退方案 A。方案 B 成功时 `hairline_source = "segmentation"`,需在返回 `data` 里透出该字段,方便前端/业务区分真实测量与估算。
### 4.0 方案 B(主):分割提取真实发际线 & 头顶
```python
def locate_hairline_by_segmentation(hair_mask, brow_center_x, image_height):
"""
输入: hair_mask (H×W bool/uint8, True=头发像素), 面部中轴线 x, 图高
输出: (hairline_y, hair_top_y) 像素坐标; 失败返回 None
"""
import numpy as np
if hair_mask is None or hair_mask.sum() == 0:
return None # 光头 / 分割失败 → 交给方案 A
cx = int(round(brow_center_x))
# 在中轴线附近取一个窄列带(±3px)求稳,避免单列噪声
band = hair_mask[:, max(0, cx - 3): cx + 4]
col = band.any(axis=1) # 每一行在该列带是否有头发
hair_rows = np.where(col)[0]
if hair_rows.size == 0:
return None
# 发际线 = 中轴线上「头发→皮肤」交界:即该列带头发像素中最靠下的连续头发块的下沿
# 简化:取中轴线列上头发区域的最大 y(向下为正)作为发际线
hairline_y = int(hair_rows.max())
# 头顶 = 整张头发 mask 的最高点(最小 y),更鲁棒地用全图而非单列
top_rows = np.where(hair_mask.any(axis=1))[0]
hair_top_y = int(top_rows.min())
# 合理性校验:头顶必须在发际线上方
if hair_top_y >= hairline_y:
return None
return hairline_y, hair_top_y
```
> 实际实现可对 mask 先做轻量形态学开运算去噪;发际线判定可改为"沿中轴线从上往下首次出现的 hair→non-hair 跳变",比单纯取 max 更贴合带刘海/碎发场景。具体阈值在拿到测试集后调。
### 4.1 方案 A(兜底)核心思路
> 仅当方案 B 不可用时启用。**注意其循环论证局限:顶上两庭为估算值,不反映真实脸型。**
MediaPipe 可以精确检测 **眉心、鼻翼下缘、下巴尖** 三个关键点(均位于面部中轴线)。利用「三庭五眼」标准比例,向上推算发际线和头顶位置。
### 4.2 比例参数
根据需求文档中的 Mock 数据反推(顶庭:上庭:中庭:下庭 = 22%:25%:28%:25%),以及经典三庭五眼理论(三庭等分),定义两套可选参数:
```
方案比例(基于Mock数据):
顶庭 : 上庭 : 中庭 : 下庭 = 0.22 : 0.25 : 0.28 : 0.25
经典三庭比例(上庭=中庭=下庭):
上庭 : 中庭 : 下庭 = 1 : 1 : 1
顶庭 ≈ 0.2 × 全脸高度(通过统计)
```
实际采用混合策略:**以实测中庭和下庭为基准,按标准比例推算上庭和顶庭**。
### 4.3 推算公式
```python
def estimate_vertical_landmarks(landmarks, image_width, image_height):
"""
输入: MediaPipe 468 landmarks + 图像尺寸
输出: 5 个关键点像素坐标 + 各段像素距离
"""
# --- 1. 提取可直接检测的关键点 ---
# 眉心 (glabella):索引 9 和 151 的中点
glabella_9 = normalized_to_pixel(landmarks[9], image_width, image_height)
glabella_151 = normalized_to_pixel(landmarks[151], image_width, image_height)
brow_center_y = (glabella_9[1] + glabella_151[1]) / 2
brow_center_x = (glabella_9[0] + glabella_151[0]) / 2
# 鼻翼下缘 (subnasale):索引 94
nose_bottom = normalized_to_pixel(landmarks[94], image_width, image_height)
# 下巴尖 (menton):索引 152
chin_tip = normalized_to_pixel(landmarks[152], image_width, image_height)
# --- 2. 计算实测段长度 (像素) ---
middle_court_px = abs(brow_center_y - nose_bottom[1]) # 眉心 → 鼻翼下缘
lower_court_px = abs(nose_bottom[1] - chin_tip[1]) # 鼻翼下缘 → 下巴尖
# --- 3. 推算上庭和顶庭 ---
# 以中庭和下庭的平均值作为基准"一等份"(减小个体差异)
one_unit_px = (middle_court_px + lower_court_px) / 2 # 一等份 ≈ 中庭/下庭的平均
# 上庭 ≈ 一等份(经典三庭等分)或根据实际中庭比例微调
upper_court_px = one_unit_px * (0.25 / 0.265) # 上庭 25% vs 中庭/下庭平均 26.5%
# 顶庭 ≈ 中庭 × (22%/28%) 或 ≈ 0.79 × one_unit_px
top_court_px = one_unit_px * (0.22 / 0.28) # 约 0.786 × one_unit_px
# --- 4. 推算头顶和发际线 Y 坐标 ---
hairline_y = brow_center_y - upper_court_px
hair_top_y = hairline_y - top_court_px
# --- 5. 计算全脸总高度 ---
face_total_height_px = hair_top_y - chin_tip[1] # 注意 Y 轴方向(向下为正)
return {
"hair_top": (brow_center_x, hair_top_y),
"hairline": (brow_center_x, hairline_y),
"brow_center": (brow_center_x, brow_center_y),
"nose_bottom": (nose_bottom[0], nose_bottom[1]),
"chin_tip": (chin_tip[0], chin_tip[1]),
# 各段像素高度
"top_court_px": top_court_px,
"upper_court_px": upper_court_px,
"middle_court_px": middle_court_px,
"lower_court_px": lower_court_px,
"face_total_height_px": face_total_height_px,
}
```
### 4.4 像素 → 厘米转换
```python
def pixels_to_cm(vertical_result, px_per_cm):
"""将像素距离转为厘米"""
return {
"top_court_cm": vertical_result["top_court_px"] / px_per_cm,
"upper_court_cm": vertical_result["upper_court_px"] / px_per_cm,
"middle_court_cm": vertical_result["middle_court_px"] / px_per_cm,
"lower_court_cm": vertical_result["lower_court_px"] / px_per_cm,
"face_total_height_cm": vertical_result["face_total_height_px"] / px_per_cm,
}
```
---
## 5. 七眼测量实现
七眼测量全部基于可直接检测的关键点(无需推算),精度较好。
```python
def measure_seven_eyes(landmarks, image_width, image_height):
"""
测量眼宽、脸宽、两眼间距(像素)
返回像素值,后续通过 px_per_cm 转为厘米
"""
# 左眼外/内角
left_outer = normalized_to_pixel(landmarks[33], image_width, image_height)
left_inner = normalized_to_pixel(landmarks[133], image_width, image_height)
# 右眼内/外角
right_inner = normalized_to_pixel(landmarks[362], image_width, image_height)
right_outer = normalized_to_pixel(landmarks[263], image_width, image_height)
# 脸宽
left_cheek = normalized_to_pixel(landmarks[234], image_width, image_height)
right_cheek = normalized_to_pixel(landmarks[454], image_width, image_height)
eye_width_px = pixel_distance(left_outer, left_inner) # 左眼宽(也可用右眼或平均)
right_eye_width_px = pixel_distance(right_inner, right_outer)
avg_eye_width_px = (eye_width_px + right_eye_width_px) / 2
inter_eye_px = pixel_distance(left_inner, right_inner) # 两眼间距
face_width_px = pixel_distance(left_cheek, right_cheek) # 脸宽
return {
"eye_width_px": avg_eye_width_px,
"face_width_px": face_width_px,
"inter_eye_distance_px": inter_eye_px,
}
```
### 占比计算
```python
# 七眼比例(眼宽/脸宽,间距/脸宽)
eye_width_ratio = eye_width_px / face_width_px
inter_eye_ratio = inter_eye_px / face_width_px
# 四庭比例(各段 / 全脸总高)
for court in ["top", "upper", "middle", "lower"]:
ratios[f"{court}_court"] = result[f"{court}_court_px"] / face_total_height_px
```
---
## 6. 标注图片生成
需求要求输出**仅包含标注图层、不含人物**的 PNG 图片,规格如下:
| 项目 | 要求 |
|------|------|
| 字体色 / 线色 | `#FFFFFF` 100% |
| 字体 | PingFangSC-Regular 10pt |
| 线宽 | 1pt |
| 四庭数值位置 | 图片**左侧** |
| 七眼间距数值 | **上下穿插**展示 |
| 横线/竖线 | 渐变消失 |
| 虚线 | 两侧带箭头 |
### 实现方案
使用 **Pillow (PIL)**`ImageDraw` 生成透明底 PNG,画布尺寸与输入原图一致。
```python
from PIL import Image, ImageDraw, ImageFont
import math
def create_annotated_image(input_image_path, vertical_result, eye_result, px_per_cm):
"""生成标注图层 PNG(透明底,仅标注)"""
# 读取原图获取尺寸
original = Image.open(input_image_path)
width, height = original.size
# 创建透明画布 (RGBA, A=0)
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(canvas)
# ⚠️ 字体:PingFangSC 是 macOS 字体,Linux 服务器没有;且 ImageFont.load_default()
# 不渲染中文(会出现方块/空白)。必须随仓库打包一个中文 TTF 并用绝对路径加载。
# 已打包 Noto Sans CJK SC= 思源黑体,同一套字体):face_analysis/fonts/NotoSansCJKsc-Regular.otf
FONT_PATH = os.path.join(os.path.dirname(__file__), "fonts", "NotoSansCJKsc-Regular.otf")
font = ImageFont.truetype(FONT_PATH, 10) # 字体缺失时直接抛错,避免静默降级成乱码
line_color = (255, 255, 255, 255) # #FFFFFF 100%
line_width = 1 # 1pt
# --- 1. 绘制四庭水平分界线(渐变消失效果) ---
courts = [
("hair_top", vertical_result["hair_top"]),
("hairline", vertical_result["hairline"]),
("brow_center", vertical_result["brow_center"]),
("nose_bottom", vertical_result["nose_bottom"]),
("chin_tip", vertical_result["chin_tip"]),
]
for name, (cx, cy) in courts:
# 绘制从中心向两侧渐变的水平线
draw_gradient_horizontal_line(draw, cx, cy, width, line_color, line_width)
# --- 2. 绘制四庭数值(左侧标注) ---
court_values = [
("顶庭", vertical_result["top_court_px"] / px_per_cm),
("上庭", vertical_result["upper_court_px"] / px_per_cm),
("中庭", vertical_result["middle_court_px"] / px_per_cm),
("下庭", vertical_result["lower_court_px"] / px_per_cm),
]
left_margin = 20
for i, (label, cm_val) in enumerate(court_values):
# 标注在对应段落中间高度
y_start = courts[i][1][1]
y_end = courts[i+1][1][1]
y_mid = (y_start + y_end) / 2
text = f"{label} {cm_val:.2f}cm"
draw.text((left_margin, y_mid), text, fill=line_color, font=font)
# --- 3. 绘制七眼标注(上下穿插) ---
# 眼宽标注在上方,间距标注在下方
# (具体位置根据实际坐标布局)
# ... (详细绘制逻辑见完整实现)
# --- 4. 绘制虚线箭头 ---
# 在分界点位置绘制水平虚线,两端带箭头
return canvas
```
### 渐变线实现
> ⚠️ **性能**:逐像素 `draw.point` 在大图上极慢(每条线几百次 Python 调用,多条线 × 高分辨率图肉眼可感卡顿)。用 numpy 向量化生成一行渐变像素后整行写入,快几个数量级:
```python
import numpy as np
def draw_gradient_horizontal_line(canvas: Image.Image, cx, cy, color, half_length=None):
"""以 (cx, cy) 为中心,向两侧绘制渐变消失的水平线(numpy 向量化)"""
arr = np.asarray(canvas) # RGBA, H×W×4
h, w = arr.shape[:2]
cy = int(round(cy)); cx = int(round(cx))
if not (0 <= cy < h):
return
half = half_length or (w // 3)
xs = np.arange(w)
dist = np.abs(xs - cx)
alpha = np.clip(1.0 - dist / half, 0.0, 1.0) * color[3] # 线性衰减,超出 half 为 0
mask = alpha > 0
row = arr[cy]
row[mask, 0], row[mask, 1], row[mask, 2] = color[0], color[1], color[2]
# 与已有 alpha 取较大值,避免覆盖其它线条
row[mask, 3] = np.maximum(row[mask, 3], alpha[mask].astype(np.uint8))
# 注意:需用可写数组(np.array(canvas) 复制),处理完用 Image.fromarray 写回画布
```
> 实现时建议全程在一个 `np.zeros((h, w, 4), uint8)` 缓冲区上画线,最后 `Image.fromarray` 一次性转回,再用 `ImageDraw` 画文字/箭头。
### 虚线带箭头
```python
def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color, dash_len=6, gap_len=4):
"""两点间画虚线,两端带箭头"""
total_len = ((x2 - x1)**2 + (y2 - y1)**2) ** 0.5
if total_len == 0:
return
dx = (x2 - x1) / total_len
dy = (y2 - y1) / total_len
# 画虚线
pos = 0
while pos < total_len:
seg_end = min(pos + dash_len, total_len)
draw.line([
(x1 + dx * pos, y1 + dy * pos),
(x1 + dx * seg_end, y1 + dy * seg_end)
], fill=color, width=1)
pos += dash_len + gap_len
# 两端箭头 (等腰三角形)
arrow_size = 6
# 左端箭头...
# 右端箭头...
```
> 标注图片的具体视觉样式建议在实现后根据实际效果微调,特别是虚线箭头的方向和位置。
---
## 7. 整体处理流程
```
输入图片
┌─────────────────────────────────────┐
│ 1. 预处理 │
│ - 校验格式 (JPG/PNG) │
│ - 校验分辨率 (短边≥600 长边≥800, 可配置)
│ - 校验文件大小 (≤ 1MB) │
│ - 校验人脸数量 (仅单人) │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 2. MediaPipe 推理 │
│ - FaceMesh(static_image_mode=True,
│ max_num_faces=1,
│ refine_landmarks=True) │
│ - 输出: 468+10 关键点 │
│ - 无人脸 → 1001 │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 3. 姿态校验 (solvePnP) │
│ - 解算 yaw/pitch/roll │
│ - 超阈值 → 1003 (非正面照) │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 4. 关键点提取 + 发际线/头顶定位 │
│ - 横向: 眼宽/脸宽/两眼间距(实测) │
│ - 中/下庭: 眉心/鼻翼/下巴 (实测) │
│ - 上/顶庭: 方案B分割(主)→A推算(兜底)│
│ 记录 hairline_source │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 5. 尺度校准 │
│ - 虹膜直径法: px_per_cm 估算 │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 6. 计算与生成 │
│ - 像素 → 厘米 │
│ - 计算占比 │
│ - 生成标注图层 PNG │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 7. 输出 │
│ - annotated_image_url (标注PNG) │
│ - face_total_height_cm │
│ - four_courts (含cm & ratios) │
│ - seven_eyes (含cm & ratios) │
│ - landmarks (5个点原图像素坐标) │
│ - hairline_source ("segmentation"│
│ / "estimated") │
│ - head_pose (yaw/pitch/roll) │
└─────────────────────────────────────┘
```
---
## 8. 关键代码骨架
### 8.1 目录结构建议
```
hair/
├── app.py # 现有 FastAPI 应用
├── face_analysis/
│ ├── __init__.py
│ ├── detector.py # MediaPipe Face Mesh 封装
│ ├── hair_segmenter.py # 方案 BBiSeNet 头发分割封装
│ ├── pose.py # solvePnP 头部姿态估计 + 正面校验
│ ├── measure.py # 四庭七眼测量逻辑(整合方案 B/A)
│ ├── calibration.py # px→cm 尺度校准(虹膜法)
│ ├── annotation.py # 标注图片生成(numpy 渐变线 + 中文字体)
│ ├── face_mesh_landmarks.py # 关键点索引常量
│ ├── fonts/
│ │ └── NotoSansCJKsc-Regular.otf # 打包的中文字体(= 思源黑体)
│ └── weights/ # 模型权重(不入 git,部署脚本拉取)
│ ├── 79999_iter.pth # BiSeNet face-parsing 权重 ~53MB
│ └── resnet18-5c106cde.pth # BiSeNet 骨干权重 ~45MB(离线必需,见下)
├── static/
│ └── annotations/ # 生成的标注 PNG 存放目录
├── .gitignore # 忽略 face_analysis/weights/*.pth
└── requirements.txt
```
### 8.2 MediaPipe 封装 (`detector.py`)
```python
import mediapipe as mp
import cv2
import numpy as np
mp_face_mesh = mp.solutions.face_mesh
class FaceMeshDetector:
"""MediaPipe Face Mesh 封装,单例模式"""
def __init__(self):
self.face_mesh = mp_face_mesh.FaceMesh(
static_image_mode=True,
max_num_faces=1, # 仅检测单人
refine_landmarks=True, # 启用虹膜 + 唇部精细关键点
min_detection_confidence=0.5,
)
def detect(self, image: np.ndarray) -> list | None:
"""
检测人脸关键点
Args:
image: BGR numpy array (OpenCV 格式)
Returns:
landmarks: NormalizedLandmarkList,或 None
"""
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
results = self.face_mesh.process(rgb)
if results.multi_face_landmarks:
return results.multi_face_landmarks[0] # 第一个人脸
return None
def close(self):
self.face_mesh.close()
# 全局单例
detector = FaceMeshDetector()
```
### 8.3 FastAPI 集成
```python
# 在 app.py 中集成
from face_analysis.measure import measure_face
from face_analysis.annotation import create_annotated_image
import cv2
import numpy as np
from io import BytesIO
@app.post("/api/v1/face/measure")
async def face_measure(image_file: UploadFile = File(...)):
# 1. 读取图片
contents = await image_file.read()
# 2. 校验
if len(contents) > 1_000_000:
return err(1006, "文件超出 1 MB 限制")
nparr = np.frombuffer(contents, np.uint8)
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if image is None:
return err(1008, "图片格式不支持")
h, w = image.shape[:2]
# ⚠️ 竖拍人像通常 w=1080, h=1920;不要把 w/h 写反导致竖图被全部拒绝。
# 用「短边/长边」判断,方向无关,竖拍横拍都兼容。
# 门槛可配置(环境变量),默认放宽到 600/800 以适配真实用户上传图。
min_short = int(os.getenv("MIN_SHORT_SIDE", "600"))
min_long = int(os.getenv("MIN_LONG_SIDE", "800"))
short_side, long_side = min(w, h), max(w, h)
if short_side < min_short or long_side < min_long:
return err(1002, "人像分辨率过低")
# 3. 人脸检测
landmarks = detector.detect(image)
if landmarks is None:
return err(1001, "无法识别人像")
# 4. 测量计算
result = measure_face(landmarks, w, h)
# 5. 生成标注图
annotated = create_annotated_image(image, result)
buf = BytesIO()
annotated.save(buf, format="PNG")
# ... 保存并返回 URL
return ok(result.to_response())
```
---
## 9. 误差分析与局限
| 误差来源 | 影响范围 | 估算误差 | 缓解措施 |
|----------|----------|----------|----------|
| 头顶/发际线推算 | 顶庭、上庭 cm 值 | ±15% | 基于实测中庭下庭比例自适应 |
| 虹膜直径个体差异 | 所有 cm 值 | ±5% | 左右眼平均;未来可接性别/年龄修正 |
| 非正面照 | 所有横向测量 | ±20% | 前置校验偏航角(yaw),过大则返回 1003 |
| 相机畸变 | 边缘区域坐标 | ±3% | 假设普通手机拍照,畸变可控 |
| 人脸比例个体差异 | 推算的发际线/头顶 | ±10% | 无完美解决方案,方案 A 的自然局限 |
**前置姿态校验**(检测是否为正面照):
> 旧版本靠「双眼 y 差 + 鼻尖偏移」的经验阈值(0.03/0.08),不可解释、难调。**改用 `cv2.solvePnP` 解算真实头部欧拉角(yaw/pitch/roll,单位:度)**,阈值就能写成业务可读的"yaw>15° 拒绝",并把角度返回给前端做拍照引导。
```python
import cv2
import numpy as np
# 通用 3D 头部模型(单位 mm,近似),与下方 MediaPipe 索引一一对应
_MODEL_POINTS = np.array([
(0.0, 0.0, 0.0), # 鼻尖 -> 1(或 4
(0.0, -63.6, -12.5), # 下巴 -> 152
(-43.3, 32.7, -26.0), # 左眼外角 -> 33
(43.3, 32.7, -26.0), # 右眼外角 -> 263
(-28.9, -28.9, -24.1), # 左嘴角 -> 61
(28.9, -28.9, -24.1), # 右嘴角 -> 291
], dtype=np.float64)
_PNP_IDX = [1, 152, 33, 263, 61, 291]
def estimate_head_pose(landmarks, image_width, image_height):
"""返回 (yaw, pitch, roll) 角度。solvePnP 失败返回 None。"""
image_points = np.array([
(landmarks[i].x * image_width, landmarks[i].y * image_height)
for i in _PNP_IDX
], dtype=np.float64)
focal = image_width # 近似焦距
cam_matrix = np.array([[focal, 0, image_width / 2],
[0, focal, image_height / 2],
[0, 0, 1]], dtype=np.float64)
dist = np.zeros((4, 1)) # 假设无畸变
ok, rvec, tvec = cv2.solvePnP(_MODEL_POINTS, image_points, cam_matrix, dist,
flags=cv2.SOLVEPNP_ITERATIVE)
if not ok:
return None
rot, _ = cv2.Rodrigues(rvec)
sy = (rot[0, 0] ** 2 + rot[1, 0] ** 2) ** 0.5
pitch = np.degrees(np.arctan2(-rot[2, 0], sy))
yaw = np.degrees(np.arctan2(rot[1, 0], rot[0, 0]))
roll = np.degrees(np.arctan2(rot[2, 1], rot[2, 2]))
return yaw, pitch, roll
def check_frontal_face(landmarks, image_width, image_height,
yaw_thr=15, pitch_thr=15, roll_thr=15):
"""正面照判定:yaw/pitch/roll 均在阈值内才算正面。阈值待测试集标定。"""
pose = estimate_head_pose(landmarks, image_width, image_height)
if pose is None:
return True # 解算失败时不拦截,交由后续逻辑
yaw, pitch, roll = pose
return abs(yaw) <= yaw_thr and abs(pitch) <= pitch_thr and abs(roll) <= roll_thr
```
> 上面 `_MODEL_POINTS` 是常用近似头模,索引/坐标可在测试阶段微调。阈值 15° 为初始值,按 §11 收集的测试数据标定。
---
## 10. 依赖与版本
```
# requirements.txt 新增
mediapipe==0.10.14 # 经典 Solutions API(模型内置,无需额外下载)
opencv-python==4.10.0 # 图片读取、处理、solvePnP 姿态估计
Pillow==11.0.0 # 标注图生成(PNG 透明图层)
numpy==1.26.4 # ⚠️ 必须 <2,否则 mediapipe 0.10.x import 崩溃
# 方案 B:头发分割(BiSeNet face-parsing
torch==2.2.2 # CPU 版即可:pip install torch --index-url https://download.pytorch.org/whl/cpu
torchvision==0.17.2
```
> ⚠️ **numpy 锁版本**mediapipe 0.10.x 对 numpy 2.x 支持不稳定,务必锁 `numpy<2`(已验证 1.26.4 可用)。先用此组合跑通,再考虑升级。
>
> ⚠️ **torch 体积**CPU 版 torch ~200MB,是本接口最大的依赖。若服务器资源紧张或不想引入 torch,可改用 SegFormer-b0onnxruntime 推理,体积更小),或先只上线方案 A、把方案 B 作为第二期。
> MediaPipe 0.10.x 的经典 Solutions API (`mp.solutions.face_mesh`) 仍稳定可用。如需迁移到 Tasks API,后续可平滑升级。
---
## 11. 待确认事项
1. **标注图片设计稿**:需求文档提到需要设计稿确认,当前 UI 规范(字体/颜色/线宽)按文档实现,后续可能需要根据设计师反馈微调
2. **男女比例差异**:是否需要在 cm 换算中区分性别(男女脸宽均值不同)?当前使用虹膜直径法天然与性别无关
3. **顶庭占比**:22% 为 Mock 数据值,实际部署后是否根据用户反馈调整比例参数
4. **非正面照角度阈值**:具体多少度算「角度过大」?建议前期收集测试数据后定阈值
---
> **文档版本**: v2.0
> **创建日期**: 2026-06-13v2.0 修订:修复循环论证/分辨率/字体/numpy 等问题,引入方案 B 分割 + solvePnP 姿态)
> **依赖模型**: MediaPipe Face Mesh (468 landmarks) + BiSeNet face-parsing (头发分割)
> **测量策略**: 眉心以下实测关键点 + 方案 B 分割取真实发际线/头顶(方案 A 比例推算兜底)
+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
```
+229 -55
View File
@@ -15,6 +15,7 @@
| 接口 | 方法 | 路径 |
|------|------|------|
| 1 四庭七眼测量 | POST | `/api/v1/face/measure` |
| 6 四庭七眼测量 v2 | POST | `/api/v1/face/measure-v2` |
| 2 C 端生发 | POST | `/api/v1/hair/grow` |
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
| 4 用户特征 | POST | `/api/v1/face/features` |
@@ -80,11 +81,12 @@
| 1001 | 无法识别人像 | 图片中未检测到人脸 |
| 1002 | 人像分辨率过低 | 低于最低分辨率要求 |
| 1003 | 角度问题,非正面照 | 非正面 / 角度过大 |
| 1004 | 性别标签判断异常 | 男女标签无法判定 **【待确认】** 是否作为错误 |
| 1004 | gender 必填/非法 | 接口2/5 的 `gender` 缺失或非 `male`/`female` |
| 1005 | 检测到多张人脸 | 默认仅支持单人,检测到 2 人或以上时返回 |
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
| 1008 | 图片格式不支持 | 非 JPG / PNG |
| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token``/api/*` 路径鉴权) |
---
@@ -107,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`(四庭,自上而下):
@@ -126,18 +130,26 @@
| face_width_cm | number | 脸宽(cm |
| inter_eye_distance_cm | number | 两眼间距(cm |
| ratios | object | 七眼各段占脸宽的比例 |
| eye1 | number \| null | 从左到右第 1 段宽度(cm):人头最左 → 左脸颊(左耳外侧段)。该侧耳朵不可见时为 null |
| eye2 | number | 从左到右第 2 段宽度(cm):左脸颊 → 左眼外角 |
| eye3 | number | 从左到右第 3 段宽度(cm):左眼外角 → 左眼内角(左眼宽度) |
| eye4 | number | 从左到右第 4 段宽度(cm):左眼内角 → 右眼内角(两眼间距) |
| eye5 | number | 从左到右第 5 段宽度(cm):右眼内角 → 右眼外角(右眼宽度) |
| eye6 | number | 从左到右第 6 段宽度(cm):右眼外角 → 右脸颊 |
| eye7 | number \| null | 从左到右第 7 段宽度(cm):右脸颊 → 人头最右(右耳外侧段)。该侧耳朵不可见时为 null |
> `eye1`~`eye7` 为从左到右共 7 段宽度,与标注图竖线一一对应。最左/最右端线取自耳朵分割外缘;某侧耳朵被头发或侧脸遮挡(不可见)时该侧端线省略,对应 `eye1` 或 `eye7` 为 `null`(键始终保留),实际有效段为 5 或 6 段。`eye3`/`eye5` 为左右眼宽、`eye4` 为两眼间距,与 `eye_width_cm` / `inter_eye_distance_cm` 语义一致。
### 标注图片(UI)规范
| 项目 | 要求 |
|------|------|
| 字体及线颜色 | `#FFFFFF` 100% |
| 数值排布 | 四庭数值统一在图片**左侧**呈现;七眼间距**上下穿插**展示 |
| 字体 | PingFangSC-Regular,字号 10pt |
| 线 | 横线、竖线、虚线均为 1pt |
| 线样式 | 横线、竖线渐变消失;虚线两侧呈现箭头 |
> **【待确认】** 标注图片需提供设计稿后才能最终确定样式。
| 颜色 | 字体及所有线/箭头 `#FFFFFF` 100%,透明底 |
| 尺寸 | 字号/线宽/虚线/箭头按图片**短边自适应缩放**(非固定 pt) |
| 横线 | 5 条分界线(头顶/发际线/眉心/鼻翼下缘/下巴尖),两端渐变消失并略超出最外侧竖线;线名在线**右上方** |
| 线 | 人头最左 + 七眼 6 点 + 人头最右(最外两条取自头发分割轮廓),两端渐变消失并略超出头顶/下巴 |
| 数值排布 | 四庭数值(名 + 数值两行,**不带 cm**)统一在图片**左侧**呈现;七眼段宽**上下穿插**展示;底部统一标「单位cm」 |
| 线样式 | 段宽/庭高用**虚线 + 实心三角双箭头**标示(箭头尖端落在虚线两端) |
### 响应示例(当前 Mock 返回值)
@@ -160,7 +172,9 @@
"eye_width_cm": 3.44,
"face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44,
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 }
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 },
"eye1": 3.44, "eye2": 3.44, "eye3": 3.44, "eye4": 3.44,
"eye5": 3.44, "eye6": 3.44, "eye7": 3.44
},
"landmarks": {
"hair_top": { "x": 540, "y": 120 },
@@ -175,9 +189,72 @@
---
## 接口 6:四庭七眼测量 v2 接口
**说明**:基于[接口 1](#接口-1四庭七眼测量标注接口)的变体。与接口 1 的差异:
- **去顶庭**:不画头顶横线、不返回顶庭数据。`four_courts` 仅含上/中/下庭,`landmarks``hair_top``face_total_height_cm` 为三庭之和(不含顶庭)。
- **竖线范围**:纵向竖线从**发际线**画到**下巴尖**(接口 1 为头顶→下巴尖)。
- **不画人头最左/最右端线**:仅画七眼 6 点(左脸颊/左眼外角/左眼内角/右眼内角/右眼外角/右脸颊)共 5 段标尺,不取头发轮廓的头部端线(接口 1 会多出最左/最右 2 条头部端线、共 7 段)。
- 其余(实心三角箭头、虚线样式、字体、单位cm、七眼数据)与接口 1 一致。
**请求**`POST /api/v1/face/measure-v2`
### 输入
与接口 1 完全相同。图片参数见「通用约定 → 图片传参字段」(`image_file` / `image_url` / `image_base64` 三选一)。本接口无其他专属参数。
### 输出(data
| 字段 | 类型 | 说明 |
|------|------|------|
| annotated_image_url | string | 标注图层 PNG URL(透明底,仅标注线/文字,不含人物) |
| face_total_height_cm | number | 面部总高度(cm)= 上庭 + 中庭 + 下庭(**不含顶庭**) |
| 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 有)。
### 响应示例
```json
{
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"annotated_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
"face_total_height_cm": 10.32,
"four_courts": {
"upper_court_cm": 3.44, "middle_court_cm": 3.44, "lower_court_cm": 3.44,
"ratios": { "upper_court": 0.333, "middle_court": 0.333, "lower_court": 0.333 }
},
"seven_eyes": {
"eye_width_cm": 3.44, "face_width_cm": 24.08, "inter_eye_distance_cm": 3.44,
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 },
"eye2": 3.0, "eye3": 3.44, "eye4": 3.44, "eye5": 3.44, "eye6": 3.0
},
"landmarks": {
"hairline": { "x": 540, "y": 430 },
"brow_center": { "x": 540, "y": 740 },
"nose_bottom": { "x": 540, "y": 1050 },
"chin_tip": { "x": 540, "y": 1360 }
}
}
}
```
---
## 接口 2C 端生发接口
**说明**:输入用户正面照,输出生发后的图片,以及推荐的发际线(可能多张),并按合适度排序
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染发际线曲线透明 PNG + 生发图
> **每个方案返回两张图**`image_url`=「发际线曲线**透明 PNG**(仅白色曲线,透明底,需叠加原图显示)」;`grown_image_url`=
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」(完整人像照片)。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。
**请求**`POST /api/v1/hair/grow`
@@ -187,19 +264,30 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false。提供该开关 |
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
| 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 的文本 |
### 输出(data
`results`:发际线方案数组(可能多张),每个元素:
`results`:发际线方案数组**数量 = 所选发型数**。每个元素:
| 字段 | 类型 | 说明 |
|------|------|------|
| image_url | string | 生发后图片 URL |
| hairline_type | string | 图片对应的发际线形(如:花瓣形、波浪形 |
| order | int | 排序序号(1 = 最优,2 次之 … |
| image_url | string | 发际线曲线**透明 PNG** URL(仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示) |
| grown_image_url | string | **生发后图片** URLComfyUI/Flux「植发 3 个月」效果图,完整人像照片 |
| hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`male |
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
### 响应示例(当前 Mock 返回值)
> ⚠️ 生发图由本机 ComfyUIFlux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
> worker 侧返回 `image_base64` / `grown_image_base64`
> 网关落盘后改写为上表的 `image_url` / `grown_image_url`。
>
> 💡 `image_url` 为透明底 PNG,前端需用绝对定位叠加到原图上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface2.html)的 `.img-stack` 叠加结构)。
### 响应示例
```json
{
@@ -208,41 +296,45 @@
"request_id": "mock-request-id",
"data": {
"results": [
{ "image_url": "https://hair.xiangsilian.com/static/sample.jpg", "hairline_type": "花瓣形", "order": 1 },
{ "image_url": "https://hair.xiangsilian.com/static/sample.jpg", "hairline_type": "波浪形", "order": 2 }
{ "image_url": "https://hair.xiangsilian.com/static/annotations/uuid1.png", "grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown1.png", "hairline_type": "ellipse", "order": 1 },
{ "image_url": "https://hair.xiangsilian.com/static/annotations/uuid2.png", "grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown2.png", "hairline_type": "flower", "order": 2 }
]
}
}
```
> 识别失败时返回通用错误码(1001 / 1002 / 1003 等)。
> 识别失败时返回通用错误码(1001 / 1002 / 1003 等)。`gender` 缺失或非法值返回 **1004**;本接口已改为必填入参,不再自动判别性别。
---
## 接口 3B 端生发接口
**说明**输入医生/操作端的「划线图片」(在原图上标注目标发际线),输出最合适的发际线图片 + 生发后图片。
**说明**:医生/操作端在用户照片上用马克笔标注目标发际线后,**只需上传这一张划线图**。系统检测划线 →
据此生成生发后图片。
**请求**`POST /api/v1/hair/grow-b`
### 输入
划线图片同样支持「文件 / URL / base64」三种方式(字段:`marked_image_file` / `marked_image_url` / `marked_image_base64`)。
划线图片支持「文件 / URL / base64」三选一(字段:`marked_image_file` / `marked_image_url` / `marked_image_base64`)。
**不需要原始照片**(划线图本身就是用户照片 + 手绘线)。
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| marked_image_* | file / string | 是 | 已划线(标注发际线的图片,三选一 |
| original_image_* | file / string | 是 | 原始用户照片,需同时上传 |
| marked_image_* | file / string | 是 | 已用马克笔标注发际线的图片,三选一 |
| use_mask | bool | 否 | 是否画发际线,默认 `true``false` 时跳过划线检测、直接送划线图,模型仅凭手绘黑线生发,供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
### 输出(data
| 字段 | 类型 | 说明 |
|------|------|------|
| best_hairline_image_url | string | 最合适的发际线图片 |
| hair_growth_image_url | string | 生发后图片 |
| hairline_type | string | 发际线形,需返回 |
| hair_growth_image_url | string | **生发后图片**(检测划线 → ComfyUI/Flux「植发 3 个月」效果)。worker 返回 `hair_growth_image_base64` |
| hairline_type | string | 发际线形,手绘为定制,固定 `"custom"` |
### 响应示例(当前 Mock 返回值)
> 未检测到划线(或无人脸)返回 **1001**。
### 响应示例
```json
{
@@ -250,9 +342,8 @@
"message": "success",
"request_id": "mock-request-id",
"data": {
"best_hairline_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
"hair_growth_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
"hairline_type": "花瓣形"
"hair_growth_image_url": "https://hair.xiangsilian.com/static/annotations/grown.png",
"hairline_type": "custom"
}
}
```
@@ -261,7 +352,7 @@
## 接口 4:用户特征接口
**说明**:输入用户照片,输出 N 个用户特征字段
**说明**:输入用户照片,由**火山方舟 豆包视觉模型**`doubao-seed-1-6-vision`)分析,输出一大批面部特征
**请求**`POST /api/v1/face/features`
@@ -271,18 +362,21 @@
### 输出(data
`data` 直接返回一个 **JSON 字符串**`features`),其内部字段不固定、可随时调整,由业务方约定。当前优先返回的字段如下(仅作示例,最终以实际返回为准)
`data.features`一个 **JSON 字符串**不是对象,客户端 `JSON.parse()` 后用),**仅含以下 6 个英文字段**
| 字段 | 类型 | 说明 |
|------|------|------|
| face_shape | string | 脸形 |
| eyebrow_shape | string | 眉形 |
| facial_age | int | 面部年龄 |
| dynamic_static_type | string | 动静类型 |
| gender | string | 性别 |
| gene_style | object | 面部特征对应面部标签的「基因风格」 |
| 字段 | 说明 |
|------|------|
| face_shape | 脸形(脸型)|
| eyebrow_shape | 眉形 |
| facial_age | 面部年龄(区间字符串,如"18-25岁"|
| dynamic_static_type | 动静类型(静态型/动态型)|
| gender | 性别(男/女)|
| gene_style | 基因风格(如"自然型")|
### 响应示例(当前 Mock 返回值)
> 无人脸返回 `1001`(据 doubao「图片是否有人脸」判定)。⚠️ 本接口是唯一调**外网云模型**的接口,
> worker 需可访问 `ark.cn-beijing.volces.com`API Key 走 worker 配置/环境变量。
### 响应示例
```json
{
@@ -290,38 +384,74 @@
"message": "success",
"request_id": "mock-request-id",
"data": {
"features": "{\"face_shape\": \"鹅蛋脸\", \"eyebrow_shape\": \"柳叶眉\", \"facial_age\": 26, \"dynamic_static_type\": \"静态\", \"gender\": \"女\", \"gene_style\": {\"label\": \"面部特征标签\", \"style\": \"基因风格示例\"}}"
"features": "{\"face_shape\":\"鹅蛋脸\",\"eyebrow_shape\":\"眉\",\"facial_age\":\"18-25岁\",\"dynamic_static_type\":\"静态\",\"gender\":\"女\",\"gene_style\":\"少年型\"}"
}
}
```
> `features` 为字符串形式的 JSON字段后续可随时增删,不固定。
> `features` 为字符串形式的 JSON,固定上述 6 个字段
---
## 接口 5:发际线 PNG 生成接口
**说明**输入用户照片,返回 N 张用户发际线的 PNG 图片,并返回「最合适发际线」的面部中间点坐标。
**说明**入参同接口2(先选性别、再多选发型)。对每个选中发型返回 `middle` / `high` / `low` **三档**发际线叠图与**生发图**,并返回「最合适发际线」的面部中间点坐标。
**请求**`POST /api/v1/hairline/generate`
### 输入
图片参数见「通用约定 → 图片传参字段」。本接口无其他专属参数
图片参数见「通用约定 → 图片传参字段」。专属参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 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 张生发图。
### 输出(data
| 字段 | 类型 | 说明 |
|------|------|------|
| hairline_images | object[] | N 张用户发际线 PNG,**数量 N 不固定**,已按合适度排序,元素见下表 |
| best_hairline_center_point | object | 最合适发际线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
| hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 |
| best_hairline_center_point | object \| null | **首个选中发型**的 **middle 档**发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
| high_hairline_center_point | object \| null | 同上,**high 档**发际线中点(发际线偏高) |
| low_hairline_center_point | object \| null | 同上,**low 档**发际线中点(发际线偏低) |
| face_measure | object \| null | **复用接口1**的四庭七眼测量**数值**(不含标注图)。独立流程,测量失败(无人脸/非正面/分割失败)时为 `null`,不影响发际线主结果。字段结构见下表 |
`hairline_images` 元素:
| 字段 | 类型 | 说明 |
|------|------|------|
| image_url | string | 发际线 PNG 图片 URL |
| order | int | 排序序号(1 = 最合适,依次递增 |
| 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「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null` |
| order | int | 发型序号(= 传入的 hair_style 值) |
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。
>
> 💡 三档 `image_*_url` 为透明底 PNG,前端需用绝对定位叠加到原图上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface5.html)的 `.img-stack` 叠加结构)。`grown_image_url` 是完整人像照片,直接显示即可。
`face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段):
| 字段 | 类型 | 说明 |
|------|------|------|
| face_total_height_cm | number | 全脸总高度(cm= 四庭之和 |
| four_courts | object | 四庭数据(顶/上/中/下庭 cm + 占比 ratios),结构同接口1 |
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + eye1~eye7 从左到右 7 段宽度),结构同接口1 |
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算) |
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
### 响应示例(当前 Mock 返回值)
@@ -332,14 +462,57 @@
"request_id": "mock-request-id",
"data": {
"hairline_images": [
{ "image_url": "https://hair.xiangsilian.com/static/sample.jpg", "order": 1 },
{ "image_url": "https://hair.xiangsilian.com/static/sample.jpg", "order": 2 }
{
"hairline_type": "ellipse",
"image_middle_url": "https://hair.xiangsilian.com/static/annotations/mid1.png",
"image_high_url": "https://hair.xiangsilian.com/static/annotations/high1.png",
"image_low_url": "https://hair.xiangsilian.com/static/annotations/low1.png",
"grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown1.png",
"order": 1
},
{
"hairline_type": "heart",
"image_middle_url": "https://hair.xiangsilian.com/static/annotations/mid3.png",
"image_high_url": "https://hair.xiangsilian.com/static/annotations/high3.png",
"image_low_url": "https://hair.xiangsilian.com/static/annotations/low3.png",
"grown_image_base64": null,
"order": 3
}
],
"best_hairline_center_point": { "x": 540, "y": 430 }
"best_hairline_center_point": { "x": 540, "y": 430 },
"high_hairline_center_point": { "x": 540, "y": 380 },
"low_hairline_center_point": { "x": 540, "y": 480 },
"face_measure": {
"face_total_height_cm": 26.76,
"four_courts": {
"top_court_cm": 5.77, "upper_court_cm": 5.93,
"middle_court_cm": 7.62, "lower_court_cm": 7.44,
"ratios": { "top_court": 0.216, "upper_court": 0.222,
"middle_court": 0.285, "lower_court": 0.278 }
},
"seven_eyes": {
"eye_width_cm": 2.76, "face_width_cm": 15.08,
"inter_eye_distance_cm": 3.9,
"ratios": { "eye_width": 0.183, "inter_eye_distance": 0.259 },
"eye1": null, "eye2": 3.0, "eye3": 2.76, "eye4": 3.9,
"eye5": 2.76, "eye6": 3.0, "eye7": null
},
"landmarks": {
"hair_top": { "x": 504, "y": 103 },
"hairline": { "x": 504, "y": 228 },
"brow_center": { "x": 504, "y": 357 },
"nose_bottom": { "x": 505, "y": 522 },
"chin_tip": { "x": 506, "y": 683 }
},
"hairline_source": "segmentation",
"head_pose": { "yaw": -1.39, "pitch": 2.49, "roll": -0.06 }
}
}
}
```
> 说明:生发失败的元素中,网关不改写 `null` 值,故字段名保持为 `grown_image_base64: null`(有值时才改写为 `grown_image_url`),与接口2生发失败项一致。
---
## 汇总:输入输出一览
@@ -347,10 +520,11 @@
| 接口 | 输入 | 主要输出 |
|------|------|----------|
| 1 四庭七眼测量 | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 |
| 2 C 端生发 | 用户照片 | 生发后图片 + 多张发际线(带类型与排序) |
| 6 四庭七眼测量 v2 | 用户照片 | 同接口1,复刻实现 |
| 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单/多张) |
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
| 4 用户特征 | 用户照片 | N 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格 |
| 5 发际线 PNG | 用户照片 | N 张发际线 PNG + 最合适发际线面部中间点坐标 |
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
---
+146
View File
@@ -0,0 +1,146 @@
# 网关侧改动清单(worker 近期变更引发)
> 给网关开发:以下是 worker/契约近期变化里**与网关有关**的点。标 ✅ 的我已在本仓库 `gateway/`
> 改好(你 review/拉取即可);标 🔲 的是**建议你确认或改**。功能必需只有第 1 条。
---
## 1. ✅【功能必需】base64→URL 落盘扩展名按内容嗅探(支持 JPG)
**背景**:接口 **2/3/5 的返回图改成了 JPG**(体积约小 9×),**接口 1 标注图仍是 PNG**(含透明)。
网关把 `*_base64` 落盘时若**硬编码 `.png`**JPG 会被存成 `.png`(内容是 JPG、扩展名错)。
**改动**`gateway/forward.py``rewrite_base64_to_url`,已改):
```python
# 原:filename = f"{uuid.uuid4().hex}.png"
ext = "png" if img_bytes[:8] == b"\x89PNG\r\n\x1a\n" else "jpg" # 按内容嗅探
filename = f"{uuid.uuid4().hex}.{ext}"
```
> 这样接口1 存 `.png`、接口2/3/5 存 `.jpg`,对外 URL 后缀也就正确。**若你的网关是独立部署/独立代码,按上面这两行改一下即可。**
---
## 2. 🔲【建议】生发接口超时调大
接口 **2(一次 N 张 Flux~18s/ 3~6s** 经 ComfyUI 同步出图较慢。
`gateway/config.json``dispatch.request_timeout_seconds` 建议 **≥ 120**,否则网关会先超时换 worker 重试。
---
## 3. 🔲【确认】base64→URL 通用改写仍覆盖这些场景
- **数组里的图片字段**:接口2 `results[].image_base64` / `results[].grown_image_base64`
接口5 `hairline_images[].image_base64` 在数组元素内——改写要**递归进数组**(你现有的递归实现已覆盖)。
- **可空字段**:接口2/3 的生发图(ComfyUI 没起/失败时)`*_base64`**null** → 保留 null、不落盘。
---
## 4. 🔲【可选·仅影响 /docs】OpenAPI 表单声明
纯文档展示,不影响转发功能(网关是盲转发)。若想让网关 `/docs` 准确:
- 接口2 `/hair/grow`、接口5 `/hairline/generate` 入参**新增必填 `gender`**male/female)。
- 接口3 `/hair/grow-b` 入参**只剩 `marked_image_*`**(已去掉 `original_image_*`)。
- `gateway/app.py` 里的 `_*_FORMS` 字典当前未被路由引用,所以不改也不影响实际行为。)
---
## 5. 🔲【新增】接口7 C端生发 v2`/api/v1/hair/grow-v2`
**背景**:worker 侧已新增接口 7,功能与接口 2 完全一致,区别仅在于 ComfyUI 工作流使用 `add_hair2.json`(而非 `add_hair.json`)。
**网关需新增一个路由**,代理转发到 worker 的同路径:
```python
# gateway/app.py
@app.post("/api/v1/hair/grow-v2", tags=["生发"])
async def hair_grow_v2(request: Request):
"""接口7C端生发 v2add_hair2 工作流)"""
return await _proxy(request, "/api/v1/hair/grow-v2")
```
**无需额外改动**
- 请求:multipart/form-data,参数与接口 2 完全相同(`image_file/url/base64` 三选一 + `gender` + `hair_style` + `beauty_enabled` + `use_mask` + `prompt`),网关盲转发即可
- 响应:结构与接口 2 完全一致,`results[].image_base64` / `results[].grown_image_base64` 经现有 `rewrite_base64_to_url` 自动改写为 URL
- base64→URL:数组内图片字段递归改写已覆盖,无需修改
### 入参(与接口 2 一致)
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| image_file / image_url / image_base64 | — | **三选一** | 用户正面照 |
| gender | string | **是** | `male` / `female` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female: 15male: 14 |
| beauty_enabled | bool | 否 | 美颜开关(本期不生效) |
| use_mask | bool | 否 | 默认 `true``false` 跳过遮罩 |
| prompt | string | 否 | ComfyUI 提示词 |
### 出参(与接口 2 一致)
```json
{
"code": 0,
"message": "success",
"request_id": "gw-xxxxxxxx",
"data": {
"results": [
{
"image_url": "https://hair.xiangsilian.com/static/annotations/xxx.jpg",
"grown_image_url": "https://hair.xiangsilian.com/static/annotations/xxx.jpg",
"hairline_type": "ellipse",
"order": 1
}
]
}
}
```
### worker 侧信息
| 项目 | 值 |
|------|-----|
| worker 路径 | `/api/v1/hair/grow-v2` |
| 工作流文件 | `add_hair2.json` |
| 输入/遮罩节点 | 26LoadImage |
| 输出节点 | 75SaveImage,自动检测) |
| 提示词节点 | 60JjkText |
### 🔲【可选·仅影响 /docs】OpenAPI 表单声明
若想让网关 `/docs` 展示准确,在 `gateway/app.py` 新增 `_GROW_V2_FORMS`(或复用 `_GROW_FORMS` 并补充 `gender`/`hair_style` 字段),然后将路由函数签名改为显式声明 Form 参数(参考接口 4 的写法)。不改也不影响实际转发。
---
## 6. ✅【已完成】接口6 四庭七眼测量 v2`/api/v1/face/measure-v2`
**背景**worker 侧接口 6 是接口 1 的**去顶庭变体**(不再完全一致):不画头顶横线/不返回顶庭数据、竖线范围发际线→下巴尖、不画人头最左/最右端线。worker 侧与接口 1 共用 `_face_measure_impl(variant="v6")`
**网关已新增路由**`gateway/app.py` 已实现,网关机器 pull 后生效):
```python
# gateway/app.py
@app.post("/api/v1/face/measure-v2", tags=["人脸分析"])
async def face_measure_v2(request: Request):
"""接口6:四庭七眼测量 v2(去顶庭 + 去头部端线)"""
return await _proxy(request, "/api/v1/face/measure-v2")
```
**无需额外改动**
- 入参:与接口 1 完全相同(image_file/url/base64 三选一)
- 出参:`annotated_image_base64` → 经现有 `rewrite_base64_to_url` 自动改写为 `annotated_image_url`
- worker 侧 v6 差异(去顶庭字段、变体标注)由 `_face_measure_impl` 内部处理,网关透明转发
---
## 已经做好、无需再动的
- **接口4 在网关本机实现**(调豆包,不转发 worker)——已完成;config 里配 `ark`
- **接口4 业务错误 HTTP 状态**已统一为 200(与其余接口一致)。
- **接口3 去 original / best_hairline**——网关盲转发,无需改(映射表里也没有 best_hairline)。
---
> 对外字段映射总表见 [`实现说明.md`](实现说明.md) §1;契约以 [`接口文档.md`](接口文档.md) 为准。
View File
+392
View File
@@ -0,0 +1,392 @@
"""标注图层生成(透明底 RGBA PNG,仅标注、不含人物)。
规格(技术方案 §6 + img1.png 版本5):线/字色 #FFFFFF、透明底。
- 字号/线宽/虚线/箭头尺寸全部按图片尺寸自适应缩放(大图也清晰)。
- 四庭水平分界线:numpy 向量化渐变消失(中间亮、两侧渐隐)。
- 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右,
把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线双箭头。
人头最左/最右取自耳朵分割外缘,看不到耳朵则省略该侧(最少 6 点 5 段)。
- 四庭:图片左侧,「名」「数值(带 cm)」「百分比」三行换行,带竖向虚线双箭头。
- 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。
- 每段数值直接带 cm 后缀,下方另起一行标百分比(不再单独标底部「单位cm」)。
中文字体用打包的思源黑体绝对路径加载,缺字体直接抛错(不静默降级成方块)。
"""
import os
import numpy as np
from PIL import Image, ImageDraw, ImageFont
FONT_PATH = os.path.join(os.path.dirname(__file__), "fonts", "NotoSansCJKsc-Regular.otf")
LINE_COLOR = (255, 255, 255, 255) # #FFFFFF 100%
def _load_font(size):
if not os.path.isfile(FONT_PATH):
raise FileNotFoundError(f"中文字体缺失:{FONT_PATH}(请按 OFFLINE_ASSETS.md 放置)")
return ImageFont.truetype(FONT_PATH, size)
def draw_gradient_horizontal_line(buf, cx, cy, color=LINE_COLOR, half_length=None, width=1):
"""在 RGBA numpy 缓冲 buf 上,以 (cx,cy) 为中心画向两侧渐变消失的水平线。"""
h, w = buf.shape[:2]
cy = int(round(cy)); cx = int(round(cx))
half = half_length or (w // 3)
xs = np.arange(w)
dist = np.abs(xs - cx)
alpha = np.clip(1.0 - dist / half, 0.0, 1.0) * color[3]
mask = alpha > 0
for off in range(-(width // 2), width - width // 2):
y = cy + off
if not (0 <= y < h):
continue
row = buf[y]
row[mask, 0] = color[0]
row[mask, 1] = color[1]
row[mask, 2] = color[2]
row[mask, 3] = np.maximum(row[mask, 3], alpha[mask].astype(np.uint8))
def draw_gradient_vertical_line(buf, cx, y0, y1, color=LINE_COLOR, fade=None, width=1):
"""在 RGBA numpy 缓冲 buf 上画一条竖线,两端渐变消失(中间实、上下淡)。"""
h, w = buf.shape[:2]
cx = int(round(cx))
y0, y1 = int(round(y0)), int(round(y1))
y0, y1 = max(0, min(y0, y1)), min(h - 1, max(y0, y1))
if y1 <= y0:
return
ys = np.arange(y0, y1 + 1)
span = y1 - y0
fade = fade or max(1, span // 5) # 仅两端 ~1/5 段渐隐
d = np.minimum(ys - y0, y1 - ys) # 到最近端点的距离
alpha = np.clip(d / fade, 0.0, 1.0) * color[3]
m = alpha > 0
for off in range(-(width // 2), width - width // 2):
x = cx + off
if not (0 <= x < w):
continue
col = buf[y0:y1 + 1, x]
col[m, 0] = color[0]
col[m, 1] = color[1]
col[m, 2] = color[2]
col[m, 3] = np.maximum(col[m, 3], alpha[m].astype(np.uint8))
def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color=LINE_COLOR,
dash_len=6, gap_len=4, arrow_size=5, width=1):
"""两点间画稀疏虚线主干,两端用实心三角箭头(尖端精确落在端点,便于对齐)。
虚线只画到「端点向内 arrow_len」处,箭头三角填补剩余,避免虚线穿出箭头。
"""
total = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
if total == 0:
return
dx = (x2 - x1) / total
dy = (y2 - y1) / total
nx, ny = -dy, dx # 法向量
arrow_len = arrow_size * 2.0 # 三角沿线方向长度
arrow_half = arrow_size # 三角底边半宽
# 主干虚线:两端各留出 arrow_len 给箭头
pos = arrow_len
main_end = max(arrow_len, total - arrow_len)
while pos < main_end:
seg_end = min(pos + dash_len, main_end)
draw.line([(x1 + dx * pos, y1 + dy * pos),
(x1 + dx * seg_end, y1 + dy * seg_end)], fill=color, width=width)
pos += dash_len + gap_len
# 两端实心三角箭头:尖端=端点,底边在向内 arrow_len 处展开 ±arrow_half
for (tipx, tipy, ix, iy) in [(x1, y1, dx, dy), (x2, y2, -dx, -dy)]:
bx, by = tipx + ix * arrow_len, tipy + iy * arrow_len
draw.polygon([(tipx, tipy),
(bx + nx * arrow_half, by + ny * arrow_half),
(bx - nx * arrow_half, by - ny * arrow_half)], fill=color)
def _text_size(draw, text, font):
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0], bbox[3] - bbox[1]
_LINE_NAMES = {
"hair_top": "头顶",
"hairline": "发际线",
"brow_center": "眉心",
"nose_bottom": "鼻翼下缘",
"chin_tip": "下巴尖",
}
def _grow_outward(start_col, fg_band, direction, limit):
"""从 start_col 沿 direction(+1 右 / -1 左) 在前景带 fg_band 内逐列外扩。
用于回收被误标成「头发」的外耳轮廓:耳朵被头发遮挡时,外耳轮廓那一圈常被
分割并入头发类,故耳朵掩膜外缘会偏内。这里把外缘沿紧邻的前景(耳∪发)向外
延伸,最多 limit 列;一旦下一列无前景(背景间隙)立即停止,绝不窜到分离的
那缕头发上。返回外扩后的列号。
"""
w = fg_band.shape[1]
c = int(start_col)
for _ in range(int(limit)):
nc = c + direction
if not (0 <= nc < w) or not fg_band[:, nc].any():
break
c = nc
return c
def _ear_edges_from_mask(ear_mask, hair_mask, y0, y1, left_cheek_x, right_cheek_x,
face_center_x):
"""从耳朵分割掩膜取人头最左/最右 x(仅在脸纵向范围 [y0,y1] 内统计)。
左线 = 脸中线左侧耳朵像素的最左列;右线 = 右侧耳朵像素的最右列;再沿紧邻的
前景(耳∪发)按脸宽自适应外扩,回收被误标成头发的外耳轮廓(见 _grow_outward)。
某侧耳朵不可见(被头发/侧脸遮挡 → 掩膜为空),或外缘未越过对应脸颊线(非真实
头宽边缘)时该侧返回 None —— 即「看不到耳朵就不画这条线」。
"""
if ear_mask is None:
return None, None
m = np.asarray(ear_mask)
if m.ndim == 3:
m = m[..., 0]
m = m > 0
h = m.shape[0]
y0 = max(0, int(y0)); y1 = min(h - 1, int(y1))
if y1 <= y0:
return None, None
ear_band = m[y0:y1 + 1]
cols = np.where(ear_band.any(axis=0))[0]
if cols.size == 0:
return None, None
# 前景带 = 耳∪发(外耳轮廓常被误标为发),外扩上限按脸宽自适应
fg_band = ear_band
if hair_mask is not None:
hm = np.asarray(hair_mask)
if hm.ndim == 3:
hm = hm[..., 0]
fg_band = ear_band | (hm[y0:y1 + 1] > 0)
grow = max(2, round(max(1.0, right_cheek_x - left_cheek_x) * 0.045))
left_cols = cols[cols < face_center_x]
right_cols = cols[cols > face_center_x]
# 左/右耳外缘(外扩后),且必须在对应脸颊线外侧(否则视为残缺/噪声,只保留脸颊线)
head_l = head_r = None
if right_cols.size:
edge = _grow_outward(right_cols.max(), fg_band, +1, grow)
head_r = float(edge) if edge >= right_cheek_x else None
if left_cols.size:
edge = _grow_outward(left_cols.min(), fg_band, -1, grow)
head_l = float(edge) if edge <= left_cheek_x else None
return head_l, head_r
def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=None,
variant="v1"):
"""生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。
布局(对齐 img1.png 版本5):
- 纵向竖线:人头最左 + 七眼 6 点 + 人头最右,切 7 段(七眼)。人头最左/最右
取自耳朵分割掩膜的外缘(方案 B,BiSeNet 类 7/8);耳朵不可见(被头发/侧脸
遮挡 → 掩膜空)或无掩膜时省略该侧端线,只画对应脸颊线。
- 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。
- 四庭(顶/上/中/下庭)在左侧:名 + 数值(带 cm) + 百分比三行换行,竖向虚线双箭头。
- 七眼段宽上下交替(上 3 / 下 4):数值(带 cm) 上、百分比(占头宽比)下,横向虚线双箭头。
variant="v6"(接口6):去掉头顶横线与顶庭(只画发际线/眉心/鼻翼下缘/下巴尖 4 条
横线 + 上/中/下庭),竖线纵向范围改为发际线→下巴尖,且不画人头最左/最右端线
(仅七眼 6 点 5 段,不取头部端)。
"""
h, w = image_bgr.shape[:2]
v = measure_result.vertical
pc = measure_result.px_per_cm
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
s = min(w, h)
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)) # 虚线更稠密(间隙<划线)
arrow_size = max(2, round(s * 0.0045)) # 箭头更小
pad = max(4, round(s * 0.012)) # 文字与线的间距
line_h = font_size + max(2, round(font_size * 0.18))
buf = np.zeros((h, w, 4), dtype=np.uint8)
# 发际线弃用(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"]
ys = [v[name][1] for name in order]
pts = measure_result.eyes["points"]
seven_keys = ["left_cheek", "left_outer", "left_inner",
"right_inner", "right_outer", "right_cheek"]
base_xs = [pts[k][0] for k in seven_keys]
if variant == "v6":
xs = sorted(base_xs) # 接口6:仅七眼 6 点,不画人头最左/最右端线
else:
# 人头最左/最右:取自耳朵分割掩膜外缘(方案B,类7/8),脸纵向范围内统计。
# 看不到耳朵(被头发/侧脸遮挡 → 掩膜空)或外缘未越过脸颊线则省略该侧端线。
lcx, rcx = pts["left_cheek"][0], pts["right_cheek"][0]
head_l, head_r = _ear_edges_from_mask(
ear_mask, hair_mask, ys[0], ys[-1], lcx, rcx, (lcx + rcx) / 2)
head_xs = [x for x in (head_l, head_r) if x is not None]
xs = sorted(base_xs + head_xs) # 自左向右(6 或 7/8 点)
# 人脸/人头包围盒
fx0, fx1 = xs[0], xs[-1]
fy0, fy1 = ys[0], ys[-1]
face_cx = (fx0 + fx1) / 2
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
# 竖线纵向范围:v6 = 发际线→下巴尖(不超出);v1(含发际线弃用)= 头顶→下巴尖并两端超出一点
v_top = fy0 if variant == "v6" else fy0 - over
v_bot = fy1 if variant == "v6" else fy1 + over
# --- 1. 横向分界线(渐变,覆盖头宽并超出一点) ---
for cy in ys:
draw_gradient_horizontal_line(buf, face_cx, cy, half_length=face_half, width=line_w)
# --- 2. 纵向竖线(渐变,覆盖 v_top→v_bot ---
for vx in xs:
draw_gradient_vertical_line(buf, vx, v_top, v_bot, width=line_w)
canvas = Image.fromarray(buf, mode="RGBA")
draw = ImageDraw.Draw(canvas)
font = _load_font(font_size)
# --- 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, _ = _text_size(draw, text, font)
x = min(name_x, w - 2 - tw) # 右侧越界时回收
# anchor="lm"x 为左、y 为竖直中点 → 文字中线正好压在横线上(与线对齐)
draw.text((x, ys[i]), text, fill=LINE_COLOR, font=font, anchor="lm")
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
# 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[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}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 - 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) + 百分比 ---
# 每段两行:数值(带 cm) 上、百分比 下;百分比分母 = 整个头宽(七段之和)
txt_off = arrow_size + pad * 2
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
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)
# 数值行在上、百分比行在下;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
if __name__ == "__main__":
import sys
import time
import cv2
from face_analysis.detector import detector
from face_analysis.measure import measure_face
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
out = sys.argv[2] if len(sys.argv) > 2 else "tests/output/annotated.png"
img = cv2.imread(path)
if img is None:
print(f"无法读取图片: {path}")
sys.exit(1)
h, w = img.shape[:2]
lms = detector.detect(img)
if lms is None:
print("未检出人脸")
sys.exit(1)
mask = None
ears = None
try:
from face_analysis.hair_segmenter import get_segmenter
mask, ears = get_segmenter().segment_hair_and_ears(img)
except Exception as e: # noqa: BLE001
print(f"[warn] 分割不可用,回退方案 A:{e}")
result = measure_face(lms, mask, w, h)
t0 = time.time()
canvas = create_annotated_image(img, result, ear_mask=ears, hair_mask=mask)
dt = time.time() - t0
os.makedirs(os.path.dirname(out), exist_ok=True)
canvas.save(out)
arr = np.asarray(canvas)
print(f"saved {out} mode={canvas.mode} size={canvas.size} "
f"transparent={bool((arr[:,:,3]==0).any())} opaque={bool((arr[:,:,3]>0).any())} "
f"elapsed={dt*1000:.1f}ms")
+215
View File
@@ -0,0 +1,215 @@
"""BiSeNet (face-parsing.PyTorch) 网络结构,vendored。
源自 zllrunning/face-parsing.PyTorch,结构与权重 `79999_iter.pth`CelebAMask-HQ
19 类)严格对应,仅修改 resnet18 骨干加载为「优先本地权重」以适配内网离线。
hair 类别索引 = 17。
"""
import os
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.utils.model_zoo as modelzoo
resnet18_url = "https://download.pytorch.org/models/resnet18-5c106cde.pth"
_LOCAL_RESNET18 = os.path.join(os.path.dirname(__file__), "weights", "resnet18-5c106cde.pth")
def conv3x3(in_planes, out_planes, stride=1):
return nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride, padding=1, bias=False)
class BasicBlock(nn.Module):
def __init__(self, in_chan, out_chan, stride=1):
super(BasicBlock, self).__init__()
self.conv1 = conv3x3(in_chan, out_chan, stride)
self.bn1 = nn.BatchNorm2d(out_chan)
self.conv2 = conv3x3(out_chan, out_chan)
self.bn2 = nn.BatchNorm2d(out_chan)
self.relu = nn.ReLU(inplace=True)
self.downsample = None
if in_chan != out_chan or stride != 1:
self.downsample = nn.Sequential(
nn.Conv2d(in_chan, out_chan, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(out_chan),
)
def forward(self, x):
residual = self.conv1(x)
residual = F.relu(self.bn1(residual))
residual = self.conv2(residual)
residual = self.bn2(residual)
shortcut = x
if self.downsample is not None:
shortcut = self.downsample(x)
out = shortcut + residual
out = self.relu(out)
return out
def create_layer_basic(in_chan, out_chan, bnum, stride=1):
layers = [BasicBlock(in_chan, out_chan, stride=stride)]
for _ in range(bnum - 1):
layers.append(BasicBlock(out_chan, out_chan, stride=1))
return nn.Sequential(*layers)
class Resnet18(nn.Module):
def __init__(self):
super(Resnet18, self).__init__()
self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)
self.bn1 = nn.BatchNorm2d(64)
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
self.layer1 = create_layer_basic(64, 64, bnum=2, stride=1)
self.layer2 = create_layer_basic(64, 128, bnum=2, stride=2)
self.layer3 = create_layer_basic(128, 256, bnum=2, stride=2)
self.layer4 = create_layer_basic(256, 512, bnum=2, stride=2)
self.init_weight()
def forward(self, x):
x = self.conv1(x)
x = F.relu(self.bn1(x))
x = self.maxpool(x)
x = self.layer1(x)
feat8 = self.layer2(x) # 1/8
feat16 = self.layer3(feat8) # 1/16
feat32 = self.layer4(feat16) # 1/32
return feat8, feat16, feat32
def init_weight(self):
# 优先本地骨干权重(内网离线),缺失才回退 torch model_zoo(会查缓存)。
if os.path.isfile(_LOCAL_RESNET18):
state_dict = torch.load(_LOCAL_RESNET18, map_location="cpu", weights_only=False)
else:
state_dict = modelzoo.load_url(resnet18_url)
self_state_dict = self.state_dict()
for k, v in state_dict.items():
if "fc" in k:
continue
self_state_dict.update({k: v})
self.load_state_dict(self_state_dict)
class ConvBNReLU(nn.Module):
def __init__(self, in_chan, out_chan, ks=3, stride=1, padding=1):
super(ConvBNReLU, self).__init__()
self.conv = nn.Conv2d(in_chan, out_chan, kernel_size=ks, stride=stride,
padding=padding, bias=False)
self.bn = nn.BatchNorm2d(out_chan)
def forward(self, x):
x = self.conv(x)
x = F.relu(self.bn(x))
return x
class BiSeNetOutput(nn.Module):
def __init__(self, in_chan, mid_chan, n_classes):
super(BiSeNetOutput, self).__init__()
self.conv = ConvBNReLU(in_chan, mid_chan, ks=3, stride=1, padding=1)
self.conv_out = nn.Conv2d(mid_chan, n_classes, kernel_size=1, bias=False)
def forward(self, x):
x = self.conv(x)
x = self.conv_out(x)
return x
class AttentionRefinementModule(nn.Module):
def __init__(self, in_chan, out_chan):
super(AttentionRefinementModule, self).__init__()
self.conv = ConvBNReLU(in_chan, out_chan, ks=3, stride=1, padding=1)
self.conv_atten = nn.Conv2d(out_chan, out_chan, kernel_size=1, bias=False)
self.bn_atten = nn.BatchNorm2d(out_chan)
self.sigmoid_atten = nn.Sigmoid()
def forward(self, x):
feat = self.conv(x)
atten = F.avg_pool2d(feat, feat.size()[2:])
atten = self.conv_atten(atten)
atten = self.bn_atten(atten)
atten = self.sigmoid_atten(atten)
out = torch.mul(feat, atten)
return out
class ContextPath(nn.Module):
def __init__(self):
super(ContextPath, self).__init__()
self.resnet = Resnet18()
self.arm16 = AttentionRefinementModule(256, 128)
self.arm32 = AttentionRefinementModule(512, 128)
self.conv_head32 = ConvBNReLU(128, 128, ks=3, stride=1, padding=1)
self.conv_head16 = ConvBNReLU(128, 128, ks=3, stride=1, padding=1)
self.conv_avg = ConvBNReLU(512, 128, ks=1, stride=1, padding=0)
def forward(self, x):
feat8, feat16, feat32 = self.resnet(x)
h8, w8 = feat8.size()[2:]
h16, w16 = feat16.size()[2:]
h32, w32 = feat32.size()[2:]
avg = F.avg_pool2d(feat32, feat32.size()[2:])
avg = self.conv_avg(avg)
avg_up = F.interpolate(avg, (h32, w32), mode="nearest")
feat32_arm = self.arm32(feat32)
feat32_sum = feat32_arm + avg_up
feat32_up = F.interpolate(feat32_sum, (h16, w16), mode="nearest")
feat32_up = self.conv_head32(feat32_up)
feat16_arm = self.arm16(feat16)
feat16_sum = feat16_arm + feat32_up
feat16_up = F.interpolate(feat16_sum, (h8, w8), mode="nearest")
feat16_up = self.conv_head16(feat16_up)
return feat8, feat16_up, feat32_up # feat8 未用,保持与权重结构一致
class FeatureFusionModule(nn.Module):
def __init__(self, in_chan, out_chan):
super(FeatureFusionModule, self).__init__()
self.convblk = ConvBNReLU(in_chan, out_chan, ks=1, stride=1, padding=0)
self.conv1 = nn.Conv2d(out_chan, out_chan // 4, kernel_size=1, stride=1,
padding=0, bias=False)
self.conv2 = nn.Conv2d(out_chan // 4, out_chan, kernel_size=1, stride=1,
padding=0, bias=False)
self.relu = nn.ReLU(inplace=True)
self.sigmoid = nn.Sigmoid()
def forward(self, fsp, fcp):
fcat = torch.cat([fsp, fcp], dim=1)
feat = self.convblk(fcat)
atten = F.avg_pool2d(feat, feat.size()[2:])
atten = self.conv1(atten)
atten = self.relu(atten)
atten = self.conv2(atten)
atten = self.sigmoid(atten)
feat_atten = torch.mul(feat, atten)
feat_out = feat_atten + feat
return feat_out
class BiSeNet(nn.Module):
def __init__(self, n_classes):
super(BiSeNet, self).__init__()
# 该权重(79999_iter.pth)的变体无独立 SpatialPath
# 直接用 ContextPath 的 resnet feat8128ch)作为空间路径特征。
self.cp = ContextPath()
self.ffm = FeatureFusionModule(256, 256)
self.conv_out = BiSeNetOutput(256, 256, n_classes)
self.conv_out16 = BiSeNetOutput(128, 64, n_classes)
self.conv_out32 = BiSeNetOutput(128, 64, n_classes)
def forward(self, x):
h, w = x.size()[2:]
feat_res8, feat_cp8, feat_cp16 = self.cp(x)
feat_fuse = self.ffm(feat_res8, feat_cp8)
feat_out = self.conv_out(feat_fuse)
feat_out16 = self.conv_out16(feat_cp8)
feat_out32 = self.conv_out32(feat_cp16)
feat_out = F.interpolate(feat_out, (h, w), mode="bilinear", align_corners=True)
feat_out16 = F.interpolate(feat_out16, (h, w), mode="bilinear", align_corners=True)
feat_out32 = F.interpolate(feat_out32, (h, w), mode="bilinear", align_corners=True)
return feat_out, feat_out16, feat_out32
+85
View File
@@ -0,0 +1,85 @@
"""尺度校准:像素 → 厘米(虹膜直径法,眼宽降级)。
人类虹膜直径高度稳定(成人平均 11.7mm),作为天然标尺把像素距离换算成厘米。
虹膜点(索引 469/471、474/476)需 refine_landmarks=True 才输出;缺失时降级用
眼宽(外→内眼角,均值约 2.85cm)。详见技术方案 §3。
"""
from face_analysis.face_mesh_landmarks import (
IRIS_LEFT_LEFT, IRIS_LEFT_RIGHT, IRIS_RIGHT_LEFT, IRIS_RIGHT_RIGHT,
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
)
AVG_IRIS_DIAMETER_CM = 1.17 # 虹膜平均直径 11.7mm
AVG_EYE_WIDTH_CM = 2.85 # 眼裂平均宽度约 28.5mm(降级标尺)
def _lm_list(landmarks):
"""兼容 NormalizedLandmarkList(有 .landmark)与裸 list 两种入参。"""
return landmarks.landmark if hasattr(landmarks, "landmark") else landmarks
def normalized_to_pixel(landmark, image_width, image_height):
"""归一化坐标 → 像素坐标。"""
return landmark.x * image_width, landmark.y * image_height
def pixel_distance(p1, p2):
"""两点像素欧氏距离。"""
return ((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2) ** 0.5
def _iris_diameter_px(lm, w, h):
"""左右虹膜直径像素均值;任一边缘点缺失/为 0 返回 None。"""
try:
ll = normalized_to_pixel(lm[IRIS_LEFT_LEFT], w, h)
lr = normalized_to_pixel(lm[IRIS_LEFT_RIGHT], w, h)
rl = normalized_to_pixel(lm[IRIS_RIGHT_LEFT], w, h)
rr = normalized_to_pixel(lm[IRIS_RIGHT_RIGHT], w, h)
except (IndexError, KeyError):
return None
left_d = pixel_distance(ll, lr)
right_d = pixel_distance(rl, rr)
if left_d <= 0 or right_d <= 0:
return None
return (left_d + right_d) / 2
def _eye_width_px(lm, w, h):
"""左右眼宽(外→内眼角)像素均值,作为虹膜降级标尺。"""
l = pixel_distance(normalized_to_pixel(lm[LEFT_EYE_OUTER], w, h),
normalized_to_pixel(lm[LEFT_EYE_INNER], w, h))
r = pixel_distance(normalized_to_pixel(lm[RIGHT_EYE_OUTER], w, h),
normalized_to_pixel(lm[RIGHT_EYE_INNER], w, h))
return (l + r) / 2
def estimate_scale_factor(landmarks, image_width, image_height):
"""估算 px_per_cm(每厘米对应像素数)。
优先用虹膜直径法;虹膜点不可用时降级用眼宽。返回正浮点数。
"""
lm = _lm_list(landmarks)
iris_px = _iris_diameter_px(lm, image_width, image_height)
if iris_px is not None:
return iris_px / AVG_IRIS_DIAMETER_CM
# 降级:眼宽法
eye_px = _eye_width_px(lm, image_width, image_height)
return eye_px / AVG_EYE_WIDTH_CM
if __name__ == "__main__":
import sys
import cv2
from face_analysis.detector import detector
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
img = cv2.imread(path)
if img is None:
print(f"无法读取图片: {path}")
sys.exit(1)
h, w = img.shape[:2]
lms = detector.detect(img)
if lms is None:
print("未检出人脸")
sys.exit(1)
print(f"px_per_cm: {estimate_scale_factor(lms, w, h):.4f}")
+61
View File
@@ -0,0 +1,61 @@
"""MediaPipe Face Mesh 关键点检测封装(单例)。
封装经典 Solutions APImp.solutions.face_mesh),模型权重内置于 pip 包,
无需额外下载。开启 refine_landmarks=True 以获得虹膜点(尺度校准用),
static_image_mode=True 适配单张图片推理,max_num_faces=1 只取最大/首个人脸。
详见技术方案 §8.2。
"""
import cv2
import numpy as np
import mediapipe as mp
mp_face_mesh = mp.solutions.face_mesh
class FaceMeshDetector:
"""MediaPipe Face Mesh 封装,单例模式(模块底部 detector)。"""
def __init__(self):
self.face_mesh = mp_face_mesh.FaceMesh(
static_image_mode=True,
max_num_faces=1, # 仅检测单人(取最大脸)
refine_landmarks=True, # 启用虹膜 + 唇部精细关键点
min_detection_confidence=0.5,
)
def detect(self, image: np.ndarray):
"""检测人脸关键点。
Args:
image: BGR numpy arrayOpenCV 格式)。
Returns:
landmarks: NormalizedLandmarkList.landmark 列表),或检测失败时 None。
"""
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
results = self.face_mesh.process(rgb)
if results.multi_face_landmarks:
return results.multi_face_landmarks[0]
return None
def close(self):
self.face_mesh.close()
# 全局单例:模块加载时初始化一次,避免每请求重建(重建很慢)。
detector = FaceMeshDetector()
if __name__ == "__main__":
import sys
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
img = cv2.imread(path)
if img is None:
print(f"无法读取图片: {path}")
sys.exit(1)
lms = detector.detect(img)
if lms is None:
print("detected landmarks: None(未检出人脸)")
sys.exit(1)
print(f"detected landmarks: {len(lms.landmark)}")
+42
View File
@@ -0,0 +1,42 @@
"""MediaPipe Face Mesh 关键点索引常量(四庭七眼测量用)。
MediaPipe Face Mesh 对 468 个点按固定拓扑编号;开启 refine_landmarks=True 后
额外输出 10 个虹膜点(索引 468–477),总计 478 点。本模块集中定义本接口
所需的全部索引,避免散落在各处的魔数。详见技术方案 §2。
"""
# --- 四庭纵向中轴关键点 ---
GLABELLA_9 = 9 # 眉间 / glabella(上点)
GLABELLA_151 = 151 # 眉间 / glabella(下点),与 9 取中点作为眉心
NOSE_BOTTOM = 94 # 鼻翼下缘 / subnasale(人中顶部)
CHIN_TIP = 152 # 下巴尖 / menton(下颌最低点)
# --- 七眼横向关键点 ---
LEFT_EYE_OUTER = 33 # 左眼外角
LEFT_EYE_INNER = 133 # 左眼内角
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 的版本)
NOSE_TIP_ALT = 4
# --- 虹膜关键点(refine_landmarks=True 才输出,尺度校准用) ---
IRIS_LEFT_CENTER = 468 # 左眼虹膜中心
IRIS_LEFT_LEFT = 469 # 左虹膜左边缘
IRIS_LEFT_RIGHT = 471 # 左虹膜右边缘
IRIS_RIGHT_CENTER = 473 # 右眼虹膜中心
IRIS_RIGHT_LEFT = 474 # 右虹膜左边缘
IRIS_RIGHT_RIGHT = 476 # 右虹膜右边缘
# --- solvePnP 姿态估计用的 6 点(与通用 3D 头模一一对应,见 pose.py ---
MOUTH_LEFT = 61 # 左嘴角
MOUTH_RIGHT = 291 # 右嘴角
PNP_INDICES = [NOSE_TIP, CHIN_TIP, LEFT_EYE_OUTER, RIGHT_EYE_OUTER, MOUTH_LEFT, MOUTH_RIGHT]
# 含虹膜时的关键点总数
NUM_LANDMARKS_WITH_IRIS = 478
+207
View File
@@ -0,0 +1,207 @@
"""方案 B:BiSeNet 头发/耳朵分割 + 发际线/头顶定位。
加载 face-parsing BiSeNetCelebAMask-HQ 19 类,hair=17、l_ear=7、r_ear=8),对整图
做像素级语义分割:得到头发 mask 用于沿面部中轴线扫描真实发际线与头顶,并得到耳朵
mask 供标注图取人头最左/最右竖线(耳朵外缘)。GPU 可用时走 CUDA,否则 CPU。
单例加载权重,避免每请求重载。详见技术方案 §1.4 / §4.0。
"""
import os
import cv2
import numpy as np
# ⚠️ torch / torchvision / BiSeNet 仅在 HairSegmenter.__init__ 内惰性导入,
# 使本模块的纯 numpy 函数 locate_hairline_by_segmentation 可在无 torch/GPU
# 的环境(如 Tier-1 合成几何测试、方案 A only 降级版)被安全导入。
_WEIGHTS = os.path.join(os.path.dirname(__file__), "weights", "79999_iter.pth")
HAIR_CLASS = 17 # CelebAMask-HQ 19 类中 hair 的索引
EAR_CLASSES = (7, 8) # 7=l_ear / 8=r_ear(类名以人为参照,图像左右另行判定,不依赖类名)
N_CLASSES = 19
_INPUT_SIZE = 512 # BiSeNet 推理输入边长
def _select_device(torch):
"""选择推理设备:优先 CUDA,但实测一次小算子确认当前 GPU 架构被本 torch 支持。
场景:本机为 RTX 5090sm_120/Blackwell),而 torch 2.2.2+cu121 仅编译到 sm_90
.cuda() 会在执行时抛 "no kernel image is available"。此处用一次小 matmul 探测,
失败则回退 CPUBiSeNet CPU 推理 ~0.31s/张,方案B 仍可用)。
换装支持 sm_120 的 torchcu128)后会自动改用 GPU,无需改代码。
可用环境变量 FORCE_CPU=1 强制 CPU。
"""
import os as _os
if _os.getenv("FORCE_CPU") == "1" or not torch.cuda.is_available():
return torch.device("cpu")
try:
_ = (torch.zeros(8, 8, device="cuda") @ torch.zeros(8, 8, device="cuda")).cpu()
return torch.device("cuda")
except Exception: # noqa: BLE001 GPU 架构不被支持 → 回退 CPU
return torch.device("cpu")
class HairSegmenter:
"""BiSeNet 头发分割封装。建议经 get_segmenter() 取单例。"""
def __init__(self, weights_path=_WEIGHTS):
import torch
import torchvision.transforms as transforms
from face_analysis.bisenet_model import BiSeNet
self._torch = torch
self.device = _select_device(torch)
self.net = BiSeNet(n_classes=N_CLASSES)
state = torch.load(weights_path, map_location="cpu", weights_only=False)
self.net.load_state_dict(state)
self.net.to(self.device)
self.net.eval()
self._to_tensor = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),
])
def _parse(self, image_bgr):
"""整图语义分割,返回原图尺寸的类别图(H×W int,值为 0–18 类别号)。"""
torch = self._torch
h, w = image_bgr.shape[:2]
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
resized = cv2.resize(rgb, (_INPUT_SIZE, _INPUT_SIZE),
interpolation=cv2.INTER_LINEAR)
inp = self._to_tensor(resized).unsqueeze(0).to(self.device)
with torch.no_grad():
out = self.net(inp)[0] # 主输出 (1, C, 512, 512)
parsing = out.squeeze(0).argmax(0).cpu().numpy() # (512, 512) 类别图
# 还原到原图尺寸(最近邻保持类别边界)
return cv2.resize(parsing.astype(np.int32), (w, h),
interpolation=cv2.INTER_NEAREST)
def segment_hair(self, image_bgr):
"""返回 hair_maskH×W boolTrue=头发),尺寸同输入原图。"""
return self._parse(image_bgr) == HAIR_CLASS
def _parse_face_cropped(self, image_bgr, face_box):
"""按人脸框裁剪后再分割,结果映射回原图尺寸(裁剪外填背景 0)。
BiSeNet 在 CelebAMask-HQ「紧裁对齐人脸」上训练,整张大场景图(全身/街拍,
脸只占一小块、背景复杂)会严重欠分割、丢耳朵。先按人脸放大裁剪,让脸接近
训练分布,耳朵/头发分割明显更稳。裁剪含足够上/侧边距以纳入发顶与双耳。
"""
h, w = image_bgr.shape[:2]
x0, y0, x1, y1 = face_box
fw, fh = max(1.0, x1 - x0), max(1.0, y1 - y0)
cx0 = int(max(0, x0 - fw * 0.8)); cx1 = int(min(w, x1 + fw * 0.8))
cy0 = int(max(0, y0 - fh * 1.0)); cy1 = int(min(h, y1 + fh * 0.5))
if cx1 - cx0 < 2 or cy1 - cy0 < 2:
return self._parse(image_bgr)
full = np.zeros((h, w), dtype=np.int32)
full[cy0:cy1, cx0:cx1] = self._parse(image_bgr[cy0:cy1, cx0:cx1])
return full
def segment_hair_and_ears(self, image_bgr, face_box=None):
"""单次推理返回 (hair_mask, ear_mask),均为 H×W bool,尺寸同原图。
ear_mask = 左耳(7) ∪ 右耳(8);耳朵被头发/侧脸遮挡时对应区域天然为空,
正好用于「看不到耳朵就不画线」的判定。两类合并、左右按图像位置另判,
不依赖以人为参照的类名(详见 EAR_CLASSES 注释)。
face_box=(x0,y0,x1,y1)(人脸关键点包围盒像素坐标)给定时先按人脸裁剪再
分割(见 _parse_face_cropped),整张大场景图也能稳定分出耳朵;不给则整图分割。
"""
if face_box is None:
parsing = self._parse(image_bgr)
else:
parsing = self._parse_face_cropped(image_bgr, face_box)
hair_mask = parsing == HAIR_CLASS
ear_mask = np.isin(parsing, EAR_CLASSES)
return hair_mask, ear_mask
_segmenter = None
def get_segmenter():
"""惰性单例:首次调用时加载权重(并占用显存),后续复用。"""
global _segmenter
if _segmenter is None:
_segmenter = HairSegmenter()
return _segmenter
def locate_hairline_by_segmentation(hair_mask, brow_center_x, image_height):
"""从头发 mask 定位发际线与头顶。
Args:
hair_mask: H×W bool/uint8True=头发。
brow_center_x: 面部中轴线 x(像素)。
image_height: 图高(保留参数,便于后续边界判断)。
Returns:
(hairline_y, hair_top_y) 像素坐标;失败返回 None(交给方案 A 兜底)。
"""
if hair_mask is None:
return None
mask = np.asarray(hair_mask).astype(bool)
if mask.sum() == 0:
return None
w = mask.shape[1]
cx = int(round(brow_center_x))
cx = max(0, min(cx, w - 1))
# 中轴线附近窄列带(±3px)求稳,避免单列噪声
band = mask[:, max(0, cx - 3): min(w, cx + 4)]
col = band.any(axis=1)
hair_rows = np.where(col)[0]
if hair_rows.size == 0:
return None
# 发际线:中轴线列带上头发区域最靠下的行(头发→皮肤交界,y 向下为正)
hairline_y = int(hair_rows.max())
# 头顶:整张头发 mask 的最高点(最小 y),用全图更鲁棒
top_rows = np.where(mask.any(axis=1))[0]
hair_top_y = int(top_rows.min())
# 合理性校验:头顶必须严格在发际线上方
if hair_top_y >= hairline_y:
return None
return hairline_y, hair_top_y
if __name__ == "__main__":
import sys
from face_analysis.detector import detector
from face_analysis.calibration import normalized_to_pixel
from face_analysis.face_mesh_landmarks import GLABELLA_9, GLABELLA_151
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
img = cv2.imread(path)
if img is None:
print(f"无法读取图片: {path}")
sys.exit(1)
h, w = img.shape[:2]
segmenter = get_segmenter()
print("device:", segmenter.device)
mask = segmenter.segment_hair(img)
print("hair pixels:", int(mask.sum()))
lms = detector.detect(img)
if lms is None:
print("未检出人脸,跳过定位")
sys.exit(0)
lm = lms.landmark
bx = (normalized_to_pixel(lm[GLABELLA_9], w, h)[0]
+ normalized_to_pixel(lm[GLABELLA_151], w, h)[0]) / 2
by = (normalized_to_pixel(lm[GLABELLA_9], w, h)[1]
+ normalized_to_pixel(lm[GLABELLA_151], w, h)[1]) / 2
res = locate_hairline_by_segmentation(mask, bx, h)
if res is None:
print("定位失败(将回退方案 A")
else:
hairline_y, hair_top_y = res
print(f"brow_y={by:.1f} hairline_y={hairline_y} hair_top_y={hair_top_y}")
print("自洽校验 hair_top_y < hairline_y < brow_y:",
hair_top_y < hairline_y < by)
# dump mask 预览
os.makedirs("tests/output", exist_ok=True)
cv2.imwrite("tests/output/hair_mask.png", (mask.astype(np.uint8) * 255))
print("mask 预览已存 tests/output/hair_mask.png")
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
"""接口10:头部外缘膨胀带遮罩。
先和接口9 一样得到**内缩后的基准遮罩**(含额头的闭合区域外缘朝151内缩 erode_cm、底线不动,默认1.2cm),
在这个基础上:
1. 取基准遮罩的**外轮廓线**(1px),去掉贴着底部分界线的那一段(只留头发/头部外缘弧线)。
2. 把这条外轮廓线膨胀成带子(半径 = dilate_cm/2,即带子**总宽 ≈ dilate_cm**,默认 2cm)。
3. 裁到分界线以上(不越过底线)。
输出这条带子作为 mask。BiSeNet / SegFormer 两套并排对比,分步可视化。
两个可调参数:erode_cm(同接口9 的内缩,默认1.2)+ dilate_cm(带子总宽,默认2)。
复用 `head_mask` 的构件,避免重复实现。
"""
import cv2
import numpy as np
from face_analysis.detector import detector
from face_analysis.calibration import estimate_scale_factor
from face_analysis.head_mask import (
BASELINE_IDX, ERODE_CM, NoFaceError,
_baseline_points, _upper_region_mask, _fill_to_baseline, _largest_cc,
_bisenet_hair_mask, _segformer_hair_mask,
_erode, _overlay, _draw_baseline, _b64png, _mask_png,
)
DILATE_CM = 2.0 # 膨胀后带子总宽(厘米,默认;半径 = 总宽/2;可由入参覆盖)
def _dilate(mask_bool, r):
"""圆盘核膨胀半径 r(像素)。r<=0 原样返回。"""
if r <= 0:
return mask_bool.copy()
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1))
return cv2.dilate(mask_bool.astype(np.uint8), k).astype(bool)
def _baseline_line_mask(baseline_pts, w, h, thickness):
"""底部分割线(含左右水平延长线)画成一条带厚度的线,用于从外轮廓里剔除底边。"""
m = np.zeros((h, w), np.uint8)
y_l = baseline_pts[0][1]
y_r = baseline_pts[-1][1]
chain = [(0, y_l)] + baseline_pts + [(w - 1, y_r)]
for a, b in zip(chain[:-1], chain[1:]):
cv2.line(m, a, b, 1, thickness)
return m.astype(bool)
def _outer_contour_no_bottom(region, baseline_band):
"""区域外轮廓(1px)去掉贴着底部分界线的那一段。"""
contour = region & ~_erode(region, 1)
return contour & ~baseline_band
def _model_result(image_bgr, hair_mask, upper, baseline_pts, baseline_band, r_erode, r_dilate, w):
"""单个分割模型的分步结果(内缩后基准遮罩 / 外轮廓线 / 膨胀带 / 纯遮罩)。"""
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
base = _largest_cc(_erode(top_fill, r_erode) & upper) # 接口9 内缩后的基准遮罩
contour = _outer_contour_no_bottom(base, baseline_band) # 外轮廓,去底线
band = _largest_cc(_dilate(contour, r_dilate) & upper) # 膨胀成带、裁到底线以上
return {
"base_pixels": int(base.sum()),
"band_pixels": int(band.sum()),
"base_mask_base64": _b64png(
_draw_baseline(_overlay(image_bgr, base, (255, 150, 0)), baseline_pts, w)),
"contour_base64": _b64png(
_draw_baseline(_overlay(image_bgr, _dilate(contour, 2), (0, 255, 0)), baseline_pts, w)),
"band_overlay_base64": _b64png(
_draw_baseline(_overlay(image_bgr, band, (0, 0, 255)), baseline_pts, w)),
"mask_base64": _mask_png(band),
}
def generate_head_band(image_bgr, erode_cm=ERODE_CM, dilate_cm=DILATE_CM):
"""接口10 完整管线。返回可直接进 ok() 的 data dict。
erode_cm:基准遮罩外缘朝151 内缩距离(厘米,同接口9),页面可调,默认 1.2cm。
dilate_cm:外轮廓线膨胀后带子总宽(厘米),页面可调,默认 2cm。
未检出人脸抛 NoFaceError。单个分割模型异常不影响另一个(记为 {"error": ...})。
"""
h, w = image_bgr.shape[:2]
landmarks = detector.detect(image_bgr)
if landmarks is None:
raise NoFaceError()
erode_cm = max(0.0, float(erode_cm))
dilate_cm = max(0.0, float(dilate_cm))
px_per_cm = estimate_scale_factor(landmarks, w, h)
r_erode = int(round(erode_cm * px_per_cm)) # 内缩半径
r_dilate = int(round((dilate_cm / 2.0) * px_per_cm)) # 膨胀半径 = 总宽/2
baseline_pts = _baseline_points(landmarks, w, h)
upper = _upper_region_mask(baseline_pts, w, h)
# 剔除底边用的分界线带:几像素宽即可,独立于膨胀/内缩半径
baseline_band = _dilate(_baseline_line_mask(baseline_pts, w, h, 5), 2)
baseline_viz = _draw_baseline(image_bgr, baseline_pts, w)
data = {
"px_per_cm": round(float(px_per_cm), 4),
"erode_cm": round(erode_cm, 2),
"erode_px": r_erode,
"dilate_cm": round(dilate_cm, 2),
"dilate_radius_px": r_dilate,
"image_size": {"width": w, "height": h},
"baseline_landmarks": [
{"index": idx, "x": p[0], "y": p[1]}
for idx, p in zip(BASELINE_IDX, baseline_pts)
],
"steps_common": {
"landmarks_baseline_base64": _b64png(baseline_viz),
"upper_region_base64": _b64png(_overlay(baseline_viz, upper, (0, 200, 0))),
},
}
seg_fns = {
"bisenet": lambda: _bisenet_hair_mask(image_bgr, landmarks, w, h),
"segformer": lambda: _segformer_hair_mask(image_bgr),
}
for name, fn in seg_fns.items():
try:
hair_mask = fn()
data[name] = _model_result(image_bgr, hair_mask, upper, baseline_pts,
baseline_band, r_erode, r_dilate, w)
except Exception as ex: # noqa: BLE001 单模型失败不影响整体
data[name] = {"error": f"{type(ex).__name__}: {ex}"}
return data
+226
View File
@@ -0,0 +1,226 @@
"""接口9:头发遮罩生成。
流程(详见需求讨论):
1. MediaPipe 关键点检测。
2. 底部分割线 = 关键点 [162,71,68,104,69,108,151,337,299,333,298,301,389] 的连线(左端162→中心151→右端389),
再把左端点 162 水平延伸到图片最左边、右端点 389 水平延伸到图片最右边。
3. 上半区 = 分割线以上区域(多边形填充:左边缘→弧线→右边缘→上边缘闭合)。
4. 头发分割:BiSeNet 与 SegFormer 各出一张 hair_mask(两套供对比)。
5. 闭合区域(含额头):每列从最顶端头发像素向下填充到分割线,把头发与画线之间的额头皮肤
也包进来(不再从发际线割断),底边即分割线。
6. 外缘内缩 erode_cm(默认 1.2cm,可调)、底线不动:对「填充到图底的实心块」做半径 r 的腐蚀,
再与上半区相交。腐蚀只把外轮廓(顶/两侧)朝内(朝 151)收 r;平底边是相交后才产生的,
所以底线纹丝不动。cm→像素用虹膜标定(calibration.estimate_scale_factor)。
对外返回每一步叠加在原图上的可视化图(base64 PNG,data URI),供测试页逐步展示。
"""
import base64
import cv2
import numpy as np
from face_analysis.detector import detector
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 = [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 类索引
class NoFaceError(Exception):
"""未检测到人脸。"""
# ---------------------------------------------------------------------------
# 几何:分割线与上半区
# ---------------------------------------------------------------------------
def _px(landmarks, idx, w, h):
p = landmarks.landmark[idx]
return (int(round(p.x * w)), int(round(p.y * h)))
def _baseline_points(landmarks, w, h):
"""额头弧线各关键点的像素坐标(按 BASELINE_IDX 顺序,左→右)。"""
return [_px(landmarks, i, w, h) for i in BASELINE_IDX]
def _upper_region_mask(baseline_pts, w, h):
"""分割线以上区域(boolH×W)。
多边形顶点:左上角 →(0, y左端)→ 弧线各点 →(w-1, y右端)→ 右上角,闭合后填充。
左端/右端为两段水平延长线(向图片左右边缘延伸)。
"""
x0, y0 = baseline_pts[0]
x1, y1 = baseline_pts[-1]
poly = [(0, 0), (0, y0)] + baseline_pts + [(w - 1, y1), (w - 1, 0)]
mask = np.zeros((h, w), np.uint8)
cv2.fillPoly(mask, [np.array(poly, np.int32)], 1)
return mask.astype(bool)
# ---------------------------------------------------------------------------
# 头发分割(两套)
# ---------------------------------------------------------------------------
def _bisenet_hair_mask(image_bgr, landmarks, w, h):
"""BiSeNet(接口1 同款):先按人脸框裁剪再分割,稳住小脸大图。"""
from face_analysis.hair_segmenter import get_segmenter
pxs = [normalized_to_pixel(p, w, h) for p in landmarks.landmark]
face_box = (min(p[0] for p in pxs), min(p[1] for p in pxs),
max(p[0] for p in pxs), max(p[1] for p in pxs))
hair_mask, _ear = get_segmenter().segment_hair_and_ears(image_bgr, face_box=face_box)
return np.asarray(hair_mask, dtype=bool)
def _segformer_hair_mask(image_bgr):
"""复用接口2/3 的 SegFormer 单例(hairline.service.get_parser),
共用权重与设备策略(SEG_DEVICE,默认 cpu;本机 5090 上 CUDA 内核不可用故走 CPU)。"""
from hairline.service import get_parser
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
labels = get_parser().parse(rgb)
return labels == SEGFORMER_HAIR
# ---------------------------------------------------------------------------
# 形态学 & 可视化
# ---------------------------------------------------------------------------
def _erode(mask_bool, r):
"""圆盘核腐蚀半径 r(像素)。r<=0 原样返回。"""
if r <= 0:
return mask_bool.copy()
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1))
return cv2.erode(mask_bool.astype(np.uint8), k).astype(bool)
def _overlay(image, mask_bool, color, alpha=0.45):
"""把纯色以 alpha 叠加到 mask 区域上(非 mask 区域保持原样)。"""
out = image.copy()
if mask_bool.any():
out[mask_bool] = (out[mask_bool] * (1 - alpha)
+ np.array(color, np.float32) * alpha).astype(np.uint8)
return out
def _draw_baseline(image, baseline_pts, w):
"""画分割线(含左右水平延长线)+ 关键点,中心点 151 标红。"""
out = image.copy()
y0 = baseline_pts[0][1]
y1 = baseline_pts[-1][1]
chain = [(0, y0)] + baseline_pts + [(w - 1, y1)]
for a, b in zip(chain[:-1], chain[1:]):
cv2.line(out, a, b, (0, 255, 255), 2, cv2.LINE_AA)
for idx, p in zip(BASELINE_IDX, baseline_pts):
col = (0, 0, 255) if idx == CENTER_IDX else (0, 200, 0)
cv2.circle(out, p, 4, col, -1, cv2.LINE_AA)
cv2.putText(out, str(idx), (p[0] + 4, p[1] - 6),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, col, 1, cv2.LINE_AA)
return out
def _b64png(bgr):
"""BGR 图 → data URIPNG base64)。gateway 会把 *_base64 字段落盘改成 *_url。"""
ok, buf = cv2.imencode(".png", bgr)
return "data:image/png;base64," + base64.b64encode(buf.tobytes()).decode()
def _mask_png(mask_bool):
"""纯遮罩:白(255)为遮罩、黑为背景。"""
m = (mask_bool.astype(np.uint8)) * 255
return _b64png(cv2.cvtColor(m, cv2.COLOR_GRAY2BGR))
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
def _largest_cc(mask_bool):
"""保留最大连通域,去掉背景里孤立的杂散头发列。空掩膜原样返回。"""
m = mask_bool.astype(np.uint8)
if m.sum() == 0:
return mask_bool
n, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8)
if n <= 2: # 只有背景 + 一个前景
return mask_bool
largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
return labels == largest
def _fill_to_baseline(hair_mask, upper):
"""含额头的实心区域:每列从最顶端头发像素向下填充(延伸到图底,未按基线裁剪)。
这样头发与画线之间的额头皮肤被包进闭合区域(不再被割断);未裁剪到基线是为了
后续腐蚀时底线不动(腐蚀在延伸到图底的实心块上做,再与上半区相交切平底边)。
"""
has_hair = (hair_mask & upper).astype(np.uint8)
return np.maximum.accumulate(has_hair, axis=0).astype(bool)
def _model_result(image_bgr, hair_mask, upper, baseline_pts, r, w):
"""单个分割模型的分步结果(闭合区域 / 最终遮罩 / 可视化)。"""
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
closed = _largest_cc(top_fill & upper) # 闭合区域:头发+额头,底=基线
final = _largest_cc(_erode(top_fill, r) & upper) # 外缘朝151内缩 r、底线不动
return {
"hair_pixels": int(hair_mask.sum()),
"closed_pixels": int(closed.sum()),
"mask_pixels": int(final.sum()),
"hair_mask_base64": _b64png(_overlay(image_bgr, hair_mask, (255, 150, 0))),
"closed_region_base64": _b64png(
_draw_baseline(_overlay(image_bgr, closed, (255, 150, 0)), baseline_pts, w)),
"final_overlay_base64": _b64png(
_draw_baseline(_overlay(image_bgr, final, (0, 0, 255)), baseline_pts, w)),
"mask_base64": _mask_png(final),
}
def generate_head_mask(image_bgr, erode_cm=ERODE_CM):
"""接口9 完整管线。返回可直接进 ok() 的 data dict。
erode_cm:外缘朝 151 内缩的距离(厘米),页面可调,默认 1cm。
未检出人脸抛 NoFaceError。单个分割模型异常不影响另一个(记为 {"error": ...})。
"""
h, w = image_bgr.shape[:2]
landmarks = detector.detect(image_bgr)
if landmarks is None:
raise NoFaceError()
erode_cm = max(0.0, float(erode_cm))
px_per_cm = estimate_scale_factor(landmarks, w, h)
r = int(round(erode_cm * px_per_cm))
baseline_pts = _baseline_points(landmarks, w, h)
upper = _upper_region_mask(baseline_pts, w, h)
baseline_viz = _draw_baseline(image_bgr, baseline_pts, w)
data = {
"px_per_cm": round(float(px_per_cm), 4),
"erode_cm": round(erode_cm, 2),
"erode_px": r,
"image_size": {"width": w, "height": h},
"baseline_landmarks": [
{"index": idx, "x": p[0], "y": p[1]}
for idx, p in zip(BASELINE_IDX, baseline_pts)
],
"steps_common": {
"landmarks_baseline_base64": _b64png(baseline_viz),
"upper_region_base64": _b64png(_overlay(baseline_viz, upper, (0, 200, 0))),
},
}
seg_fns = {
"bisenet": lambda: _bisenet_hair_mask(image_bgr, landmarks, w, h),
"segformer": lambda: _segformer_hair_mask(image_bgr),
}
for name, fn in seg_fns.items():
try:
hair_mask = fn()
data[name] = _model_result(image_bgr, hair_mask, upper, baseline_pts, r, w)
except Exception as ex: # noqa: BLE001 单模型失败不影响整体
data[name] = {"error": f"{type(ex).__name__}: {ex}"}
return data
+320
View File
@@ -0,0 +1,320 @@
"""四庭七眼测量核心:纵向定位(方案 B 主 / 方案 A 兜底)+ 七眼 + 厘米换算。
整合:
- estimate_vertical_landmarks:方案 A,按三庭比例推算上/顶庭(兜底)。
- 决策逻辑:优先方案 B(分割发际线/头顶),合理性校验不过则回退方案 A。
- measure_seven_eyes:眼宽/脸宽/两眼间距实测。
- measure_face:主入口,产出结构化结果 MeasureResult(含 to_response)。
详见技术方案 §4 / §5。本模块不依赖 torch,可在纯几何环境单独运行。
"""
from face_analysis.calibration import (
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
)
from face_analysis.face_mesh_landmarks import (
GLABELLA_9, NOSE_BOTTOM, CHIN_TIP,
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
)
from face_analysis.hair_segmenter import locate_hairline_by_segmentation
# 方案 A 推算比例常量(顶:上:中:下 = 0.22:0.25:0.28:0.25),见技术方案 §4.2
_UPPER_RATIO = 0.25 / 0.265 # 上庭 ÷ 中下庭均值
_TOP_RATIO = 0.22 / 0.28 # 顶庭 ÷ 中庭(≈ 0.786
def _brow_center(lm, w, h):
"""眉心 = 索引 9(眉间上点)。"""
return normalized_to_pixel(lm[GLABELLA_9], w, h)
def estimate_vertical_landmarks(landmarks, image_width, image_height):
"""方案 A(兜底):实测中/下庭,按比例推算上/顶庭。
返回 5 个纵向点像素坐标 + 各段像素高度。注意其循环论证局限:
上/顶庭为估算值,不反映真实脸型(详见技术方案 §4.1)。
"""
lm = _lm_list(landmarks)
w, h = image_width, image_height
brow_x, brow_y = _brow_center(lm, w, h)
nose_bottom = normalized_to_pixel(lm[NOSE_BOTTOM], w, h)
chin_tip = normalized_to_pixel(lm[CHIN_TIP], w, h)
middle_court_px = abs(brow_y - nose_bottom[1]) # 眉心 → 鼻翼下缘
lower_court_px = abs(nose_bottom[1] - chin_tip[1]) # 鼻翼下缘 → 下巴尖
one_unit_px = (middle_court_px + lower_court_px) / 2 # 一等份 ≈ 中/下庭均值
upper_court_px = one_unit_px * _UPPER_RATIO
top_court_px = one_unit_px * _TOP_RATIO
hairline_y = brow_y - upper_court_px
hair_top_y = hairline_y - top_court_px
return {
"hair_top": (brow_x, hair_top_y),
"hairline": (brow_x, hairline_y),
"brow_center": (brow_x, brow_y),
"nose_bottom": (nose_bottom[0], nose_bottom[1]),
"chin_tip": (chin_tip[0], chin_tip[1]),
"top_court_px": top_court_px,
"upper_court_px": upper_court_px,
"middle_court_px": middle_court_px,
"lower_court_px": lower_court_px,
}
def _vertical_from_segmentation(lm, w, h, hair_mask):
"""方案 B:用分割得到的发际线/头顶替换方案 A 的上/顶庭。
成功且通过合理性校验返回 vertical dict,否则返回 None。
"""
res = locate_hairline_by_segmentation(hair_mask, _brow_center(lm, w, h)[0], h)
if res is None:
return None
hairline_y, hair_top_y = res
brow_x, brow_y = _brow_center(lm, w, h)
nose_bottom = normalized_to_pixel(lm[NOSE_BOTTOM], w, h)
chin_tip = normalized_to_pixel(lm[CHIN_TIP], w, h)
middle_court_px = abs(brow_y - nose_bottom[1])
lower_court_px = abs(nose_bottom[1] - chin_tip[1])
upper_court_px = brow_y - hairline_y # 发际线 → 眉心
top_court_px = hairline_y - hair_top_y # 头顶 → 发际线
# 合理性校验:发际线在眉心上方、头顶在发际线上方、各庭为正
if not (hair_top_y < hairline_y < brow_y):
return None
if upper_court_px <= 0 or top_court_px <= 0:
return None
if middle_court_px <= 0 or lower_court_px <= 0:
return None
return {
"hair_top": (brow_x, float(hair_top_y)),
"hairline": (brow_x, float(hairline_y)),
"brow_center": (brow_x, brow_y),
"nose_bottom": (nose_bottom[0], nose_bottom[1]),
"chin_tip": (chin_tip[0], chin_tip[1]),
"top_court_px": top_court_px,
"upper_court_px": upper_court_px,
"middle_court_px": middle_court_px,
"lower_court_px": lower_court_px,
}
def decide_vertical(landmarks, image_width, image_height, hair_mask):
"""纵向定位决策:方案 B 优先,失败回退方案 A。
返回 (vertical_dict, hairline_source)source ∈ {"segmentation","estimated"}。
"""
lm = _lm_list(landmarks)
vb = _vertical_from_segmentation(lm, image_width, image_height, hair_mask)
if vb is not None:
return vb, "segmentation"
return estimate_vertical_landmarks(landmarks, image_width, image_height), "estimated"
def measure_seven_eyes(landmarks, image_width, image_height):
"""七眼:眼宽(左右均值)、脸宽、两眼间距(像素)。"""
lm = _lm_list(landmarks)
w, h = image_width, image_height
left_outer = normalized_to_pixel(lm[LEFT_EYE_OUTER], w, h)
left_inner = normalized_to_pixel(lm[LEFT_EYE_INNER], w, h)
right_inner = normalized_to_pixel(lm[RIGHT_EYE_INNER], w, h)
right_outer = normalized_to_pixel(lm[RIGHT_EYE_OUTER], w, h)
left_cheek = normalized_to_pixel(lm[LEFT_CHEEK], w, h)
right_cheek = normalized_to_pixel(lm[RIGHT_CHEEK], w, h)
left_eye = pixel_distance(left_outer, left_inner)
right_eye = pixel_distance(right_inner, right_outer)
return {
"eye_width_px": (left_eye + right_eye) / 2,
"face_width_px": pixel_distance(left_cheek, right_cheek),
"inter_eye_distance_px": pixel_distance(left_inner, right_inner),
# 标注图用的横向点像素坐标(不进 to_response
"points": {
"left_outer": left_outer, "left_inner": left_inner,
"right_inner": right_inner, "right_outer": right_outer,
"left_cheek": left_cheek, "right_cheek": right_cheek,
},
}
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 字段。"""
# 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。
# 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
# 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。
# 弃用时 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
self.face_width_cm = eyes["face_width_px"] / px_per_cm
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
def to_response(self):
# 发际线弃用:顶/上庭相关字段置 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"] / 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": 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"] = {
"yaw": round(yaw, 2), "pitch": round(pitch, 2), "roll": round(roll, 2),
}
return data
def measure_face(landmarks, hair_mask, image_width, image_height, head_pose=None):
"""主入口:纵向决策 + 七眼 + 尺度换算 → MeasureResult。"""
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,
landmarks, image_width, image_height)
if __name__ == "__main__":
import sys
import json
import cv2
from face_analysis.detector import detector
from face_analysis.pose import estimate_head_pose
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
img = cv2.imread(path)
if img is None:
print(f"无法读取图片: {path}")
sys.exit(1)
h, w = img.shape[:2]
lms = detector.detect(img)
if lms is None:
print("未检出人脸")
sys.exit(1)
# 尝试分割(若 torch 不可用则走方案 A)
mask = None
try:
from face_analysis.hair_segmenter import get_segmenter
mask = get_segmenter().segment_hair(img)
except Exception as e: # noqa: BLE001
print(f"[warn] 分割不可用,回退方案 A:{e}")
pose = estimate_head_pose(lms, w, h)
result = measure_face(lms, mask, w, h, head_pose=pose)
print(json.dumps(result.to_response(), ensure_ascii=False, indent=2))
+110
View File
@@ -0,0 +1,110 @@
"""头部姿态估计(cv2.solvePnP)+ 正面照校验。
用通用 3D 头模与 6 个 MediaPipe 关键点求解欧拉角(yaw/pitch/roll,单位:度),
阈值即可写成业务可读的「yaw>15° 拒绝」,并把角度返回前端做拍照引导。
详见技术方案 §9。
"""
import os
import cv2
import numpy as np
from face_analysis.face_mesh_landmarks import PNP_INDICES
# 正面照判定阈值(度),可由环境变量覆盖,便于上线后按真实数据标定(见技术方案 §11)。
# ⚠️ 标定说明:基于通用 6 点 3D 头模 + solvePnP,对明显正面但相机略带俯仰/个体
# 脸型差异的真实照片,解出的 yaw/pitch 常落在 15~25°(roll 较稳定,多在 5° 内)。
# 因此默认阈值放宽到 30°,只拦截明显侧脸(真实侧脸 yaw 通常 40°+),
# 避免误杀正常上传图。生产可通过环境变量随时收紧/放宽,无需改代码。
YAW_THRESHOLD = float(os.getenv("FRONTAL_YAW_THR", "30"))
PITCH_THRESHOLD = float(os.getenv("FRONTAL_PITCH_THR", "30"))
ROLL_THRESHOLD = float(os.getenv("FRONTAL_ROLL_THR", "30"))
# 通用 3D 头部模型(单位 mm,近似),与 PNP_INDICES 一一对应:
# 鼻尖(1) / 下巴(152) / 左眼外角(33) / 右眼外角(263) / 左嘴角(61) / 右嘴角(291)
# ⚠️ 采用「相机坐标系」约定:x 向右、y 向下、z 向场景内(远离观察者)。
# 与 MediaPipe 像素坐标(y 下)一致,且 +z 指向人脸背面,
# 这样正面照解出的旋转矩阵≈单位阵,欧拉角≈0。
# 若只翻 y 不翻 z(或都不翻),会残留 ~180° 翻转使正面图被误判。
_MODEL_POINTS = np.array([
(0.0, 0.0, 0.0), # 鼻尖
(0.0, 63.6, 12.5), # 下巴(在鼻尖下方 → y 正)
(-43.3, -32.7, 26.0), # 左眼外角(在鼻尖上方 → y 负,且凹于鼻尖 → z 正)
(43.3, -32.7, 26.0), # 右眼外角
(-28.9, 28.9, 24.1), # 左嘴角
(28.9, 28.9, 24.1), # 右嘴角
], dtype=np.float64)
def estimate_head_pose(landmarks, image_width, image_height):
"""求解头部欧拉角,返回 (yaw, pitch, roll)(度)。solvePnP 失败返回 None。"""
lm = landmarks.landmark if hasattr(landmarks, "landmark") else landmarks
image_points = np.array([
(lm[i].x * image_width, lm[i].y * image_height)
for i in PNP_INDICES
], dtype=np.float64)
focal = float(image_width) # 近似焦距
cam_matrix = np.array([[focal, 0, image_width / 2],
[0, focal, image_height / 2],
[0, 0, 1]], dtype=np.float64)
dist = np.zeros((4, 1)) # 假设无畸变
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(竖轴)转 → 左右扭头
# pitch = 绕 X(横轴)转 → 上下点头
# roll = 绕 Z(光轴)转 → 面内倾斜
sy = (rot[0, 0] ** 2 + rot[1, 0] ** 2) ** 0.5
yaw = float(np.degrees(np.arctan2(-rot[2, 0], sy)))
pitch = float(np.degrees(np.arctan2(rot[2, 1], rot[2, 2])))
roll = float(np.degrees(np.arctan2(rot[1, 0], rot[0, 0])))
return yaw, pitch, roll
def check_frontal_face(landmarks, image_width, image_height,
yaw_thr=YAW_THRESHOLD, pitch_thr=PITCH_THRESHOLD,
roll_thr=ROLL_THRESHOLD):
"""正面照判定:yaw/pitch/roll 绝对值均在阈值内才算正面。
solvePnP 解算失败时返回 True(不拦截,交由后续逻辑),避免误杀。
"""
pose = estimate_head_pose(landmarks, image_width, image_height)
if pose is None:
return True
yaw, pitch, roll = pose
return abs(yaw) <= yaw_thr and abs(pitch) <= pitch_thr and abs(roll) <= roll_thr
if __name__ == "__main__":
import sys
from face_analysis.detector import detector
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
img = cv2.imread(path)
if img is None:
print(f"无法读取图片: {path}")
sys.exit(1)
h, w = img.shape[:2]
lms = detector.detect(img)
if lms is None:
print("未检出人脸")
sys.exit(1)
yaw, pitch, roll = estimate_head_pose(lms, w, h)
frontal = check_frontal_face(lms, w, h)
print(f"yaw={yaw:.2f} pitch={pitch:.2f} roll={roll:.2f} frontal={frontal}")
+152
View File
@@ -0,0 +1,152 @@
"""接口4:用户面部特征分析(调用火山方舟 豆包视觉模型 doubao-seed-1-6-vision)。
算法来源:/home/xsl/fuyanFaceArk.py)。worker 把图片以 base64 data URI 传给方舟
多模态模型,模型返回一大堆人脸特征 JSON;本模块解析后映射出接口4 的英文优先字段
face_shape 等),并保留 doubao 返回的全部中文字段。
⚠️ 这是**唯一调外网云模型**的接口(其余接口全本地)。API Key 走配置/环境变量,不入 git。
"""
from __future__ import annotations
import base64
import json
import logging
import os
logger = logging.getLogger("hair.worker")
ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-6-vision-250815")
# doubao 中文键 → 接口4 英文优先字段(仅保留这 6 项)
_KEY_MAP = {
"脸型": "face_shape",
"眉形": "eyebrow_shape",
"面部年龄": "facial_age",
"动静类型": "dynamic_static_type",
"性别": "gender",
"基因风格": "gene_style",
}
# 仅请求接口4 需要的 6 个字段(+「图片是否有人脸」用于 1001 判定,不进最终输出)
_PROMPT = (
"分析一下图片告诉我以下特征,只要答案,格式为json字符串,"
"图片是否有人脸(有人/没人) "
"脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 眉形 "
"面部年龄(给出区间年龄) 动静类型(静态型/动态型) 性别(男/女) "
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
)
_client = None # 缓存的 Ark clientapi_key 变更时自动重建)
_client_key: str | None = None # _client 构建时使用的 api_key,用于检测配置变更
def _load_api_key() -> str | None:
"""ARK_API_KEY 环境变量优先,否则读 worker_config.json / gateway/config.json 的 ark_api_key。"""
key = os.getenv("ARK_API_KEY")
if key:
return key
base = os.path.dirname(__file__)
for cfg_name in ("worker_config.json", "gateway/config.json"):
cfg = os.path.join(base, cfg_name)
if os.path.isfile(cfg):
try:
with open(cfg, encoding="utf-8") as f:
v = json.load(f).get("ark_api_key")
if v:
return v
except Exception as e: # noqa: BLE001
logger.warning("读取 %s ark_api_key 失败:%s", cfg_name, e)
return None
def get_client():
"""返回 Ark client。
client 全局缓存,但每次都会重新读取 api_key —— 一旦配置(环境变量 /
worker_config.json / gateway/config.json 的 ark_api_key)发生变化,
自动重建 client。这样换 key 后无需重启进程。
"""
global _client, _client_key
key = _load_api_key()
if not key:
raise RuntimeError("缺少火山方舟 API Key(设 ARK_API_KEY 或 worker_config.json.ark_api_key")
# client 未建、或 key 变了 → 重建
if _client is None or key != _client_key:
from volcenginesdkarkruntime import Ark
_client = Ark(base_url=ARK_BASE_URL, api_key=key)
_client_key = key
return _client
def _parse_json(text: str) -> dict:
"""去掉 ```json 包裹后解析。"""
s = text.strip()
if s.startswith("```"):
s = s.strip("`")
if s[:4].lower() == "json":
s = s[4:]
return json.loads(s.strip())
def _image_to_url(image_bytes: bytes = None, image_url: str = None) -> str:
"""优先用现成 URL;否则把字节转 base64 data URIdoubao 兼容)。"""
if image_url:
return image_url
fmt = "png" if image_bytes[:8] == b"\x89PNG\r\n\x1a\n" else "jpeg"
return f"data:image/{fmt};base64," + base64.b64encode(image_bytes).decode()
def analyze_features(image_bytes: bytes = None, image_url: str = None):
"""调 doubao 视觉模型分析人脸特征。
Returns: dict —— 仅含接口4 的 6 个英文字段(face_shape/eyebrow_shape/facial_age/
dynamic_static_type/gender/gene_style)**无人脸返回 None**(调用方据此判 1001)。
"""
url = _image_to_url(image_bytes, image_url)
resp = get_client().chat.completions.create(
model=ARK_MODEL,
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": url}},
{"type": "text", "text": _PROMPT},
],
}],
max_tokens=1024, # 限制输出长度,模型秒回
temperature=0, # 固定输出,无随机采样,提速+结果稳定
stream=False, # 关闭流式,单次返回结果更快
extra_body={
"thinking": {
"type": "disabled", # 彻底关闭深度思考模式,提速50%+
},
},
)
text = resp.choices[0].message.content
logger.info("doubao raw response (first 500 chars): %s", text[:500])
try:
raw = _parse_json(text) # doubao 原始中文字段
except (json.JSONDecodeError, ValueError) as e:
logger.error("doubao 返回非 JSON,原文: %s", text[:1000])
raise RuntimeError(f"豆包模型返回格式异常,无法解析为 JSON:{text[:200]}") from e
if not has_face(raw):
return None
# 只保留 6 个英文字段(doubao 缺某字段则跳过)
return {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
def has_face(features: dict) -> bool:
"""据 doubao 的「图片是否有人脸」判断。"""
v = features.get("图片是否有人脸") or features.get("是否有人") or ""
return "没人" not in str(v) and "没有" not in str(v)
if __name__ == "__main__":
import sys
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
with open(path, "rb") as f:
feats = analyze_features(image_bytes=f.read())
if feats is None:
print("无人脸(1001")
else:
print(json.dumps(feats, ensure_ascii=False, indent=2))
+1
View File
@@ -0,0 +1 @@
# gateway — 外网网关
+609
View File
@@ -0,0 +1,609 @@
"""外网网关 — FastAPI 应用。
薄反向代理层对外保持 HTTPS 接口不变对内转发到 worker
不跑任何算法 torch/mediapipe/opencv 依赖
"""
import asyncio
import base64
import json
import logging
import time
from contextlib import asynccontextmanager
from io import BytesIO
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from gateway.config import load_config
from gateway.logging_middleware import (
get_stats,
init_logging as _init_req_logging,
request_logging_middleware,
)
# ---------------------------------------------------------------------------
# 日志
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("gateway")
# ---------------------------------------------------------------------------
# 应用生命周期
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
"""启动:加载配置、初始化健康池;关闭:清理资源。"""
# 启动
cfg = load_config()
logger.info("网关启动中... workers=%s", cfg["workers"])
# 初始化请求日志
_init_req_logging(cfg)
# 初始化健康池(阶段二实现)
try:
from gateway.pool import init_pool, shutdown_pool as _pool_shutdown
_has_pool = True
except ImportError:
logger.warning("pool 模块未就绪,跳过健康池初始化")
_has_pool = False
if _has_pool:
await init_pool(cfg)
app.state._pool_shutdown = _pool_shutdown
else:
app.state._pool_shutdown = None
# 确保标注图目录存在
static_dir = Path(cfg["static_dir"])
static_dir.mkdir(parents=True, exist_ok=True)
logger.info("标注图目录: %s", static_dir)
# 启动定期清理任务(阶段四)
cleanup_shutdown = asyncio.Event()
cleanup_task = asyncio.create_task(
_cleanup_loop(static_dir, cfg, cleanup_shutdown)
)
app.state._cleanup_shutdown = cleanup_shutdown
app.state._cleanup_task = cleanup_task
yield
# 关闭
logger.info("网关关闭中...")
# 停止清理任务
if app.state._cleanup_shutdown:
app.state._cleanup_shutdown.set()
if app.state._cleanup_task:
app.state._cleanup_task.cancel()
try:
await app.state._cleanup_task
except asyncio.CancelledError:
pass
if app.state._pool_shutdown:
await app.state._pool_shutdown()
logger.info("网关已关闭")
# ---------------------------------------------------------------------------
# 创建应用
# ---------------------------------------------------------------------------
app = FastAPI(
title="旷视五接口 — 网关",
version="0.1.0",
description="外网网关:反向代理 5 个接口到高性能 worker 池。",
lifespan=lifespan,
)
# 静态文件托管(阶段四完善)
static_root = Path(__file__).resolve().parent.parent / "static"
static_root.mkdir(parents=True, exist_ok=True)
(static_root / "annotations").mkdir(parents=True, exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_root)), name="static")
# 请求日志中间件(在所有路由之前,静态文件之后)
app.middleware("http")(request_logging_middleware)
# ---------------------------------------------------------------------------
# 健康检查(网关自身)
# ---------------------------------------------------------------------------
def _get_pool_status_safe():
"""安全获取池状态(pool 未就绪时返回占位值)。"""
try:
from gateway.pool import get_pool_status
return get_pool_status()
except ImportError:
return {"total": 0, "healthy": 0, "busy": 0}
async def _cleanup_loop(annotations_dir: Path, cfg: dict, shutdown: asyncio.Event):
"""定期清理 static/annotations/ 中过期的标注图文件。
配置项可选 config.json 中设定
- cleanup.interval_minutes: 清理间隔默认 60
- cleanup.max_age_hours: 文件保留时长小时默认 24
"""
cleanup_cfg = cfg.get("cleanup", {})
interval_s = cleanup_cfg.get("interval_minutes", 60) * 60
max_age_s = cleanup_cfg.get("max_age_hours", 24) * 3600
logger.info(
"清理任务启动 | 间隔=%dmin | 保留=%dh | 目录=%s",
interval_s // 60, max_age_s // 3600, annotations_dir,
)
while not shutdown.is_set():
try:
await asyncio.wait_for(shutdown.wait(), timeout=interval_s)
break # shutdown
except asyncio.TimeoutError:
pass # 正常到时,执行清理
now = time.time()
deleted = 0
for f in annotations_dir.iterdir():
if f.name == ".gitkeep":
continue
if not f.is_file():
continue
try:
age_s = now - f.stat().st_mtime
if age_s > max_age_s:
f.unlink()
deleted += 1
logger.debug("清理过期文件: %s (age=%.1fh)", f.name, age_s / 3600)
except Exception:
logger.warning("清理文件失败: %s", f.name, exc_info=True)
if deleted:
logger.info("清理完成: 删除 %d 个过期文件", deleted)
logger.info("清理任务已停止")
@app.get("/gateway-health", include_in_schema=False)
async def gateway_health():
"""网关自身健康检查(区别于 worker 的 /health)。"""
status = _get_pool_status_safe()
return {
"status": "ok",
"service": "gateway",
"workers_total": status["total"],
"workers_healthy": status["healthy"],
"workers_busy": status["busy"],
}
@app.get("/health", include_in_schema=False)
async def health():
"""兼容旧 /health 路径,返回网关状态。"""
return await gateway_health()
@app.get("/", include_in_schema=False)
async def index():
return {
"service": "旷视五接口 — 网关",
"version": "0.1.0",
"docs": "/docs",
"stats": "/admin/stats",
"integration_guide": "/static/integration.html",
"test_pages": {
"if1_measure": "/static/test_interface1.html",
"if2_hair_grow": "/static/test_interface2.html",
"if3_hair_grow_b": "/static/test_interface3.html",
"if4_features": "/static/test_interface4.html",
"if5_hairline": "/static/test_interface5.html",
"if6_measure_v2": "/static/test_interface6.html",
"if7_hair_grow_v2": "/static/test_interface7.html",
"if9_head_mask": "/static/test_interface9.html",
"if10_head_band": "/static/test_interface10.html",
},
}
# ---------------------------------------------------------------------------
# 请求统计仪表盘 HTML
# ---------------------------------------------------------------------------
_STATS_PAGE_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网关请求统计</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1300px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 4px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; }
.nav { margin-bottom: 20px; }
.nav a { color: #2563eb; text-decoration: none; font-size: 13px; }
.nav a:hover { text-decoration: underline; }
/* 汇总卡片 */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card { background: #fff; border-radius: 12px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.stat-card .value { font-size: 28px; font-weight: 700; color: #111827; }
.stat-card .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
.stat-card.ok .value { color: #059669; }
.stat-card.warn .value { color: #d97706; }
/* 表格 */
.section { margin-bottom: 24px; }
.section h2 { font-size: 16px; margin-bottom: 10px; color: #374151; }
.table-wrap { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 9px 14px; text-align: left; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
th { background: #f8fafc; font-weight: 700; color: #475569; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; white-space: nowrap; }
tr:hover td { background: #fafbfc; }
td.mono { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.badge-ok { background: #d1fae5; color: #065f46; }
.badge-err { background: #fee2e2; color: #991b1b; }
.badge-other { background: #f3f4f6; color: #6b7280; }
.duration-fast { color: #059669; }
.duration-mid { color: #d97706; }
.duration-slow { color: #dc2626; }
.footer { text-align: right; font-size: 12px; color: #9ca3af; margin-top: 20px; }
.auto-refresh { display: flex; align-items: center; gap: 8px; }
.auto-refresh input { accent-color: #2563eb; }
.empty { text-align: center; padding: 40px; color: #9ca3af; font-size: 14px; }
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
th, td { padding: 6px 8px; font-size: 12px; }
}
</style>
</head>
<body>
<div class="container">
<h1>📊 网关请求统计</h1>
<p class="subtitle">实时请求监控 &nbsp;|&nbsp; 3 秒自动刷新</p>
<div class="nav">
<a href="/"> 返回首页</a> &nbsp;|&nbsp;
<a href="/docs">API 文档</a> &nbsp;|&nbsp;
<a href="/static/integration.html">接入指南</a>
</div>
<!-- 汇总卡片 -->
<div class="stats-grid">
<div class="stat-card"><div class="value" id="totalCount"></div><div class="label">请求总数</div></div>
<div class="stat-card ok"><div class="value" id="successRate"></div><div class="label">成功率code=0</div></div>
<div class="stat-card"><div class="value" id="avgTime"></div><div class="label">平均响应时间</div></div>
<div class="stat-card"><div class="value" id="minTime"></div><div class="label">最短响应</div></div>
<div class="stat-card warn"><div class="value" id="maxTime"></div><div class="label">最长响应</div></div>
</div>
<!-- 按接口 -->
<div class="section">
<h2>📋 按接口统计</h2>
<div class="table-wrap">
<table>
<thead><tr><th>路径</th><th>请求数</th><th>平均耗时</th><th>最大耗时</th><th>成功率</th></tr></thead>
<tbody id="endpointTable"><tr><td class="empty" colspan="5">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<!-- Worker -->
<div class="section">
<h2>🖥 GPU Worker 统计</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Worker</th><th>请求数</th><th>平均耗时</th><th>成功率</th></tr></thead>
<tbody id="workerTable"><tr><td class="empty" colspan="4">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<!-- 最近请求 -->
<div class="section">
<h2>🕐 最近请求最新 100 </h2>
<div class="table-wrap" style="max-height:600px;overflow:auto;">
<table>
<thead><tr><th>时间</th><th>方法</th><th>路径</th><th>Worker</th><th>HTTP</th><th>业务码</th><th>耗时</th><th>客户端 IP</th></tr></thead>
<tbody id="recentTable"><tr><td class="empty" colspan="8">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<div class="footer">
<label class="auto-refresh">
<input type="checkbox" id="autoRefresh" checked onchange="toggleAutoRefresh()"> 自动刷新3s
</label>
<span style="margin-left:16px" id="lastUpdated">加载中</span>
</div>
</div>
<script>
let _timer = null;
function formatDuration(ms) {
if (ms < 1000) return ms.toFixed(1) + 'ms';
if (ms < 60000) return (ms / 1000).toFixed(2) + 's';
return (ms / 60000).toFixed(1) + 'min';
}
function durationClass(ms) {
if (ms < 500) return 'duration-fast';
if (ms < 2000) return 'duration-mid';
return 'duration-slow';
}
function badgeClass(code) {
if (code === 0) return 'badge-ok';
if (code !== null && code !== undefined && code !== 0) return 'badge-err';
return 'badge-other';
}
function badgeText(code) {
if (code === 0) return 'OK';
if (code !== null && code !== undefined) return 'ERR ' + code;
return '';
}
async function refresh() {
try {
const r = await fetch('/admin/stats/json');
const data = await r.json();
const s = data.summary;
document.getElementById('totalCount').textContent = s.total.toLocaleString();
document.getElementById('successRate').textContent = s.success_rate + '%';
document.getElementById('avgTime').textContent = formatDuration(s.avg_duration_ms);
document.getElementById('minTime').textContent = formatDuration(s.min_duration_ms);
document.getElementById('maxTime').textContent = formatDuration(s.max_duration_ms);
// 按接口
let ehtml = '';
if (data.endpoints.length === 0) {
ehtml = '<tr><td class="empty" colspan="5">暂无数据</td></tr>';
} else {
data.endpoints.forEach(function(e) {
ehtml += '<tr>' +
'<td class="mono">' + e.path + '</td>' +
'<td>' + e.count + '</td>' +
'<td class="' + durationClass(e.avg_duration_ms) + '">' + formatDuration(e.avg_duration_ms) + '</td>' +
'<td>' + formatDuration(e.max_duration_ms) + '</td>' +
'<td><span class="badge ' + badgeClass(0) + '" style="opacity:' + (e.success_rate / 100) + '">' + e.success_rate + '%</span></td>' +
'</tr>';
});
}
document.getElementById('endpointTable').innerHTML = ehtml;
// Worker
let whtml = '';
if (!data.workers || data.workers.length === 0) {
whtml = '<tr><td class="empty" colspan="4">暂无数据</td></tr>';
} else {
data.workers.forEach(function(w) {
whtml += '<tr>' +
'<td class="mono">' + w.worker + '</td>' +
'<td>' + w.count + '</td>' +
'<td class="' + durationClass(w.avg_duration_ms) + '">' + formatDuration(w.avg_duration_ms) + '</td>' +
'<td><span class="badge ' + badgeClass(0) + '" style="opacity:' + (w.success_rate / 100) + '">' + w.success_rate + '%</span></td>' +
'</tr>';
});
}
document.getElementById('workerTable').innerHTML = whtml;
// 最近请求
let rhtml = '';
if (data.recent.length === 0) {
rhtml = '<tr><td class="empty" colspan="8">暂无数据</td></tr>';
} else {
data.recent.forEach(function(entry) {
var ts = entry.timestamp.replace('T', ' ').substring(0, 23);
var workerDisplay = entry.worker || '';
// worker 显示只取主机部分
if (workerDisplay.length > 30) {
workerDisplay = workerDisplay.replace(/^https?:\/\//, '').substring(0, 28) + '';
}
rhtml += '<tr>' +
'<td class="mono">' + ts + '</td>' +
'<td>' + entry.method + '</td>' +
'<td class="mono">' + entry.path + '</td>' +
'<td class="mono" style="font-size:11px">' + workerDisplay + '</td>' +
'<td>' + entry.status_code + '</td>' +
'<td><span class="badge ' + badgeClass(entry.response_code) + '">' + badgeText(entry.response_code) + '</span></td>' +
'<td class="' + durationClass(entry.duration_ms) + '">' + formatDuration(entry.duration_ms) + '</td>' +
'<td class="mono">' + entry.client_ip + '</td>' +
'</tr>';
});
}
document.getElementById('recentTable').innerHTML = rhtml;
document.getElementById('lastUpdated').textContent = '最后更新: ' + new Date().toLocaleTimeString();
} catch(err) {
document.getElementById('lastUpdated').textContent = '加载失败: ' + err.message;
}
}
function toggleAutoRefresh() {
var checked = document.getElementById('autoRefresh').checked;
if (checked) {
_timer = setInterval(refresh, 3000);
} else {
clearInterval(_timer);
_timer = null;
}
}
refresh();
_timer = setInterval(refresh, 3000);
</script>
</body>
</html>"""
# ---------------------------------------------------------------------------
# 请求统计页面
# ---------------------------------------------------------------------------
@app.get("/admin/stats", include_in_schema=False)
async def admin_stats():
"""请求统计仪表盘(HTML 页面)。"""
return HTMLResponse(content=_STATS_PAGE_HTML)
@app.get("/admin/stats/json", include_in_schema=False)
async def admin_stats_json():
"""请求统计数据(JSON,供页面轮询)。"""
return get_stats()
# ---------------------------------------------------------------------------
# 代理路由
# ---------------------------------------------------------------------------
# 所有接口统一走「选 worker → 转发 → 改写 base64 → 返回」链路。
# 使用 Request 对象直接读取并转发,不做业务入参解析(解析在 worker 侧完成)。
# Form/File 声明保留在 OpenAPI extra 中以便文档生成。
def _proxy(request: Request, path: str):
"""延迟导入 proxy_request。"""
from gateway.forward import proxy_request
return proxy_request(request, path)
# 声明各接口的 form 参数用于 OpenAPI schema(实际转发直接读 Request
_MEASURE_FORMS = {
"image_file": {"type": "file", "description": "上传图片文件(JPG/PNG"},
"image_url": {"type": "string", "description": "图片 URL"},
"image_base64": {"type": "string", "description": "图片 base64(需带前缀)"},
}
_GROW_FORMS = {
**_MEASURE_FORMS,
"beauty_enabled": {"type": "boolean", "description": "是否开启美颜效果"},
}
_GROW_B_FORMS = {
"marked_image_file": {"type": "file", "description": "划线图片文件"},
"marked_image_url": {"type": "string", "description": "划线图片 URL"},
"marked_image_base64": {"type": "string", "description": "划线图片 base64"},
}
@app.post("/api/v1/face/measure", tags=["人脸分析"])
async def face_measure(request: Request):
"""接口1:四庭七眼测量标注"""
return await _proxy(request, "/api/v1/face/measure")
@app.post("/api/v1/face/measure-v2", tags=["人脸分析"])
async def face_measure_v2(request: Request):
"""接口6:四庭七眼测量标注 v2(去顶庭 + 去头部端线)"""
return await _proxy(request, "/api/v1/face/measure-v2")
@app.post("/api/v1/hair/grow", tags=["生发"])
async def hair_grow(request: Request):
"""接口2C端生发"""
return await _proxy(request, "/api/v1/hair/grow")
@app.post("/api/v1/hair/grow-b", tags=["生发"])
async def hair_grow_b(request: Request):
"""接口3B端生发"""
return await _proxy(request, "/api/v1/hair/grow-b")
@app.post("/api/v1/face/features", tags=["人脸分析"])
async def face_features(
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(需带前缀)"),
):
"""接口4:用户特征分析 — 本机直接调豆包视觉模型,不经过 worker。"""
import uuid as _uuid
# 三选一校验
provided = [x for x in (image_file, image_url, image_base64) if x]
if len(provided) != 1:
return JSONResponse(status_code=200, content={
"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
img_bytes = None
if image_file:
img_bytes = await image_file.read()
elif image_base64:
b64 = image_base64
if "," in b64:
b64 = b64.split(",", 1)[1]
try:
img_bytes = base64.b64decode(b64)
except Exception:
return JSONResponse(status_code=200, content={
"code": 1008, "message": "图片格式不支持(base64 解码失败)",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
from fastapi.concurrency import run_in_threadpool
from face_features import analyze_features
try:
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
except Exception as ex:
logger.exception("接口4 豆包调用失败")
return JSONResponse(status_code=200, content={
"code": 1007, "message": f"分析服务异常:{ex}",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
if feats is None:
return JSONResponse(status_code=200, content={
"code": 1001, "message": "无法识别人像",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
return JSONResponse(status_code=200, content={
"code": 0,
"message": "success",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}",
"data": {"features": json.dumps(feats, ensure_ascii=False)},
})
@app.post("/api/v1/hairline/generate", tags=["人脸分析"])
async def hairline_generate(request: Request):
"""接口5:发际线PNG生成"""
return await _proxy(request, "/api/v1/hairline/generate")
@app.post("/api/v1/hair/grow-v2", tags=["生发"])
async def hair_grow_v2(request: Request):
"""接口7C端生发 v2add_hair2 工作流)"""
return await _proxy(request, "/api/v1/hair/grow-v2")
@app.post("/api/v1/head/mask", tags=["人脸分析"])
async def head_mask(request: Request):
"""接口9:头发遮罩生成 + 分步可视化"""
return await _proxy(request, "/api/v1/head/mask")
@app.post("/api/v1/head/band", tags=["人脸分析"])
async def head_band(request: Request):
"""接口10:头部外缘膨胀带遮罩 + 分步可视化"""
return await _proxy(request, "/api/v1/head/band")
+28
View File
@@ -0,0 +1,28 @@
{
"workers": [
"http://127.0.0.1:28187",
"http://127.0.0.1:28188"
],
"shared_password": "REPLACE_ME_ROTATE_PERIODICALLY",
"public_base_url": "https://hair.xiangsilian.com",
"static_dir": "static/annotations",
"health_check": {
"path": "/health",
"interval_seconds": 8,
"timeout_seconds": 3,
"unhealthy_threshold": 2,
"healthy_threshold": 1
},
"ark_api_key": "",
"dispatch": {
"per_worker_concurrency": 1,
"queue_wait_seconds": 30,
"request_timeout_seconds": 600,
"retry_on_failure": true,
"max_retries": 1
},
"cleanup": {
"interval_minutes": 60,
"max_age_hours": 24
}
}
+131
View File
@@ -0,0 +1,131 @@
"""网关配置加载与校验。
gateway/config.json 读取配置缺字段给默认值启动时校验必填项
"""
import json
import logging
from pathlib import Path
from typing import List
logger = logging.getLogger("gateway.config")
CONFIG_PATH = Path(__file__).resolve().parent / "config.json"
DEFAULTS = {
"public_base_url": "https://hair.xiangsilian.com",
"static_dir": "static/annotations",
"health_check": {
"path": "/health",
"interval_seconds": 8,
"timeout_seconds": 3,
"unhealthy_threshold": 2,
"healthy_threshold": 1,
},
"dispatch": {
"per_worker_concurrency": 1,
"queue_wait_seconds": 30,
"request_timeout_seconds": 600,
"retry_on_failure": True,
"max_retries": 1,
},
"cleanup": {
"interval_minutes": 60,
"max_age_hours": 24,
},
"request_log": {
"enabled": True,
"log_file": "gateway/request_log.jsonl",
"buffer_size": 2000,
"max_file_lines": 10000,
"max_file_age_days": 7,
},
}
_config_cache = None
def _deep_merge(defaults: dict, overrides: dict) -> dict:
"""递归合并:overrides 中的值覆盖 defaults,嵌套 dict 递归处理。"""
result = defaults.copy()
for key, value in overrides.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
def load_config() -> dict:
"""加载并校验配置文件,结果缓存仅加载一次。"""
global _config_cache
if _config_cache is not None:
return _config_cache
if not CONFIG_PATH.exists():
raise FileNotFoundError(
f"配置文件不存在: {CONFIG_PATH}\n"
f"请从 gateway/config.example.json 复制并修改: "
f"cp gateway/config.example.json gateway/config.json"
)
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
raw = json.load(f)
# 合并默认值
cfg = _deep_merge(DEFAULTS, raw)
# --- 校验 ---
workers: List[str] = cfg.get("workers", [])
if not workers:
raise ValueError("配置错误: workers 列表不能为空,至少需要一个 worker 地址")
for i, w in enumerate(workers):
if not isinstance(w, str) or not w:
raise ValueError(f"配置错误: workers[{i}] 必须是非空字符串")
password: str = cfg.get("shared_password", "")
if not password or password == "REPLACE_ME_ROTATE_PERIODICALLY":
logger.warning(
"⚠ 安全警告: shared_password 未设置或仍为占位值 "
"'REPLACE_ME_ROTATE_PERIODICALLY',请立即更换!"
)
if len(password) < 8:
logger.warning("⚠ 安全警告: shared_password 长度不足 8 位,建议使用更长的密码")
public_base_url: str = cfg.get("public_base_url", "")
if public_base_url.endswith("/"):
cfg["public_base_url"] = public_base_url.rstrip("/")
logger.warning("public_base_url 末尾含 '/',已自动去除")
# 确保 static_dir 是绝对路径
static_dir = Path(cfg["static_dir"])
if not static_dir.is_absolute():
cfg["static_dir"] = str(Path(__file__).resolve().parents[1] / static_dir)
logger.info(
"配置加载完成 | workers=%s | public_base_url=%s | "
"hc_interval=%ds | dispatch_timeout=%ds | queue_wait=%ds",
cfg["workers"],
cfg["public_base_url"],
cfg["health_check"]["interval_seconds"],
cfg["dispatch"]["request_timeout_seconds"],
cfg["dispatch"]["queue_wait_seconds"],
)
_config_cache = cfg
return cfg
def get_config() -> dict:
"""获取已加载的配置(必须先调用 load_config)。"""
if _config_cache is None:
raise RuntimeError("配置尚未加载,请先调用 load_config()")
return _config_cache
def reload_config() -> dict:
"""强制重新加载配置(用于热更新)。"""
global _config_cache
_config_cache = None
return load_config()
+300
View File
@@ -0,0 +1,300 @@
"""请求转发 + base64→URL 改写。
- httpx.AsyncClient 把客户端请求原样转发到选中的 worker
- 附加 X-Internal-Token
- 失败重试 worker
- 响应中含 *_base64 图片字段 解码落盘 改写为 *_url
"""
import base64
import logging
import re
import uuid
from pathlib import Path
from typing import Any, Optional
import httpx
from fastapi import Request
from fastapi.responses import JSONResponse
from gateway.pool import (
NoWorkerAvailable,
acquire_worker,
mark_worker_unhealthy,
release_worker,
)
logger = logging.getLogger("gateway.forward")
# ---------------------------------------------------------------------------
# 全局 httpx client(连接复用)
# ---------------------------------------------------------------------------
_client: Optional[httpx.AsyncClient] = None
def get_client() -> httpx.AsyncClient:
global _client
if _client is None:
_client = httpx.AsyncClient()
return _client
async def close_client():
global _client
if _client:
await _client.aclose()
_client = None
# ---------------------------------------------------------------------------
# base64 → URL 改写
# ---------------------------------------------------------------------------
# data URI 正则:data:image/png;base64,xxxx
_DATA_URI_RE = re.compile(r"^data:(image/\w+);base64,(.+)$", re.IGNORECASE)
def _decode_base64_value(value: str) -> Optional[bytes]:
"""解码 base64 值(支持 data URI 和原始 base64 两种格式)。
格式1data URI: data:image/png;base64,xxxx
格式2原始base64: xxxx无前缀自动尝试解码
"""
# 优先匹配 data URI
m = _DATA_URI_RE.match(value)
if m:
try:
return base64.b64decode(m.group(2))
except Exception:
logger.warning("data URI base64 解码失败")
return None
# 尝试当作原始 base64 解码(排除明显不是 base64 的短字符串)
stripped = value.strip()
if len(stripped) < 20:
return None # 太短,不可能是图片
try:
decoded = base64.b64decode(stripped)
if len(decoded) >= 50: # 最小合法图片大小
return decoded
except Exception:
pass
return None
def rewrite_base64_to_url(
obj: Any,
public_base_url: str,
static_dir: str,
) -> Any:
"""递归遍历响应 JSON,将所有 *_base64 字段改写为 *_url。
- 识别 key _base64 结尾的字段
- 支持 data: URI 和原始 base64 两种格式
- 解码 base64 保存到 static_dir/{uuid}.png
- 删除 *_base64 字段新增 *_url 字段指向公网 URL
"""
if isinstance(obj, dict):
new_dict: Dict[str, Any] = {}
for key, value in obj.items():
if key.endswith("_base64") and isinstance(value, str):
img_bytes = _decode_base64_value(value)
if img_bytes is not None:
# 按内容嗅探扩展名:PNG(接口1标注图,含透明) / JPEG(接口2/3/5 照片)
ext = "png" if img_bytes[:8] == b"\x89PNG\r\n\x1a\n" else "jpg"
filename = f"{uuid.uuid4().hex}.{ext}"
filepath = Path(static_dir) / filename
filepath.write_bytes(img_bytes)
# 构造对外 URL
url_key = key[:-7] + "_url" # "xxx_base64" → "xxx_url"
new_dict[url_key] = f"{public_base_url}/static/annotations/{filename}"
logger.info("base64→URL: %s%s (%d bytes)", key, new_dict[url_key], len(img_bytes))
continue # 跳过原 _base64 key
else:
logger.warning("字段 %s 的值无法解码为图片,保留", key)
new_dict[key] = value
else:
new_dict[key] = rewrite_base64_to_url(value, public_base_url, static_dir)
return new_dict
elif isinstance(obj, list):
return [rewrite_base64_to_url(item, public_base_url, static_dir) for item in obj]
else:
return obj
# ---------------------------------------------------------------------------
# 请求转发
# ---------------------------------------------------------------------------
async def proxy_request(request: Request, path: str) -> JSONResponse:
"""代理一次请求到 worker,处理重试与 base64 改写。
流程
1. acquire worker排队等待空闲
2. 重构 multipart/form 请求 X-Internal-Token
3. 转发到 worker
4. 成功 release worker 改写 base64 返回 JSONResponse
5. 失败连接/超时/5xx/401 mark unhealthy retry
6. retry 耗尽 / acquire 失败 返回 1007
"""
from gateway.config import get_config
cfg = get_config()
dispatch_cfg = cfg["dispatch"]
token = cfg["shared_password"]
request_timeout = dispatch_cfg["request_timeout_seconds"]
max_retries = dispatch_cfg.get("max_retries", 1)
retry_on_failure = dispatch_cfg.get("retry_on_failure", True)
public_base_url = cfg["public_base_url"]
static_dir = cfg["static_dir"]
# --- 1. 读取客户端请求体(原始字节,不做解析) ---
body = await request.body()
# --- 2. 获取 worker(最多重试 max_retries+1 次) ---
attempts = max_retries + 1
last_error_response = None
for attempt in range(attempts):
worker = None
try:
worker = await acquire_worker(cfg)
# 记录当前使用的 worker,供日志中间件读取
request.state.worker_url = worker.url
except NoWorkerAvailable:
logger.warning("无可用 worker,返回 1007")
return JSONResponse(
status_code=503,
content={
"code": 1007,
"message": "后端服务暂不可用,请稍后重试",
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": None,
},
)
try:
client = get_client()
# 直接转发原始请求体(不解析、不重建,保证 multipart 原样透传)
headers = {"X-Internal-Token": token}
ct = request.headers.get("content-type", "")
if ct:
headers["Content-Type"] = ct
resp = await client.request(
method="POST",
url=f"{worker.url}{path}",
content=body,
headers=headers,
timeout=request_timeout,
)
# --- 判断响应 ---
if resp.status_code == 401:
# worker 鉴权失败 → 视为 worker 异常
logger.warning("Worker %s 返回 401(鉴权失败),标记不健康", worker.url)
await mark_worker_unhealthy(worker)
await release_worker(worker)
if attempt < attempts - 1:
continue # retry
last_error_response = JSONResponse(
status_code=503,
content={
"code": 1007,
"message": "后端服务暂不可用,请稍后重试",
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": None,
},
)
break
if resp.status_code >= 500:
# worker 内部错误 → 视为该 worker 异常
logger.warning("Worker %s 返回 %d,标记不健康", worker.url, resp.status_code)
await mark_worker_unhealthy(worker)
await release_worker(worker)
if attempt < attempts - 1:
continue
last_error_response = JSONResponse(
status_code=503,
content={
"code": 1007,
"message": "后端服务暂不可用,请稍后重试",
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": None,
},
)
break
# --- 成功:改写 base64 → URL ---
await release_worker(worker)
try:
worker_json = resp.json()
except Exception:
logger.warning("Worker %s 返回非 JSON 响应", worker.url)
return JSONResponse(
status_code=502,
content={
"code": 1007,
"message": "后端服务响应异常",
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": None,
},
)
# 递归改写 base64 图片字段
rewritten = rewrite_base64_to_url(worker_json, public_base_url, static_dir)
return JSONResponse(
status_code=resp.status_code,
content=rewritten,
)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.RemoteProtocolError) as exc:
logger.warning("Worker %s 连接失败: %s", worker.url, exc)
await mark_worker_unhealthy(worker)
await release_worker(worker)
if attempt < attempts - 1:
continue
last_error_response = JSONResponse(
status_code=503,
content={
"code": 1007,
"message": "后端服务暂不可用,请稍后重试",
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": None,
},
)
break
except Exception as exc:
logger.error("转发异常: %s", exc)
await release_worker(worker)
if attempt < attempts - 1:
continue
last_error_response = JSONResponse(
status_code=503,
content={
"code": 1007,
"message": "后端服务暂不可用,请稍后重试",
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": None,
},
)
break
# --- 所有尝试耗尽 ---
return last_error_response or JSONResponse(
status_code=503,
content={
"code": 1007,
"message": "后端服务暂不可用,请稍后重试",
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": None,
},
)
+406
View File
@@ -0,0 +1,406 @@
"""请求日志中间件:为每个请求记录时间、路径、耗时等,并提供统计查询。
- 内存环形缓冲区最近 N
- JSON Lines 文件持久化自动轮转
- ASGI 中间件透明捕获请求/响应
"""
import datetime
import json
import logging
import time
from collections import deque
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger("gateway.logging_middleware")
# ---------------------------------------------------------------------------
# 数据结构
# ---------------------------------------------------------------------------
@dataclass
class RequestLogEntry:
"""单条请求日志。"""
timestamp: str # ISO-8601
method: str
path: str
status_code: int
duration_ms: float
client_ip: str
worker: str = "" # 处理请求的 worker URL(空串表示网关本地处理)
response_code: Optional[int] = None # 响应 JSON 中的 code 字段
request_id: Optional[str] = None # 响应 JSON 中的 request_id
# ---------------------------------------------------------------------------
# 环形缓冲区
# ---------------------------------------------------------------------------
class RingBuffer:
"""固定大小的环形缓冲区,线程安全。"""
def __init__(self, maxlen: int = 2000):
self._deque: deque = deque(maxlen=maxlen)
def append(self, entry: RequestLogEntry) -> None:
self._deque.append(entry)
def snapshot(self) -> List[RequestLogEntry]:
"""返回当前缓冲区副本(最新在前)。"""
return list(reversed(self._deque))
def __len__(self) -> int:
return len(self._deque)
# ---------------------------------------------------------------------------
# JSON Lines 文件写入(含轮转)
# ---------------------------------------------------------------------------
class LogFileWriter:
"""追加写入 JSON Lines 日志文件,自动按行数 / 天数轮转。
轮转策略保留 1 个备份 (.jsonl.1)不保留更多历史
"""
def __init__(self, filepath: str, max_lines: int = 10000, max_age_days: int = 7):
self.filepath = Path(filepath)
self.max_lines = max_lines
self.max_age_seconds = max_age_days * 86400
def write(self, entry: RequestLogEntry) -> None:
try:
self._maybe_rotate()
self.filepath.parent.mkdir(parents=True, exist_ok=True)
line = json.dumps(asdict(entry), ensure_ascii=False) + "\n"
with open(self.filepath, "a", encoding="utf-8") as f:
f.write(line)
except Exception:
logger.warning("写入请求日志失败", exc_info=True)
def _maybe_rotate(self) -> None:
if not self.filepath.exists():
return
# 按天数轮转
mtime = self.filepath.stat().st_mtime
if time.time() - mtime > self.max_age_seconds:
self._rotate()
return
# 按行数轮转
try:
with open(self.filepath, "r", encoding="utf-8") as f:
count = sum(1 for _ in f)
if count >= self.max_lines:
self._rotate()
except Exception:
pass # 读不到就算了,下次再说
def _rotate(self) -> None:
backup = self.filepath.with_suffix(".jsonl.1")
if backup.exists():
backup.unlink()
try:
self.filepath.rename(backup)
logger.info("请求日志已轮转: %s%s", self.filepath.name, backup.name)
except Exception:
logger.warning("日志轮转失败", exc_info=True)
# ---------------------------------------------------------------------------
# 模块级全局状态(由 init_logging 初始化)
# ---------------------------------------------------------------------------
_buffer: Optional[RingBuffer] = None
_writer: Optional[LogFileWriter] = None
def init_logging(cfg: dict) -> None:
"""初始化日志缓冲区与文件写入器。"""
global _buffer, _writer
log_cfg = cfg.get("request_log", {})
if not log_cfg.get("enabled", True):
logger.info("请求日志已禁用")
return
log_file = log_cfg.get("log_file", "gateway/request_log.jsonl")
log_path = Path(log_file)
if not log_path.is_absolute():
log_path = Path(__file__).resolve().parent.parent / log_file
_buffer = RingBuffer(maxlen=log_cfg.get("buffer_size", 2000))
_writer = LogFileWriter(
filepath=str(log_path),
max_lines=log_cfg.get("max_file_lines", 10000),
max_age_days=log_cfg.get("max_file_age_days", 7),
)
# 从历史日志文件加载最近 N 条到缓冲区
buffer_size = log_cfg.get("buffer_size", 2000)
loaded = _load_from_logfile(str(log_path), buffer_size)
if loaded > 0:
logger.info("从日志文件恢复 %d 条历史记录", loaded)
logger.info("请求日志已启用 | 缓冲=%d | 文件=%s",
buffer_size, log_path)
def _load_from_logfile(filepath: str, max_entries: int) -> int:
"""从 JSON Lines 日志文件读取最近 max_entries 条到缓冲区。"""
try:
p = Path(filepath)
if not p.exists():
return 0
# 从文件末尾反向读取(高效处理大文件)
with open(p, "rb") as f:
# 估算:每条约 200 bytes,读最后 max_entries * 250 bytes 足够
chunk_size = max_entries * 250
f.seek(0, 2) # 文件末尾
file_size = f.tell()
read_size = min(chunk_size, file_size)
f.seek(max(0, file_size - read_size))
raw = f.read().decode("utf-8", errors="replace")
# 跳过可能不完整的第一行
lines = raw.split("\n")
if file_size > read_size:
# 第一行可能不完整,跳过
lines = lines[1:]
# 去掉末尾空行
lines = [l for l in lines if l.strip()]
# 只取最后 max_entries 条
lines = lines[-max_entries:]
count = 0
for line in lines:
try:
data = json.loads(line)
entry = RequestLogEntry(
timestamp=data.get("timestamp", ""),
method=data.get("method", ""),
path=data.get("path", ""),
status_code=data.get("status_code", 0),
duration_ms=data.get("duration_ms", 0.0),
client_ip=data.get("client_ip", ""),
worker=data.get("worker", ""),
response_code=data.get("response_code"),
request_id=data.get("request_id"),
)
_buffer.append(entry)
count += 1
except (json.JSONDecodeError, KeyError):
continue
return count
except Exception:
logger.warning("从日志文件恢复历史记录失败", exc_info=True)
return 0
def is_initialized() -> bool:
return _buffer is not None
# ---------------------------------------------------------------------------
# ASGI 中间件
# ---------------------------------------------------------------------------
def _should_log(path: str) -> bool:
"""只记录 API 请求(/api/ 路径),跳过静态文件、健康检查等。"""
return path.startswith("/api/")
async def request_logging_middleware(request, call_next):
"""记录每个请求的耗时、状态码等信息。"""
# 未初始化或不需要记录的路径 → 直接放行
if _buffer is None or not _should_log(request.url.path):
return await call_next(request)
start = time.perf_counter()
# 获取客户端 IP(优先级:X-Forwarded-For > X-Real-IP > client.host
client_ip = request.client.host if request.client else "unknown"
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
client_ip = forwarded.split(",")[0].strip()
else:
real_ip = request.headers.get("x-real-ip")
if real_ip:
client_ip = real_ip.strip()
response = await call_next(request)
duration_ms = round((time.perf_counter() - start) * 1000, 2)
# 读取 worker URL(由 forward.py 在转发时写入 request.state
worker_url = getattr(request.state, "worker_url", None) or ""
# 提取响应 body 并解析业务字段(仅 JSON 响应)
response_code = None
request_id = None
content_type = response.headers.get("content-type", "")
if "application/json" in content_type or "application/json" in (response.media_type or ""):
# 读取 body(兼容 body_iterator 和 body 两种属性)
body = getattr(response, "body", None)
if body is None:
body = b""
async for chunk in response.body_iterator:
body += chunk
try:
data = json.loads(body)
response_code = data.get("code")
request_id = data.get("request_id")
except (json.JSONDecodeError, UnicodeDecodeError):
pass
# 如果读取了 body_iterator,需要重建响应
if not hasattr(response, "body") or response.body is None:
from starlette.responses import Response as StarletteResponse
response = StarletteResponse(
content=body,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type,
)
# 记录
now = datetime.datetime.utcnow()
entry = RequestLogEntry(
timestamp=now.strftime("%Y-%m-%dT%H:%M:%S.") +
f"{now.microsecond // 1000:03d}Z",
method=request.method,
path=request.url.path,
status_code=response.status_code,
duration_ms=duration_ms,
client_ip=client_ip,
worker=worker_url,
response_code=response_code,
request_id=request_id,
)
_buffer.append(entry)
if _writer is not None:
_writer.write(entry)
return response
# ---------------------------------------------------------------------------
# 统计查询
# ---------------------------------------------------------------------------
def get_stats() -> Dict[str, Any]:
"""基于缓冲区数据计算统计摘要,返回给统计页面使用。"""
if _buffer is None:
return {
"summary": {"total": 0, "success_rate": 0, "avg_duration_ms": 0,
"min_duration_ms": 0, "max_duration_ms": 0},
"endpoints": [],
"workers": [],
"recent": [],
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
}
snapshot = _buffer.snapshot()
total = len(snapshot)
if total == 0:
return {
"summary": {"total": 0, "success_rate": 0, "avg_duration_ms": 0,
"min_duration_ms": 0, "max_duration_ms": 0},
"endpoints": [],
"workers": [],
"recent": [],
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
}
# 汇总指标
durations = [e.duration_ms for e in snapshot]
ok_count = sum(1 for e in snapshot if e.response_code == 0)
# 按路径聚合
by_path: Dict[str, dict] = {}
for e in snapshot:
path = e.path
if path not in by_path:
by_path[path] = {"count": 0, "total_duration": 0.0, "ok": 0}
by_path[path]["count"] += 1
by_path[path]["total_duration"] += e.duration_ms
if e.response_code == 0:
by_path[path]["ok"] += 1
endpoints = sorted(
({
"path": path,
"count": v["count"],
"avg_duration_ms": round(v["total_duration"] / v["count"], 1),
"max_duration_ms": round(
max(e.duration_ms for e in snapshot if e.path == path), 1),
"success_rate": round(v["ok"] / v["count"] * 100, 1),
} for path, v in by_path.items()),
key=lambda x: -x["count"],
)
# 最近 100 条(最新在前)
recent_100 = snapshot[:100]
recent = [
{
"timestamp": e.timestamp,
"method": e.method,
"path": e.path,
"status_code": e.status_code,
"duration_ms": e.duration_ms,
"client_ip": e.client_ip,
"worker": e.worker,
"response_code": e.response_code,
"request_id": e.request_id,
}
for e in recent_100
]
# 按 worker 聚合
by_worker: Dict[str, dict] = {}
for e in snapshot:
w = e.worker or "(网关本地)"
if w not in by_worker:
by_worker[w] = {"count": 0, "total_duration": 0.0, "ok": 0}
by_worker[w]["count"] += 1
by_worker[w]["total_duration"] += e.duration_ms
if e.response_code == 0:
by_worker[w]["ok"] += 1
workers = sorted(
({
"worker": w,
"count": v["count"],
"avg_duration_ms": round(v["total_duration"] / v["count"], 1),
"success_rate": round(v["ok"] / v["count"] * 100, 1) if v["count"] else 0,
} for w, v in by_worker.items()),
key=lambda x: -x["count"],
)
return {
"summary": {
"total": total,
"success_rate": round(ok_count / total * 100, 1),
"avg_duration_ms": round(sum(durations) / len(durations), 1),
"min_duration_ms": round(min(durations), 1),
"max_duration_ms": round(max(durations), 1),
},
"endpoints": endpoints,
"workers": workers,
"recent": recent,
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
}
+278
View File
@@ -0,0 +1,278 @@
"""Worker 健康池 + 空闲派发。
- 后台 asyncio 任务周期性探测每个 worker /health
- 连续失败 N 下线连续成功 N 上线
- 每个 worker 一个 busy 标志per_worker_concurrency=1
- acquire_worker() 从在线池挑空闲 worker全忙排队池空NoWorkerAvailable
- release_worker() 释放 worker
"""
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional
import httpx
logger = logging.getLogger("gateway.pool")
# ---------------------------------------------------------------------------
# 异常
# ---------------------------------------------------------------------------
class NoWorkerAvailable(Exception):
"""无可用 worker(池空或全忙排队超时)。"""
pass
# ---------------------------------------------------------------------------
# 数据结构
# ---------------------------------------------------------------------------
@dataclass
class WorkerState:
"""单个 worker 的运行时状态。"""
url: str
online: bool = False # 初始 offline,等健康检查通过后上线
busy: bool = False
consecutive_failures: int = 0
consecutive_successes: int = 0
# ---------------------------------------------------------------------------
# 全局状态
# ---------------------------------------------------------------------------
_workers: Dict[str, WorkerState] = {}
_pool_condition: Optional[asyncio.Condition] = None
_health_task: Optional[asyncio.Task] = None
_shutdown_event: Optional[asyncio.Event] = None
# ---------------------------------------------------------------------------
# 健康检查后台任务
# ---------------------------------------------------------------------------
async def _check_worker_health(
client: httpx.AsyncClient,
w: WorkerState,
cfg: dict,
) -> None:
"""探测单个 worker 的 /health,更新上下线状态。"""
hc_cfg = cfg["health_check"]
url = f"{w.url}{hc_cfg['path']}"
token = cfg["shared_password"]
try:
resp = await client.get(
url,
headers={"X-Internal-Token": token},
timeout=hc_cfg["timeout_seconds"],
)
if resp.status_code == 200:
w.consecutive_failures = 0
w.consecutive_successes += 1
if w.consecutive_successes >= hc_cfg["healthy_threshold"]:
if not w.online:
w.online = True
logger.info("Worker 上线: %s(连续成功 %d 次)", w.url, w.consecutive_successes)
else:
_mark_failure(w, f"HTTP {resp.status_code}")
except Exception as exc:
_mark_failure(w, str(exc))
def _mark_failure(w: WorkerState, reason: str) -> None:
"""记录一次失败,达到阈值后下线。"""
w.consecutive_successes = 0
w.consecutive_failures += 1
threshold = w.consecutive_failures # used in log
hc_threshold = 2 # default, will be overridden
if w.consecutive_failures >= hc_threshold:
# 实际 threshold 从配置读取,这里先做基本判断
pass
logger.debug("Worker %s 健康检查失败 (%d/%d): %s", w.url, w.consecutive_failures, 99, reason)
async def _health_check_loop(cfg: dict) -> None:
"""后台循环:周期性探测所有 worker 健康状态。"""
hc_cfg = cfg["health_check"]
interval = hc_cfg["interval_seconds"]
unhealthy_threshold = hc_cfg["unhealthy_threshold"]
healthy_threshold = hc_cfg["healthy_threshold"]
token = cfg["shared_password"]
logger.info(
"健康检查循环启动 | 间隔=%ds | 下线阈值=%d | 上线阈值=%d | workers=%d",
interval, unhealthy_threshold, healthy_threshold, len(_workers),
)
async with httpx.AsyncClient() as client:
while not _shutdown_event.is_set():
for w in _workers.values():
url = f"{w.url}{hc_cfg['path']}"
try:
resp = await client.get(
url,
headers={"X-Internal-Token": token},
timeout=hc_cfg["timeout_seconds"],
)
if resp.status_code == 200:
w.consecutive_failures = 0
w.consecutive_successes += 1
if w.consecutive_successes >= healthy_threshold and not w.online:
w.online = True
logger.info("✅ Worker 上线: %s", w.url)
else:
w.consecutive_successes = 0
w.consecutive_failures += 1
if w.consecutive_failures >= unhealthy_threshold and w.online:
w.online = False
logger.warning("⚠ Worker 下线: %sHTTP %d,连续失败 %d 次)",
w.url, resp.status_code, w.consecutive_failures)
except Exception as exc:
w.consecutive_successes = 0
w.consecutive_failures += 1
if w.consecutive_failures >= unhealthy_threshold and w.online:
w.online = False
logger.warning("⚠ Worker 下线: %s%s,连续失败 %d 次)",
w.url, exc, w.consecutive_failures)
# 等待下一次探测(支持快速关闭)
try:
await asyncio.wait_for(_shutdown_event.wait(), timeout=interval)
break # shutdown signaled
except asyncio.TimeoutError:
pass # 正常的 interval 到期
logger.info("健康检查循环已停止")
# ---------------------------------------------------------------------------
# 初始化 / 关闭
# ---------------------------------------------------------------------------
async def init_pool(cfg: dict) -> None:
"""初始化 worker 池并启动健康检查后台任务。"""
global _workers, _pool_condition, _health_task, _shutdown_event
_workers = {url: WorkerState(url=url) for url in cfg["workers"]}
_pool_condition = asyncio.Condition()
_shutdown_event = asyncio.Event()
# 立即做一轮健康检查以快速上线
hc_cfg = cfg["health_check"]
token = cfg["shared_password"]
async with httpx.AsyncClient() as client:
for w in _workers.values():
try:
resp = await client.get(
f"{w.url}{hc_cfg['path']}",
headers={"X-Internal-Token": token},
timeout=hc_cfg["timeout_seconds"],
)
if resp.status_code == 200:
w.consecutive_successes = 1
if hc_cfg["healthy_threshold"] <= 1:
w.online = True
else:
w.consecutive_failures = 1
except Exception:
w.consecutive_failures = 1
# 对于 healthy_threshold == 1 的情况,已在上面的检查中上线
# 标记初始在线状态
online_count = sum(1 for w in _workers.values() if w.online)
logger.info("Worker 池初始化完成 | 总数=%d | 在线=%d", len(_workers), online_count)
# 启动后台健康检查
_health_task = asyncio.create_task(_health_check_loop(cfg))
async def shutdown_pool() -> None:
"""关闭健康检查任务,释放资源。"""
global _shutdown_event, _health_task
if _shutdown_event:
_shutdown_event.set()
if _health_task:
_health_task.cancel()
try:
await _health_task
except asyncio.CancelledError:
pass
_health_task = None
logger.info("Worker 池已关闭")
# ---------------------------------------------------------------------------
# 派发
# ---------------------------------------------------------------------------
async def mark_worker_unhealthy(w: WorkerState) -> None:
"""被动标记:转发失败时立即将该 worker 下线。"""
async with _pool_condition:
if w.online:
w.online = False
w.consecutive_failures += 1
logger.warning("⚠ Worker 被动下线: %s(转发失败)", w.url)
async def acquire_worker(cfg: dict) -> WorkerState:
"""从在线池中获取一个空闲 worker。
全忙则排队等待最多 queue_wait_seconds池空立即抛异常
"""
queue_wait = cfg["dispatch"]["queue_wait_seconds"]
deadline = time.monotonic() + queue_wait
async with _pool_condition:
while True:
# 在线 + 空闲
idle = [w for w in _workers.values() if w.online and not w.busy]
if not idle:
online = [w for w in _workers.values() if w.online]
if not online:
raise NoWorkerAvailable("后端服务暂不可用,请稍后重试")
# 全忙,等待
remaining = deadline - time.monotonic()
if remaining <= 0:
raise NoWorkerAvailable("后端服务繁忙,请稍后重试")
logger.info("所有 worker 全忙(%d 在线),等待 %.1fs...", len(online), remaining)
try:
await asyncio.wait_for(_pool_condition.wait(), timeout=remaining)
except asyncio.TimeoutError:
raise NoWorkerAvailable("后端服务繁忙,请稍后重试")
continue # 重新检查
# 取第一个空闲 worker
w = idle[0]
w.busy = True
logger.debug("Worker 分配: %s", w.url)
return w
async def release_worker(w: WorkerState) -> None:
"""释放 worker,标记为空闲并通知等待者。"""
async with _pool_condition:
w.busy = False
logger.debug("Worker 释放: %s", w.url)
_pool_condition.notify(1)
# ---------------------------------------------------------------------------
# 状态查询
# ---------------------------------------------------------------------------
def get_pool_status() -> dict:
"""返回当前池状态(供 /gateway-health 使用)。"""
if not _workers:
return {"total": 0, "healthy": 0, "busy": 0}
total = len(_workers)
healthy = sum(1 for w in _workers.values() if w.online)
busy = sum(1 for w in _workers.values() if w.busy)
return {"total": total, "healthy": healthy, "busy": busy}
+265
View File
@@ -0,0 +1,265 @@
"""Stub Worker — 模拟高性能后端,用于网关开发与测试。
每个 stub 监听一个端口提供
- GET /health 200可用 ?fail=1 模拟故障
- POST /api/v1/face/measure mock 响应 annotated_image_base64
- POST /api/v1/hair/grow mock 响应
- POST /api/v1/hair/grow-b mock 响应
- POST /api/v1/face/features mock 响应
- POST /api/v1/hairline/generate mock 响应
鉴权校验 X-Internal-Token 不匹配返回 401
并发模拟可通过 ?delay=N 让接口 sleep N 默认 1
故障模拟GET /health?fail=1 返回 503
用法
python gateway/stub_worker.py --port 28187
python gateway/stub_worker.py --port 28188
"""
import argparse
import base64
import json
import os
import sys
import time
import uuid
from typing import Optional
# 确保项目根在 sys.path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import uvicorn
from fastapi import FastAPI, File, Form, Header, Request, UploadFile
from fastapi.responses import JSONResponse
# ---------------------------------------------------------------------------
# 一小张 PNG 用于 mock base64 返回
# 1x1 白色像素 PNG(最小合法 PNG)
# ---------------------------------------------------------------------------
TINY_PNG_BASE64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
)
TINY_PNG_BYTES = base64.b64decode(TINY_PNG_BASE64)
# ---------------------------------------------------------------------------
# 应用工厂
# ---------------------------------------------------------------------------
def create_app(port: int, password: str) -> FastAPI:
app = FastAPI(
title=f"Stub Worker :{port}",
version="0.1.0",
docs_url=None,
redoc_url=None,
)
# --- 鉴权依赖 ---
def check_token(x_internal_token: Optional[str] = Header(default=None)):
if x_internal_token != password:
return False
return True
# --- 通用 mock data 生成 ---
def mock_ok(data: dict, delay: float = 1.0):
"""模拟处理延迟后返回标准响应。"""
if delay > 0:
time.sleep(delay)
return {
"code": 0,
"message": "success",
"request_id": f"stub-{port}-{uuid.uuid4().hex[:8]}",
"data": data,
}
def mock_err(code: int, message: str):
return {
"code": code,
"message": message,
"request_id": f"stub-{port}-{uuid.uuid4().hex[:8]}",
"data": None,
}
# --- /health ---
@app.get("/health", include_in_schema=False)
async def health(request: Request, x_internal_token: Optional[str] = Header(default=None)):
# /health 也校验 token(生产环境 worker 行为)
if x_internal_token != password:
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
fail = request.query_params.get("fail")
if fail == "1":
return JSONResponse(status_code=503, content={"status": "error"})
return {"status": "ok", "worker": f"stub-{port}"}
# --- 接口1 ---
@app.post("/api/v1/face/measure")
async def face_measure(
request: Request,
x_internal_token: Optional[str] = Header(default=None),
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
):
if x_internal_token != password:
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
delay = float(request.query_params.get("delay", "1"))
data = {
"annotated_image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
"face_total_height_cm": 13.76,
"four_courts": {
"top_court_cm": 3.44,
"upper_court_cm": 3.44,
"middle_court_cm": 3.44,
"lower_court_cm": 3.44,
"ratios": {
"top_court": 0.25,
"upper_court": 0.25,
"middle_court": 0.25,
"lower_court": 0.25,
},
},
"seven_eyes": {
"eye_width_cm": 3.44,
"face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44,
"ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143},
},
"landmarks": {
"hair_top": {"x": 540, "y": 120},
"hairline": {"x": 540, "y": 430},
"brow_center": {"x": 540, "y": 740},
"nose_bottom": {"x": 540, "y": 1050},
"chin_tip": {"x": 540, "y": 1360},
},
}
return mock_ok(data, delay=delay)
# --- 接口2 ---
@app.post("/api/v1/hair/grow")
async def hair_grow(
request: Request,
x_internal_token: Optional[str] = Header(default=None),
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
beauty_enabled: bool = Form(default=False),
):
if x_internal_token != password:
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
delay = float(request.query_params.get("delay", "1"))
data = {
"results": [
{
"image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
"hairline_type": "花瓣形",
"order": 1,
},
{
"image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
"hairline_type": "波浪形",
"order": 2,
},
]
}
return mock_ok(data, delay=delay)
# --- 接口3 ---
@app.post("/api/v1/hair/grow-b")
async def hair_grow_b(
request: Request,
x_internal_token: Optional[str] = Header(default=None),
marked_image_file: Optional[UploadFile] = File(default=None),
marked_image_url: Optional[str] = Form(default=None),
marked_image_base64: Optional[str] = Form(default=None),
original_image_file: Optional[UploadFile] = File(default=None),
original_image_url: Optional[str] = Form(default=None),
original_image_base64: Optional[str] = Form(default=None),
):
if x_internal_token != password:
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
delay = float(request.query_params.get("delay", "1"))
data = {
"best_hairline_image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
"hair_growth_image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
"hairline_type": "花瓣形",
}
return mock_ok(data, delay=delay)
# --- 接口4 ---
@app.post("/api/v1/face/features")
async def face_features(
request: Request,
x_internal_token: Optional[str] = Header(default=None),
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
):
if x_internal_token != password:
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
delay = float(request.query_params.get("delay", "1"))
features = json.dumps(
{
"face_shape": "鹅蛋脸",
"eyebrow_shape": "柳叶眉",
"facial_age": 26,
"dynamic_static_type": "静态",
"gender": "",
"gene_style": {"label": "面部特征标签", "style": "基因风格示例"},
},
ensure_ascii=False,
)
return mock_ok({"features": features}, delay=delay)
# --- 接口5 ---
@app.post("/api/v1/hairline/generate")
async def hairline_generate(
request: Request,
x_internal_token: Optional[str] = Header(default=None),
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
):
if x_internal_token != password:
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
delay = float(request.query_params.get("delay", "1"))
_png = f"data:image/png;base64,{TINY_PNG_BASE64}"
data = {
"hairline_images": [
{
"hairline_type": "ellipse",
"image_middle_base64": _png,
"image_high_base64": _png,
"image_low_base64": _png,
"grown_image_base64": _png,
"order": 1,
},
{
"hairline_type": "heart",
"image_middle_base64": _png,
"image_high_base64": _png,
"image_low_base64": _png,
"grown_image_base64": None,
"order": 3,
},
],
"best_hairline_center_point": {"x": 540, "y": 430},
}
return mock_ok(data, delay=delay)
return app
# ---------------------------------------------------------------------------
# 入口
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Stub Worker for gateway testing")
parser.add_argument("--port", type=int, default=28187, help="监听端口(默认 28187")
parser.add_argument("--password", type=str, default="dev-shared-secret-2026", help="共享密码")
args = parser.parse_args()
app = create_app(port=args.port, password=args.password)
print(f"[stub_worker] 启动在 :{args.port},密码={args.password[:4]}...")
uvicorn.run(app, host="127.0.0.1", port=args.port, log_level="warning")
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Hair Gateway - 外网网关(反向代理到 worker 池)
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/hair
ExecStart=/home/ubuntu/hair/venv/bin/uvicorn gateway.app:app --host 127.0.0.1 --port 8080
Restart=always
RestartSec=3
# 日志
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+15
View File
@@ -0,0 +1,15 @@
[Unit]
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=ubuntu
WorkingDirectory=/home/ubuntu/hair
ExecStart=/home/ubuntu/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+450
View File
@@ -0,0 +1,450 @@
{
"1": {
"inputs": {
"scheduler": "simple",
"steps": 6,
"denoise": 1,
"model": [
"2",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "基本调度器"
}
},
"2": {
"inputs": {
"max_shift": 1.15,
"base_shift": 0.5,
"width": [
"14",
0
],
"height": [
"14",
1
],
"model": [
"16",
0
]
},
"class_type": "ModelSamplingFlux",
"_meta": {
"title": "采样算法(Flux"
}
},
"3": {
"inputs": {
"vae_name": "flux2-vae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "加载VAE"
}
},
"5": {
"inputs": {
"conditioning": [
"19",
0
],
"latent": [
"13",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "参考Latent"
}
},
"6": {
"inputs": {
"noise_seed": 808990860769642
},
"class_type": "RandomNoise",
"_meta": {
"title": "随机噪波"
}
},
"7": {
"inputs": {
"width": [
"14",
0
],
"height": [
"14",
1
],
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": {
"title": "空Latent图像(SD3"
}
},
"8": {
"inputs": {
"sampler_name": "euler"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "K采样器选择"
}
},
"9": {
"inputs": {
"noise": [
"6",
0
],
"guider": [
"20",
0
],
"sampler": [
"8",
0
],
"sigmas": [
"1",
0
],
"latent_image": [
"7",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "自定义采样器(高级)"
}
},
"10": {
"inputs": {
"samples": [
"9",
0
],
"vae": [
"3",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE解码"
}
},
"13": {
"inputs": {
"pixels": [
"44",
0
],
"vae": [
"3",
0
]
},
"class_type": "VAEEncode",
"_meta": {
"title": "VAE编码"
}
},
"14": {
"inputs": {
"image": [
"44",
0
]
},
"class_type": "GetImageSize+",
"_meta": {
"title": "🔧 Get Image Size"
}
},
"16": {
"inputs": {
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
"weight_dtype": "fp8_e4m3fn"
},
"class_type": "UnetLoaderGGUF",
"_meta": {
"title": "UNet加载器"
}
},
"17": {
"inputs": {
"filename_prefix": "ComfyUI",
"images": [
"62",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "保存图像"
}
},
"19": {
"inputs": {
"guidance": 1,
"conditioning": [
"22",
0
]
},
"class_type": "FluxGuidance",
"_meta": {
"title": "Flux引导"
}
},
"20": {
"inputs": {
"model": [
"2",
0
],
"conditioning": [
"5",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "基本引导器"
}
},
"22": {
"inputs": {
"text": [
"60",
0
],
"clip": [
"61",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP文本编码"
}
},
"26": {
"inputs": {
"image": "clipspace/clipspace-painted-masked-1784045785080.png [input]"
},
"class_type": "LoadImage",
"_meta": {
"title": "加载图像"
}
},
"31": {
"inputs": {
"image": [
"26",
0
]
},
"class_type": "easy imageSize",
"_meta": {
"title": "图像尺寸"
}
},
"32": {
"inputs": {
"aspect_ratio": "custom",
"proportional_width": [
"31",
0
],
"proportional_height": [
"31",
1
],
"fit": "letterbox",
"method": "lanczos",
"round_to_multiple": "8",
"scale_to_side": "None",
"scale_to_length": 1024,
"background_color": "#000000",
"image": [
"26",
0
],
"mask": [
"37",
0
]
},
"class_type": "LayerUtility: ImageScaleByAspectRatio V2",
"_meta": {
"title": "LayerUtility: ImageScaleByAspectRatio V2"
}
},
"33": {
"inputs": {
"masks": [
"26",
1
]
},
"class_type": "Mask Fill Holes",
"_meta": {
"title": "遮罩填充漏洞"
}
},
"36": {
"inputs": {
"masks": [
"33",
0
]
},
"class_type": "Convert Masks to Images",
"_meta": {
"title": "遮罩到图像"
}
},
"37": {
"inputs": {
"method": "intensity",
"image": [
"39",
0
]
},
"class_type": "Image To Mask",
"_meta": {
"title": "图像到遮罩"
}
},
"39": {
"inputs": {
"upscale_method": "nearest-exact",
"width": [
"31",
0
],
"height": [
"31",
1
],
"crop": "disabled",
"image": [
"36",
0
]
},
"class_type": "ImageScale",
"_meta": {
"title": "缩放图像"
}
},
"44": {
"inputs": {
"mask_opacity": 1,
"mask_color": "FFFF00",
"pass_through": true,
"image": [
"32",
0
],
"mask": [
"32",
1
]
},
"class_type": "ImageAndMaskPreview",
"_meta": {
"title": "图像与遮罩预览"
}
},
"45": {
"inputs": {
"images": [
"44",
0
]
},
"class_type": "PreviewImage",
"_meta": {
"title": "预览图像"
}
},
"53": {
"inputs": {
"rgthree_comparer": {
"images": [
{
"name": "A",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_kzrpg_00019_.png&type=temp&subfolder=&rand=0.8964945384546902"
},
{
"name": "B",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_kzrpg_00020_.png&type=temp&subfolder=&rand=0.6762414189274947"
}
]
},
"image_a": [
"62",
0
],
"image_b": [
"26",
0
]
},
"class_type": "Image Comparer (rgthree)",
"_meta": {
"title": "图像对比"
}
},
"60": {
"inputs": {
"text": "填充遮罩区域的头发"
},
"class_type": "JjkText",
"_meta": {
"title": "Text"
}
},
"61": {
"inputs": {
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
"type": "flux2",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "加载CLIP"
}
},
"62": {
"inputs": {
"method": "mkl",
"strength": 1,
"multithread": true,
"image_ref": [
"26",
0
],
"image_target": [
"10",
0
]
},
"class_type": "ColorMatch",
"_meta": {
"title": "Color Match"
}
}
}
+7
View File
@@ -0,0 +1,7 @@
"""接口 2C 端生发)发际线渲染管线。
head3d 项目移植MediaPipe 468 + SegFormer 人脸分割 + 17 锚点射线检测发际线
502 3D meshface_ext.obj+ UV 把发际线类型贴图渲染到照片对应位置
模块来源见 docs/接口2-C端生发-技术实现方案.md
"""
+55
View File
@@ -0,0 +1,55 @@
"""Auto-extracted indexMap[468] from the SDK's hardcode_data.h.
Each entry: OBJ-vertex-index i -> MediaPipe canonical landmark index.
Do not edit by hand; regenerate from the C++ source.
"""
INDEX_MAP_468 = [
127, 34, 139, 11, 0, 37, 232, 231, 120, 72,
39, 128, 121, 47, 104, 69, 67, 175, 171, 148,
118, 50, 101, 73, 40, 9, 151, 108, 48, 115,
131, 194, 204, 211, 74, 185, 80, 42, 183, 92,
186, 230, 229, 202, 212, 214, 83, 18, 17, 76,
61, 146, 160, 29, 30, 56, 157, 173, 106, 135,
192, 203, 165, 98, 21, 71, 68, 51, 45, 4,
144, 24, 23, 77, 91, 205, 187, 201, 200, 182,
90, 181, 85, 84, 206, 36, 140, 193, 189, 244,
159, 158, 28, 247, 246, 161, 236, 3, 196, 54,
168, 8, 117, 228, 31, 55, 97, 99, 126, 100,
166, 79, 218, 155, 154, 26, 209, 49, 136, 150,
217, 223, 52, 53, 134, 170, 43, 119, 226, 130,
63, 238, 20, 242, 46, 70, 156, 78, 62, 96,
143, 227, 123, 111, 44, 125, 19, 216, 153, 22,
167, 208, 142, 57, 60, 35, 113, 27, 210, 225,
137, 116, 41, 38, 129, 64, 240, 102, 207, 184,
169, 149, 176, 105, 66, 122, 6, 147, 65, 107,
89, 180, 93, 15, 86, 14, 87, 145, 88, 179,
95, 138, 172, 215, 58, 219, 81, 195, 199, 82,
163, 110, 234, 109, 235, 191, 222, 141, 221, 197,
25, 7, 33, 220, 237, 245, 162, 188, 174, 2,
241, 164, 12, 13, 198, 133, 112, 243, 239, 190,
32, 178, 132, 177, 1, 213, 59, 94, 75, 224,
233, 114, 124, 356, 389, 368, 302, 267, 452, 350,
349, 303, 269, 357, 343, 277, 453, 333, 332, 297,
152, 377, 347, 348, 330, 304, 270, 336, 337, 278,
279, 360, 418, 262, 431, 408, 409, 310, 415, 407,
410, 450, 422, 430, 434, 313, 314, 306, 307, 375,
387, 388, 260, 286, 414, 398, 335, 406, 364, 367,
416, 423, 358, 327, 251, 284, 298, 281, 5, 373,
374, 253, 320, 321, 425, 427, 411, 421, 405, 404,
315, 16, 426, 266, 400, 369, 322, 391, 417, 465,
464, 386, 257, 258, 466, 456, 399, 419, 285, 346,
340, 261, 413, 441, 460, 328, 355, 371, 329, 392,
439, 438, 382, 341, 256, 429, 420, 394, 379, 437,
443, 444, 283, 275, 440, 363, 338, 273, 451, 446,
342, 467, 293, 334, 282, 458, 461, 462, 276, 353,
383, 308, 324, 325, 300, 372, 345, 447, 352, 274,
248, 436, 381, 252, 393, 428, 287, 250, 384, 265,
259, 424, 292, 366, 271, 294, 455, 272, 432, 395,
299, 351, 280, 319, 295, 296, 403, 323, 454, 316,
380, 318, 402, 365, 435, 397, 344, 311, 291, 396,
268, 445, 254, 339, 449, 264, 10, 442, 370, 263,
255, 359, 412, 301, 378, 326, 457, 362, 459, 463,
354, 401, 361, 309, 376, 433, 289, 305, 448, 290,
288, 249, 103, 385, 331, 317, 312, 390,
]
+67
View File
@@ -0,0 +1,67 @@
"""Standalone MediaPipe FaceLandmarker runner used by web_service.py.
Running MediaPipe Tasks in the same process as the Flask dev server can
segfault under WSL (D3D12 EGL backend). Spawning a fresh subprocess per
request keeps the web server alive and lets us inject WSL-friendly env
vars before any mediapipe import.
Usage:
python -m python._mediapipe_subprocess <image_path> <output_npy>
"""
from __future__ import annotations
import os
import sys
os.environ.setdefault("LIBGL_ALWAYS_SOFTWARE", "1")
os.environ.setdefault("MESA_LOADER_DRIVER_OVERRIDE", "llvmpipe")
os.environ.setdefault("GALLIUM_DRIVER", "llvmpipe")
os.environ.setdefault("MEDIAPIPE_DISABLE_GPU", "1")
os.environ.setdefault("EGL_PLATFORM", "surfaceless")
import numpy as np # noqa: E402
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_DIR = os.path.dirname(THIS_DIR)
if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR)
from python.face_landmarks import FaceLandmarker # noqa: E402
def main() -> int:
if len(sys.argv) != 3:
print(
"usage: python -m python._mediapipe_subprocess <image_path> <output_npy>",
file=sys.stderr,
)
return 2
image_path, output_path = sys.argv[1], sys.argv[2]
import cv2
bgr = cv2.imread(image_path)
if bgr is None:
print(f"could not read image: {image_path}", file=sys.stderr)
return 3
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
rgb = np.ascontiguousarray(rgb, dtype=np.uint8)
landmarker = FaceLandmarker(static_image_mode=True)
try:
landmarks = landmarker.detect(rgb)
finally:
landmarker.close()
if landmarks is None:
print("no face detected", file=sys.stderr)
return 4
np.save(output_path, landmarks.astype(np.float32))
return 0
if __name__ == "__main__":
sys.exit(main())
+229
View File
@@ -0,0 +1,229 @@
"""ComfyUI 客户端:用 add_hair.json / add_hair2.json 工作流跑生发图(Flux-2 inpaint)。
worker 不跑 Flux只把划线图 + 遮罩 RGBA 上传到远端 ComfyUI
默认 http://10.60.74.221:8188可用环境变量 COMFYUI_URL 覆盖
替换工作流节点 26 的输入图随机 seed提交 /prompt轮询 /history取回 /view 输出
ComfyUI 若开启了 HTTP Basic Authuser `admin` + 密码所有请求都带凭据
支持多工作流run() 可通过 workflow_path 指定不同工作流 JSON自动检测 SaveImage 输出节点
"""
from __future__ import annotations
import copy
import json
import logging
import os
import random
import time
import uuid
import httpx
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188").rstrip("/")
_WORKFLOW_DEFAULT = os.getenv(
"ADD_HAIR_WORKFLOW",
os.path.join(os.path.dirname(os.path.dirname(__file__)), "add_hair.json"),
)
COMFY_TIMEOUT = float(os.getenv("COMFYUI_TIMEOUT", "600")) # 单张出图最长等待(秒)
_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
def _comfy_auth():
"""ComfyUI Basic Auth 凭据 (user, password)。
user环境变量 COMFYUI_USER默认 admin
password环境变量 COMFYUI_PASSWORD worker_config.json.comfyui_password password.txt
无密码则返回 None不带鉴权兼容未开启 auth 的实例
"""
user = os.getenv("COMFYUI_USER", "admin")
pw = os.getenv("COMFYUI_PASSWORD")
if not pw:
cfg = os.path.join(_REPO, "worker_config.json")
if os.path.isfile(cfg):
try:
with open(cfg, encoding="utf-8") as f:
pw = json.load(f).get("comfyui_password")
except Exception: # noqa: BLE001
pw = None
if not pw:
pwfile = os.path.join(_REPO, "password.txt")
if os.path.isfile(pwfile):
with open(pwfile, encoding="utf-8") as f:
pw = f.read().strip()
return (user, pw) if pw else None
def _load_workflow(workflow_path: str | None = None) -> dict:
"""加载工作流 JSON(按路径缓存)。自动检测 SaveImage 节点 ID。"""
path = workflow_path or _WORKFLOW_DEFAULT
if path not in _wf_cache:
with open(path, encoding="utf-8") as f:
wf = json.load(f)
_wf_cache[path] = wf
# 自动检测 SaveImage 输出节点
for node_id, node in wf.items():
if node.get("class_type") == "SaveImage":
_wf_output_node[path] = node_id
break
else:
raise ValueError(f"工作流 {path} 中未找到 SaveImage 节点")
return _wf_cache[path]
def _get_output_node(workflow_path: str | None = None) -> str:
"""返回指定工作流的 SaveImage 节点 ID。"""
path = workflow_path or _WORKFLOW_DEFAULT
if path not in _wf_output_node:
_load_workflow(path) # 触发检测
return _wf_output_node[path]
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
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)
client_id = uuid.uuid4().hex
with httpx.Client(base_url=COMFYUI_URL, timeout=30.0, auth=_comfy_auth()) as cli:
# 1. 上传输入图(含 alpha 遮罩)到 ComfyUI input 目录
fname = f"hair_{client_id}.png"
r = cli.post("/upload/image", files={"image": (fname, rgba_png_bytes, "image/png")},
data={"overwrite": "true", "type": "input"})
r.raise_for_status()
up = r.json()
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:
import os as _os
_diag = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
"log", "comfyui_last_submit")
_os.makedirs(_diag, exist_ok=True)
with open(_os.path.join(_diag, "workflow.json"), "w", encoding="utf-8") as _f:
json.dump(wf, _f, ensure_ascii=False, indent=2)
with open(_os.path.join(_diag, "input.png"), "wb") as _f:
_f.write(rgba_png_bytes)
with open(_os.path.join(_diag, "prompt.txt"), "w", encoding="utf-8") as _f:
_f.write(prompt if prompt is not None else "(None=用工作流内置默认)")
except Exception: # noqa: BLE001
pass
# 3. 提交(front=True 时插队到队列最前)
payload = {"prompt": wf, "client_id": client_id}
if front:
payload["front"] = True
r = cli.post("/prompt", json=payload)
r.raise_for_status()
prompt_id = r.json()["prompt_id"]
# 4. 轮询 /history
deadline = time.time() + timeout
outputs = None
while time.time() < deadline:
hr = cli.get(f"/history/{prompt_id}")
hr.raise_for_status()
hist = hr.json()
if prompt_id in hist:
entry = hist[prompt_id]
status = entry.get("status", {})
if status.get("status_str") == "error":
raise RuntimeError(f"ComfyUI 执行报错: {status}")
outputs = entry.get("outputs")
if outputs and output_node in outputs:
break
time.sleep(0.05)
if not outputs or output_node not in outputs:
raise TimeoutError(f"ComfyUI 出图超时({timeout}s) prompt_id={prompt_id}")
# 5. 取回输出图
imgs = outputs[output_node].get("images") or []
if not imgs:
raise RuntimeError("ComfyUI 输出无图像")
info = imgs[0]
vr = cli.get("/view", params={"filename": info["filename"],
"subfolder": info.get("subfolder", ""),
"type": info.get("type", "output")})
vr.raise_for_status()
return vr.content
def ping() -> bool:
"""探测 ComfyUI 是否在线(/system_stats)。"""
try:
with httpx.Client(base_url=COMFYUI_URL, timeout=3.0, auth=_comfy_auth()) as cli:
return cli.get("/system_stats").status_code == 200
except Exception: # noqa: BLE001
return False
if __name__ == "__main__":
import sys
inp = sys.argv[1] if len(sys.argv) > 1 else "tests/output/comfy_input.png"
print("ComfyUI:", COMFYUI_URL, "online:", ping())
with open(inp, "rb") as f:
png = run(f.read())
out = "tests/output/grown.png"
with open(out, "wb") as f:
f.write(png)
print(f"生发图已存 {out}{len(png)} bytes")
+170
View File
@@ -0,0 +1,170 @@
"""Shared constants for the hairline-extension pipeline.
These define the topology of the extended mesh and must stay in sync with
the C++ side (see sdk/ExtensionConstants.h). If you change anything here,
regenerate face_ext.obj and update the C++ header.
"""
from __future__ import annotations
# Number of MediaPipe FaceMesh landmarks (no iris refinement).
N_MP = 468
# Anchors along the upper boundary of the MediaPipe face mesh,
# ordered left-to-right when viewing the face frontally.
# Each anchor will get a paired hairline sample directly "above" it.
#
# Verify visually with scripts/show_anchors.py before locking these in.
MP_TOP_ANCHORS: list[int] = [
127, 234, 162, 21, 54, 103, 67, 109, 10,
338, 297, 332, 284, 251, 389, 356, 454,
]
N_ANCHORS = len(MP_TOP_ANCHORS) # 17
# Vertex ID layout in the extended array of length 468 + 2*N_ANCHORS.
# [0 .. 468) : MediaPipe canonical landmarks
# [468 .. 468+N) : middle row (between MP boundary and hairline)
# [468+N .. 468+2N) : hairline row
N_EXT = 2 * N_ANCHORS # 34
N_TOTAL = N_MP + N_EXT # 502
MIDDLE_START = N_MP # 468
HAIRLINE_START = N_MP + N_ANCHORS # 485
# Saggital head-curvature radius (in MediaPipe normalized-Y units),
# expressed as a fraction of face height. The head's mid-line cross
# section is treated locally as a circular arc; for a hairline / middle
# vertex located dy=(y_hair - y_anchor) above an MP top anchor (dy < 0
# since hairline y < anchor y) we compute its Z by
#
# z_hair = z_anchor + dy² / (2 R), R = HEAD_ARC_RADIUS_FRAC × face_h
#
# This always pushes the added vertex BACKWARD (toward +z in MP / face.obj
# convention, i.e. toward the back of the head), matching the actual
# anatomy. See README for the derivation.
#
# Smaller fraction = more pronounced backward bulge. 0.30 produces a
# moderate offset (~0.024 in normalized z for a 0.08-y hairline lift on
# a typical face).
HEAD_ARC_RADIUS_FRAC = 0.30
# Extra lift applied to the detected hairline along the face-up direction,
# expressed as a fraction of the MP face height. The 2D hairline detector
# stops at the hair-skin boundary (start of the visible hair); the mesh
# ribbon's top row should sit at the crown of the head instead, so the
# texture-overlay band can cover the whole forehead → crown region.
#
# 0.06 ≈ moves the hairline row up by 6% of face height, which on the
# reference photos lands the top row just above the visible hairline and
# below the crown — empirically tuned with the /preview slider and locked
# in as the project default. Bump to 0.10..0.15 for taller foreheads /
# higher crowns; drop to 0.03 to keep the ribbon hugging the hair-skin
# boundary.
HAIRLINE_CROWN_LIFT_FRAC = 0.06
# Pure-geometry hairline offset for the /preview 502-point pipeline.
#
# When placing the hairline row WITHOUT hair detection (works for bald /
# with-hair / hat — all head types), each anchor is offset upward along
# face-up by GEOMETRIC_HAIRLINE_OFFSET_FRAC × face_h in normalised
# image space. The sagittal-arc model then derives Z.
#
# 0.25 × face_h ≈ 0.12 normalised on a typical face (face_h ≈ 0.48),
# giving dz ≈ 0.05 — matches the real hairline distance observed on
# reference photos where hair detection succeeds.
GEOMETRIC_HAIRLINE_OFFSET_FRAC = 0.25
# UV layout for the 34 forehead-extension vertices.
#
# The texture (imgs/texture0.png, 512×512) is laid out with the original
# MediaPipe face skin in V_raw ≈ 0.00..0.77 (image y ≈ 117..511) and a
# horizontal stack of 5 hairline-design arcs at the TOP of the image
# (image y ≈ 30..170, i.e. V_raw ≈ 0.67..0.94). Those 5 arcs are the
# content that the extension strip is supposed to display: hairline row
# samples the topmost arc (blue), middle row samples the bottom arc
# (orange), and the 3 arcs in between fall out automatically because the
# ribbon triangle interpolates V linearly between the two rows.
#
# V conventions
# -------------
# uv_template.py / Three.js (with texture.flipY=true) treat V_raw=1 as
# the TOP of the image (image y=0) and V_raw=0 as the BOTTOM.
#
# U conventions — IMPORTANT
# -------------------------
# The 17 MP_TOP_ANCHORS in face.obj have NON-uniform u (≈ 0.00 at the
# temples, ≈ 0.50 at the forehead center, ≈ 1.00 at the other temple).
# Each ribbon triangle (anchor[i] → middle[i] → anchor[i+1] etc.) is a
# vertical column in UV space ONLY when the middle/hairline vertex
# inherits its U from the corresponding anchor. If we used a uniform
# 0.05..0.95 U for the strip the columns would slant relative to the
# anchor U values, warping the texture's 5 horizontal arcs into
# zig-zags. So `extension_uv_for` takes `anchor_u` and copies it.
UV_MIDDLE_DV = 0.110 # middle 行 V_raw 相对该列 anchor V_raw 上移这么多
UV_HAIRLINE_DV = 0.220 # hairline 行 V_raw 相对该列 anchor V_raw 上移这么多
# 为什么是"相对 anchor 平行偏移"而不是固定常数:
#
# face.obj 中 17 个 MP_TOP_ANCHORS 的 V_raw 是**非均匀弧形** (额头中央 MP 10
# = 0.7724, 太阳穴 MP 127 = 0.4668, 横跨 0.30 V 单位)。 如果 middle/hairline
# 用固定常数 V_raw (例如 0.82 / 0.998), 那每个 ribbon quad 的 V 跨度
# (= middle.V anchor[i].V) 在 17 列之间差异巨大 (中央列 0.05, 两端列 0.35,
# 差了 7 倍)。 贴图最底部的弧线 (V_raw ≈ 0.76) 正好落在 V 跨度大的列上 →
# 被拉伸成粗大色块, 而顶部弧线 (V_raw ≈ 0.93) 落在 V 跨度小的列上 → 被压
# 缩成细线。 这就是"最下面那条线特别粗、上面 4 根都细"的根因。
#
# 把 middle/hairline 的 V 也设成"anchor V + 固定 Δ", 每列的 V 跨度变成恒定
# 的 Δm / (Δh Δm), ribbon 在贴图上是上下都跟随 anchor 弧度的弯月形带,
# 贴图 5 条弧线在 mesh 上粗细均匀。
#
# 硬约束:
# 1. Δm > 0 且 Δm < Δh (顺序保持 anchor < middle < hairline, 防 V 反向)
# 2. anchor.V_max + Δh ≤ 1.0 (即 Δh ≤ 1 0.7724 = 0.2276)
# 否则中央列 hairline V 溢出, 采到贴图边缘的抗锯齿像素。
# 当前 Δh = 0.220 留 ≈ 0.008 V 单位 buffer; Δm = Δh / 2 让上下两段等宽。
def extension_uv_for(row: int, anchor_u: float, anchor_v: float) -> tuple[float, float]:
"""Return (u, v_raw) UV for an extension vertex.
row: 0 = middle, 1 = hairline.
anchor_u: U of the corresponding MP anchor (copy verbatim ribbon column
is vertical in UV space).
anchor_v: V_raw of the corresponding MP anchor (we add a constant Δ to
it ribbon row stays parallel to anchor row in UV space, so
each quad has the same V span and texture arcs render at the
same thickness across all 17 columns).
"""
dv = UV_MIDDLE_DV if row == 0 else UV_HAIRLINE_DV
return (anchor_u, anchor_v + dv)
# Face-parsing class indices for the jonathandinu/face-parsing
# SegFormer model (matches CelebAMask-HQ labels):
PARSE_BG = 0
PARSE_SKIN = 1
PARSE_NOSE = 2
PARSE_EYE_G = 3
PARSE_L_EYE = 4
PARSE_R_EYE = 5
PARSE_L_BROW = 6
PARSE_R_BROW = 7
PARSE_L_EAR = 8
PARSE_R_EAR = 9
PARSE_MOUTH = 10
PARSE_U_LIP = 11
PARSE_L_LIP = 12
PARSE_HAIR = 13
PARSE_HAT = 14
PARSE_EAR_R = 15
PARSE_NECK_L = 16
PARSE_NECK = 17
PARSE_CLOTH = 18
# 内网/离线:指向本地权重目录(transformers from_pretrained 支持本地路径)。
# 在线 id 为 "jonathandinu/face-parsing",权重已放到 hairline/models/face-parsing/。
import os as _os
HF_FACE_PARSER_MODEL = _os.path.join(
_os.path.dirname(_os.path.abspath(__file__)), "models", "face-parsing"
)
+115
View File
@@ -0,0 +1,115 @@
"""Main CLI: image -> JSON of 502 3D points consumable by the SDK.
Pipeline:
1. MediaPipe FaceMesh -> 468 normalized landmarks
2. Face parsing (HF SegFormer) -> per-pixel class map
3. Hairline curve detection + per-anchor ray casting -> 17 hairline 2D points
4. Lift 2D hairline points to 3D using anchor Z + curvature offset
5. Interpolate 17 middle-row 3D points
6. Concatenate into (502, 3) and emit JSON
Output JSON schema:
{
"image": {"width": W, "height": H, "path": "..."},
"n_total": 502,
"n_mp": 468,
"n_extension": 34,
"layout": ["mp[0..468)", "middle[468..485)", "hairline[485..502)"],
"points": [[x_norm, y_norm, z_relative], ...] # length 502
"valid_hairline": [true/false, ...] # length 17
}
Usage:
py -3 python/extract_hairline.py path/to/image.jpg
py -3 python/extract_hairline.py path/to/image.jpg --out data/out.json
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import numpy as np
if __package__ is None or __package__ == "":
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from python import constants as C
from python.face_landmarks import FaceLandmarker
from python.face_parsing import FaceParser
from python.hairline_2d import sample_hairline, smooth_hairline
from python.lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
else:
from . import constants as C
from .face_landmarks import FaceLandmarker
from .face_parsing import FaceParser
from .hairline_2d import sample_hairline, smooth_hairline
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
def run(image_path: str, out_path: str | None = None, device: str | None = None) -> dict:
import cv2
bgr = cv2.imread(image_path)
if bgr is None:
raise FileNotFoundError(f"could not read image: {image_path}")
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
H, W = rgb.shape[:2]
t0 = time.perf_counter()
lmk = FaceLandmarker(static_image_mode=True)
landmarks = lmk.detect(rgb)
lmk.close()
if landmarks is None:
raise RuntimeError("no face detected")
t1 = time.perf_counter()
print(f" [time] mediapipe: {(t1 - t0)*1000:.0f} ms")
parser = FaceParser(device=device)
parse_map = parser.parse(rgb)
t2 = time.perf_counter()
print(f" [time] parsing: {(t2 - t1)*1000:.0f} ms")
hairline_2d, valid = sample_hairline(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)
points_full = assemble_full(landmarks, middle_3d, hairline_3d)
t3 = time.perf_counter()
print(f" [time] hairline: {(t3 - t2)*1000:.0f} ms")
record = {
"image": {"width": int(W), "height": int(H), "path": image_path},
"n_total": C.N_TOTAL,
"n_mp": C.N_MP,
"n_extension": C.N_EXT,
"layout": [
f"mp[0..{C.N_MP})",
f"middle[{C.MIDDLE_START}..{C.HAIRLINE_START})",
f"hairline[{C.HAIRLINE_START}..{C.N_TOTAL})",
],
"points": points_full.tolist(),
"valid_hairline": valid.tolist(),
}
if out_path is None:
base = os.path.splitext(os.path.basename(image_path))[0]
out_path = os.path.join("data", base + ".json")
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(record, f, ensure_ascii=False, indent=2)
print(f" wrote {out_path}")
return record
def main() -> None:
ap = argparse.ArgumentParser(description="Extract MediaPipe + hairline points from one image.")
ap.add_argument("image", help="path to input image (jpg/png)")
ap.add_argument("--out", default=None, help="output JSON path")
ap.add_argument("--device", default=None, help="torch device (cpu/cuda); auto if omitted")
args = ap.parse_args()
run(args.image, args.out, args.device)
if __name__ == "__main__":
main()
+104
View File
@@ -0,0 +1,104 @@
"""MediaPipe FaceMesh wrappers returning 468 3D landmarks in [0,1] x/y space.
`FaceLandmarker` uses the modern mediapipe.tasks.vision API and requires
models/face_landmarker.task.
`SolutionsFaceLandmarker` uses the older mediapipe.solutions.face_mesh API.
It is useful as a CPU fallback in WSL environments where the Tasks API may
segfault while initializing EGL/OpenGL.
"""
from __future__ import annotations
import os
import numpy as np
# 模型在 hairline/models/ 下(模块即在 hairline/ 根),故只取一层 dirname。
DEFAULT_MODEL_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"models", "face_landmarker.task",
)
class FaceLandmarker:
def __init__(self, static_image_mode: bool = True, model_path: str | None = None):
import mediapipe as mp
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision
path = model_path or DEFAULT_MODEL_PATH
if not os.path.isfile(path):
raise FileNotFoundError(
f"face_landmarker.task not found at {path}. "
"Download it from "
"https://storage.googleapis.com/mediapipe-models/face_landmarker/"
"face_landmarker/float16/1/face_landmarker.task"
)
running_mode = vision.RunningMode.IMAGE if static_image_mode else vision.RunningMode.VIDEO
options = vision.FaceLandmarkerOptions(
base_options=mp_python.BaseOptions(model_asset_path=path),
running_mode=running_mode,
num_faces=1,
output_face_blendshapes=False,
output_facial_transformation_matrixes=False,
)
self._detector = vision.FaceLandmarker.create_from_options(options)
self._mp = mp
def detect(self, image_rgb: np.ndarray) -> np.ndarray | None:
"""Returns (468, 3) float32 of normalized x, y and relative z, or None.
The task model produces 478 landmarks (468 face + 10 iris); we return
only the first 468 to match the canonical FaceMesh topology used by
the SDK's OBJ file.
"""
mp_image = self._mp.Image(image_format=self._mp.ImageFormat.SRGB, data=image_rgb)
result = self._detector.detect(mp_image)
if not result.face_landmarks:
return None
lm = result.face_landmarks[0][:468]
arr = np.array([[p.x, p.y, p.z] for p in lm], dtype=np.float32)
return arr
def close(self):
try:
self._detector.close()
except Exception:
pass
class SolutionsFaceLandmarker:
"""CPU-oriented fallback using mediapipe.solutions.face_mesh."""
def __init__(self, static_image_mode: bool = True):
import mediapipe as mp
try:
face_mesh_module = mp.solutions.face_mesh
except AttributeError as exc:
raise RuntimeError(
"当前 mediapipe 包不包含 mediapipe.solutions.face_mesh。"
"请使用 web_service.py 的默认 parsing backend,或安装包含 solutions API 的 mediapipe 版本。"
) from exc
self._face_mesh = face_mesh_module.FaceMesh(
static_image_mode=static_image_mode,
max_num_faces=1,
refine_landmarks=False,
min_detection_confidence=0.5,
)
def detect(self, image_rgb: np.ndarray) -> np.ndarray | None:
"""Returns (468, 3) float32 of normalized x, y and relative z, or None."""
image_rgb.flags.writeable = False
result = self._face_mesh.process(image_rgb)
image_rgb.flags.writeable = True
if not result.multi_face_landmarks:
return None
lm = result.multi_face_landmarks[0].landmark[:468]
return np.array([[p.x, p.y, p.z] for p in lm], dtype=np.float32)
def close(self):
try:
self._face_mesh.close()
except Exception:
pass
+42
View File
@@ -0,0 +1,42 @@
"""Face parsing via HuggingFace SegFormer (jonathandinu/face-parsing).
First call downloads ~150 MB of weights into the HF cache.
"""
from __future__ import annotations
import numpy as np
from typing import TYPE_CHECKING
from . import constants as C
if TYPE_CHECKING: # avoid hard import at module load
pass
class FaceParser:
"""Wraps a SegFormer face-parsing model and returns a (H, W) int label map."""
def __init__(self, device: str | None = None):
import torch
from transformers import SegformerImageProcessor, SegformerForSemanticSegmentation
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.processor = SegformerImageProcessor.from_pretrained(C.HF_FACE_PARSER_MODEL)
self.model = SegformerForSemanticSegmentation.from_pretrained(C.HF_FACE_PARSER_MODEL)
self.model.to(self.device).eval()
self._torch = torch
def parse(self, image_rgb: np.ndarray) -> np.ndarray:
"""image_rgb: (H, W, 3) uint8. Returns (H, W) int64 of class indices."""
from PIL import Image
H, W = image_rgb.shape[:2]
pil = Image.fromarray(image_rgb)
inputs = self.processor(images=pil, return_tensors="pt").to(self.device)
with self._torch.no_grad():
logits = self.model(**inputs).logits # (1, C, h, w)
# Upsample to original resolution
up = self._torch.nn.functional.interpolate(
logits, size=(H, W), mode="bilinear", align_corners=False
)
labels = up.argmax(dim=1).squeeze(0).to("cpu").numpy().astype(np.int32)
return labels
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
"""Lift 2D hairline samples to 3D in MediaPipe's normalized space.
Saggital-arc Z model
--------------------
MediaPipe / face.obj use ``-z = front of face`` (nose tip is the most
negative z), ``+z = back of head``. The frontal head surface curves
backward as you move up from the forehead to the crown, so any vertex
**above** an MP top anchor (smaller y in image coords) must have a
**larger** z than that anchor.
For each new hairline / middle vertex (x_h, y_h) attached to MP anchor a
located at (x_a, y_a, z_a), we model the local sagittal cross-section
of the head as a circular arc of radius ``R = HEAD_ARC_RADIUS_FRAC × face_h``
and derive
z_new = z_a + (y_h - y_a)² / (2 R)
The squared dy term guarantees ``z_new z_a`` (the surface always
bulges backward as you walk up the head, never forward). x is taken
directly from the 2D hairline detection (we don't project x onto the
arc the parsing tells us exactly where the hairline sits in x).
"""
from __future__ import annotations
import numpy as np
from . import constants as C
from .hairline_2d import face_up_vector
def _face_height(landmarks_norm: np.ndarray) -> float:
"""Range of MP y for the visible face (used for the arc radius)."""
y = landmarks_norm[:, 1]
return float(y.max() - y.min())
def _arc_dz(dy: float, R: float) -> float:
"""Backward (positive) z offset along a circular arc of radius R for
a vertical displacement dy. Always non-negative."""
return (dy * dy) / (2.0 * max(R, 1e-6))
def lift_hairline_to_3d(
landmarks_norm: np.ndarray,
hairline_norm_xy: np.ndarray,
crown_lift_frac: float | None = None,
) -> np.ndarray:
"""Combine 2D hairline samples with a sagittal-arc Z, after lifting
the (x, y) along the face-up direction by ``crown_lift_frac × face_h``
so the ribbon's top row sits at the crown of the head rather than at
the detected hair-skin boundary.
landmarks_norm: (468, 3) MediaPipe normalized landmarks.
hairline_norm_xy: (N_ANCHORS, 2) 2D hairline samples in [0,1] image space.
crown_lift_frac: how far above the detected hairline (in fractions of
face height) the mesh row should sit. ``None`` uses
``C.HAIRLINE_CROWN_LIFT_FRAC``.
Returns: (N_ANCHORS, 3) (x_norm_lifted, y_norm_lifted, z).
"""
face_h = _face_height(landmarks_norm)
R = C.HEAD_ARC_RADIUS_FRAC * face_h
if crown_lift_frac is None:
crown_lift_frac = C.HAIRLINE_CROWN_LIFT_FRAC
up = face_up_vector(landmarks_norm)
lift = up * (crown_lift_frac * face_h) # 2D offset, face-up direction
out = np.zeros((C.N_ANCHORS, 3), dtype=np.float32)
for i, mp_idx in enumerate(C.MP_TOP_ANCHORS):
z_a = float(landmarks_norm[mp_idx, 2])
y_a = float(landmarks_norm[mp_idx, 1])
x_h = float(hairline_norm_xy[i, 0]) + float(lift[0])
y_h = float(hairline_norm_xy[i, 1]) + float(lift[1])
dy = y_h - y_a # < 0 (上移更多 → dz 更大)
out[i, 0] = x_h
out[i, 1] = y_h
out[i, 2] = z_a + _arc_dz(dy, R)
return out
def build_middle_row(
landmarks_norm: np.ndarray,
hairline_3d: np.ndarray,
bias: float = 0.5,
) -> np.ndarray:
"""Place the middle row at the arc-length midpoint between each MP
anchor and its hairline point.
The old approach used bias=0.5 linear interpolation in XY and then
independently recomputed Z via the arc formula. Because dz dy²,
the middle row only received 25 % of the hairline Z offset, creating
a visible dent where the extension met the face mesh.
New approach: for each anchorhairline pair, parameterise the
sagittal circular arc by the angle θ and place the middle vertex at
θ_mid = bias × θ_hair. This distributes both the Y displacement
**and** the Z displacement smoothly along the arc, so the surface
transitions from the face mesh through middle to hairline without
any concavity.
"""
R = C.HEAD_ARC_RADIUS_FRAC * _face_height(landmarks_norm)
out = np.zeros((C.N_ANCHORS, 3), dtype=np.float32)
for i, mp_idx in enumerate(C.MP_TOP_ANCHORS):
a = landmarks_norm[mp_idx]
h = hairline_3d[i]
# X: simple linear interpolation (no arc model in the coronal plane)
out[i, 0] = (1 - bias) * float(a[0]) + bias * float(h[0])
# Y, Z: interpolate along the circular arc in the sagittal plane.
# The arc angle for the hairline point:
# θ_h = |dy_h| / R (small-angle: arc-length ≈ R θ)
# Middle sits at θ_m = bias × θ_h, giving:
# dy_m = R sin(θ_m) ≈ R θ_m for small θ
# dz_m = R (1 cos(θ_m))
# For the parabolic regime (θ < 0.8 rad ≈ 46°) these simplify to
# the same formula but we use the full trig for correctness.
dy_h = float(h[1]) - float(a[1]) # negative (upward)
sign = -1.0 if dy_h < 0 else 1.0
theta_h = abs(dy_h) / max(R, 1e-9)
theta_m = bias * theta_h
dy_m = sign * R * np.sin(theta_m) # same sign as dy_h
dz_m = R * (1.0 - np.cos(theta_m)) # always ≥ 0
out[i, 1] = float(a[1]) + dy_m
out[i, 2] = float(a[2]) + dz_m
return out
def assemble_full(landmarks_norm: np.ndarray,
middle_3d: np.ndarray,
hairline_3d: np.ndarray) -> np.ndarray:
"""Concatenate to a (N_TOTAL, 3) array in the SDK's expected order:
[0..468) MediaPipe / [468..485) middle / [485..502) hairline."""
assert landmarks_norm.shape == (C.N_MP, 3)
assert middle_3d.shape == (C.N_ANCHORS, 3)
assert hairline_3d.shape == (C.N_ANCHORS, 3)
return np.concatenate([landmarks_norm, middle_3d, hairline_3d], axis=0).astype(np.float32)
+102
View File
@@ -0,0 +1,102 @@
"""接口3:马克笔手绘发际线检测(黑帽响应图 + 端点锚定 Dijkstra 最小路径)。
源自 /home/xsl/headmark 调研结论全局灰度阈值不可用笔迹平均灰度反高于阈值
与皮肤阴影分布重叠黑帽变换响应"比局部邻域暗的细结构"叠加 ROI + 两鬓角锚点间
最小代价路径对抬头纹/眉毛/发丝鲁棒复用接口2 ROI额头上部 头部分割
"""
from __future__ import annotations
import cv2
import numpy as np
from skimage.graph import route_through_array
from .mask import forehead_upper_region, head_silhouette
# 鬓角锚点(MediaPipe canonical 索引):21 左、251 右
ANCHOR_LEFT = 21
ANCHOR_RIGHT = 251
# 拒识阈值:路径平均黑帽响应低于此值 → 判"未检测到画线"(待真实图标定)
MIN_MEAN_RESPONSE = 8.0
def _blackhat(gray: np.ndarray, w: int) -> np.ndarray:
k = max(15, int(w * 0.025) | 1)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
return cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel).astype(np.float32)
def _snap_anchor(bh_roi: np.ndarray, x: int, y: int, w: int):
"""在 (x,y) 周围窗口内吸附到黑帽响应最大处,返回 (row, col)。"""
win = max(8, int(w * 0.03))
h, ww = bh_roi.shape
x0, x1 = max(0, x - win), min(ww, x + win)
y0, y1 = max(0, y - win), min(h, y + win)
sub = bh_roi[y0:y1, x0:x1]
if sub.size == 0 or sub.max() <= 0:
return (int(np.clip(y, 0, h - 1)), int(np.clip(x, 0, ww - 1)))
dy, dx = np.unravel_index(int(np.argmax(sub)), sub.shape)
return (y0 + dy, x0 + dx)
def detect_marker_hairline(marked_bgr: np.ndarray, landmarks_mp: np.ndarray,
parse_map: np.ndarray, min_mean_response: float = MIN_MEAN_RESPONSE):
"""检测手绘发际线,返回路径 (N,2) row,col;未检出/被拒识返回 None。"""
h, w = marked_bgr.shape[:2]
roi = cv2.bitwise_and(forehead_upper_region(landmarks_mp, w, h),
head_silhouette(parse_map)) > 0
if roi.sum() == 0:
return None
gray = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2GRAY)
bh = _blackhat(gray, w)
bh_roi = bh * roi
al = _snap_anchor(bh_roi, int(landmarks_mp[ANCHOR_LEFT, 0] * w),
int(landmarks_mp[ANCHOR_LEFT, 1] * h), w)
ar = _snap_anchor(bh_roi, int(landmarks_mp[ANCHOR_RIGHT, 0] * w),
int(landmarks_mp[ANCHOR_RIGHT, 1] * h), w)
cost = (bh.max() - bh) + 1.0
cost[~roi] = 1e6 # 禁止路径走出 ROI
path, _ = route_through_array(cost, al, ar, fully_connected=True, geometric=True)
path = np.asarray(path)
# 拒识:路径平均黑帽响应过低 → 没画线(强行找出的伪路径)
if float(bh[path[:, 0], path[:, 1]].mean()) < min_mean_response:
return None
return path
def path_to_curve_mask(path: np.ndarray, h: int, w: int, thickness: int = 3) -> np.ndarray:
"""把路径画成曲线 mask(uint8 0/255),用作遮罩下边界 / 重画干净线。"""
m = np.zeros((h, w), np.uint8)
pts = path[:, ::-1].reshape(-1, 1, 2) # (row,col)→(x,y)
cv2.polylines(m, [pts], False, 255, thickness, lineType=cv2.LINE_AA)
return m
if __name__ == "__main__":
import sys
from .service import get_landmarker, get_parser
path_img = sys.argv[1] if len(sys.argv) > 1 else "/home/xsl/headmark/test_image/input1.png"
img = cv2.imread(path_img)
if img is None:
print(f"无法读取 {path_img}"); sys.exit(1)
h, w = img.shape[:2]
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
lm = get_landmarker().detect(rgb)
if lm is None:
print("未检出人脸"); sys.exit(1)
pm = get_parser().parse(rgb)
p = detect_marker_hairline(img, lm, pm)
if p is None:
print("未检测到发际线划线(拒识)"); sys.exit(0)
print(f"检测到画线:{len(p)}")
vis = img.copy()
cv2.polylines(vis, [p[:, ::-1].reshape(-1, 1, 2)], False, (0, 0, 255), 2)
import os
os.makedirs("tests/output", exist_ok=True)
name = os.path.splitext(os.path.basename(path_img))[0]
cv2.imwrite(f"tests/output/marker_{name}.png", vis)
print(f"saved tests/output/marker_{name}.png")
+151
View File
@@ -0,0 +1,151 @@
"""接口2 第二步:inpaint 遮罩 + 黑色发际线划线合成(参考 headmark 5步法)。
算法 hairline_texture_black 渲染黑线替代 headmark 的手绘检测
额头上部区域MediaPipe 额头边界关键点连线向上+两侧补到图像边缘填充
头部轮廓SegFormer 头部类hairskin排除 bg/neck/cloth
ROI =
渲染黑色发际线 烧进照片(marked) + 得到曲线像素
mask = ROI "发际线曲线以上"闭运算去洞 + 最大连通域 + 轻羽化
合成 RGBARGB=markedalpha=255×(1mask)透明=重绘区对齐 ComfyUI mask=1alpha
"""
from __future__ import annotations
import cv2
import numpy as np
from PIL import Image
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
# headmark 额头边界关键点(MediaPipe canonical 索引,左→右沿上额)
FOREHEAD_LANDMARKS = [21, 68, 104, 69, 108, 151, 337, 299, 333, 298, 251]
# SegFormer 头部类(含 skin..hat;排除 bg=0 / ear_r=15 / neck_l=16 / neck=17 / cloth=18
_HEAD_CLASSES = list(range(1, 15))
def forehead_upper_region(landmarks_mp: np.ndarray, w: int, h: int) -> np.ndarray:
"""headmark step1:额头边界关键点以上的"上部区域"填充 maskuint8 0/255)。"""
pts = [(int(landmarks_mp[i, 0] * w), int(landmarks_mp[i, 1] * h)) for i in FOREHEAD_LANDMARKS]
left_ext = (0, pts[0][1])
right_ext = (w - 1, pts[-1][1])
polygon = np.array([left_ext] + pts + [right_ext, (w - 1, 0), (0, 0)], dtype=np.int32)
m = np.zeros((h, w), np.uint8)
cv2.fillPoly(m, [polygon], 255)
return m
def head_silhouette(parse_map: np.ndarray) -> np.ndarray:
"""headmark step2SegFormer 头部轮廓 maskuint8 0/255)。"""
return (np.isin(parse_map, _HEAD_CLASSES).astype(np.uint8) * 255)
def _curve_bottom_per_column(curve_mask: np.ndarray):
"""每列发际线曲线的**最低**像素 y(线下沿),返回 (xs, ys) 仅含有曲线的列。"""
ys_idx, xs_idx = np.where(curve_mask > 0)
if xs_idx.size == 0:
return None, None
w = curve_mask.shape[1]
bottom = np.full(w, -1, np.int32)
np.maximum.at(bottom, xs_idx, ys_idx)
cols = np.where(bottom >= 0)[0]
return cols, bottom[cols]
def _above_curve_region(curve_mask: np.ndarray, h: int, w: int) -> np.ndarray:
"""由发际线曲线得到"曲线以上"区域(uint8 0/255)。
曲线 x 跨度内逐列插值出下沿 y_line(x)两侧按端点 y 水平延伸
above = 所有 y y_line(x)曲线缺失极端则返回全 1交给 ROI 兜底
"""
cols, ybot = _curve_bottom_per_column(curve_mask)
if cols is None:
return np.full((h, w), 255, np.uint8)
x0, x1 = int(cols.min()), int(cols.max())
# 全列插值 y_line:[x0,x1] 内线性插值,两侧水平延伸
yline = np.interp(np.arange(w), cols, ybot,
left=float(ybot[0]), right=float(ybot[-1])).astype(np.int32)
yy = np.arange(h)[:, None] # (h,1)
above = (yy <= yline[None, :]).astype(np.uint8) * 255 # (h,w)
return above
def _clean_mask(mask: np.ndarray, w: int) -> np.ndarray:
"""闭运算去洞 + 取最大连通域填充 + 轻羽化。"""
k = max(3, (int(w * 0.015) | 1))
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
out = np.zeros_like(mask)
if cnts:
largest = max(cnts, key=cv2.contourArea)
cv2.drawContours(out, [largest], -1, 255, -1)
# 轻羽化(柔化边缘,利于扩散衔接)
out = cv2.GaussianBlur(out, (0, 0), sigmaX=max(1.0, w * 0.004))
return out
def mask_from_curve(curve_mask: np.ndarray, landmarks_mp: np.ndarray,
parse_map: np.ndarray) -> np.ndarray:
"""由发际线曲线 + ROI(额头上部 ∩ 头部) 围成"曲线以上"闭合遮罩(uint8 0..255)。
接口2(模板渲染曲线) 接口3(检测路径曲线) 共用
"""
h, w = curve_mask.shape[:2]
roi = cv2.bitwise_and(forehead_upper_region(landmarks_mp, w, h),
head_silhouette(parse_map))
above = _above_curve_region(curve_mask, h, w)
return _clean_mask(cv2.bitwise_and(roi, above), w)
def build_inpaint_mask(photo_bgr: np.ndarray, landmarks_mp: np.ndarray,
parse_map: np.ndarray, points502: np.ndarray,
black_texture_rgba: np.ndarray):
"""接口2:返回 (marked_bgr 划线图, mask uint8 0..255 重绘区)。"""
h, w = photo_bgr.shape[:2]
uv, ext_faces = load_ext_mesh()
marked = render_hairline_overlay(photo_bgr, points502, ext_faces, uv, black_texture_rgba)
overlay = build_overlay_layer(h, w, points502, ext_faces, uv, black_texture_rgba)
curve_mask = (overlay[:, :, 3] > 40).astype(np.uint8) * 255
mask = mask_from_curve(curve_mask, landmarks_mp, parse_map)
return marked, mask
def compose_comfy_rgba(marked_bgr: np.ndarray, mask: np.ndarray) -> Image.Image:
"""合成 ComfyUI LoadImage 用的 RGBARGB=划线图,alpha=255mask(透明=重绘区)。"""
rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB)
alpha = (255 - mask).astype(np.uint8)
rgba = np.dstack([rgb, alpha])
return Image.fromarray(rgba, mode="RGBA")
if __name__ == "__main__":
import sys, os
from .service import get_landmarker, get_parser
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
tex_name = sys.argv[2] if len(sys.argv) > 2 else "girl_straight"
img = cv2.imread(path)
h, w = img.shape[:2]
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
from .hairline_2d import sample_hairline, smooth_hairline
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
lm = get_landmarker().detect(rgb)
parse_map = get_parser().parse(rgb)
h2d, valid = sample_hairline(lm, parse_map); h2d = smooth_hairline(h2d, valid)
h3d = lift_hairline_to_3d(lm, h2d); mid = build_middle_row(lm, h3d)
pts = assemble_full(lm, mid, h3d)
black = load_texture_rgba(f"hairline_texture_black/{tex_name}.png")
marked, mask = build_inpaint_mask(img, lm, parse_map, pts, black)
os.makedirs("tests/output", exist_ok=True)
cv2.imwrite("tests/output/mask_marked.png", marked)
cv2.imwrite("tests/output/mask_binary.png", mask)
# 三联可视化:划线图 / ROI / mask 叠加
upper = forehead_upper_region(lm, w, h); head = head_silhouette(parse_map)
roi = cv2.bitwise_and(upper, head)
vis = marked.copy()
vis[roi > 0] = (vis[roi > 0] * 0.6 + np.array([0, 40, 0])).clip(0, 255).astype(np.uint8)
vis[mask > 128] = (vis[mask > 128] * 0.4 + np.array([0, 0, 150])).clip(0, 255).astype(np.uint8)
cv2.imwrite("tests/output/mask_vis.png", vis)
compose_comfy_rgba(marked, mask).save("tests/output/comfy_input.png")
print(f"saved mask_marked/mask_binary/mask_vis/comfy_inputmask 像素 {int((mask>128).sum())}")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
{
"_name_or_path": "jonathandinu/face-parsing",
"architectures": [
"SegformerForSemanticSegmentation"
],
"attention_probs_dropout_prob": 0.0,
"classifier_dropout_prob": 0.1,
"decoder_hidden_size": 768,
"depths": [
3,
6,
40,
3
],
"downsampling_rates": [
1,
4,
8,
16
],
"drop_path_rate": 0.1,
"hidden_act": "gelu",
"hidden_dropout_prob": 0.0,
"hidden_sizes": [
64,
128,
320,
512
],
"id2label": {
"0": "background",
"1": "skin",
"2": "nose",
"3": "eye_g",
"4": "l_eye",
"5": "r_eye",
"6": "l_brow",
"7": "r_brow",
"8": "l_ear",
"9": "r_ear",
"10": "mouth",
"11": "u_lip",
"12": "l_lip",
"13": "hair",
"14": "hat",
"15": "ear_r",
"16": "neck_l",
"17": "neck",
"18": "cloth"
},
"image_size": 224,
"initializer_range": 0.02,
"label2id": {
"background": 0,
"skin": 1,
"nose": 2,
"eye_g": 3,
"l_eye": 4,
"r_eye": 5,
"l_brow": 6,
"r_brow": 7,
"l_ear": 8,
"r_ear": 9,
"mouth": 10,
"u_lip": 11,
"l_lip": 12,
"hair": 13,
"hat": 14,
"ear_r": 15,
"neck_l": 16,
"neck": 17,
"cloth": 18
},
"layer_norm_eps": 1e-06,
"mlp_ratios": [
4,
4,
4,
4
],
"model_type": "segformer",
"num_attention_heads": [
1,
2,
5,
8
],
"num_channels": 3,
"num_encoder_blocks": 4,
"patch_sizes": [
7,
3,
3,
3
],
"reshape_last_stage": true,
"semantic_loss_ignore_index": 255,
"sr_ratios": [
8,
4,
2,
1
],
"strides": [
4,
2,
2,
2
],
"transformers_version": "4.37.0.dev0"
}
@@ -0,0 +1,23 @@
{
"do_normalize": true,
"do_reduce_labels": false,
"do_rescale": true,
"do_resize": true,
"image_mean": [
0.485,
0.456,
0.406
],
"image_processor_type": "SegformerFeatureExtractor",
"image_std": [
0.229,
0.224,
0.225
],
"resample": 2,
"rescale_factor": 0.00392156862745098,
"size": {
"height": 512,
"width": 512
}
}
Binary file not shown.
+97
View File
@@ -0,0 +1,97 @@
"""Minimal Wavefront OBJ reader/writer.
Tailored to the project's face.obj:
- Latin-1 / GB-encoded comments allowed (we tolerate undecodable bytes).
- Vertices written with 4 decimal places (matches existing file).
- Preserves comment/mtllib/group lines if requested.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Sequence
@dataclass
class ObjMesh:
"""1-indexed in OBJ files; stored 0-indexed internally."""
positions: list[tuple[float, float, float]] = field(default_factory=list)
texcoords: list[tuple[float, float]] = field(default_factory=list)
normals: list[tuple[float, float, float]] = field(default_factory=list)
# Each face is a list of 3 (pos_idx, uv_idx, normal_idx) tuples, 0-indexed.
# -1 means "absent".
faces: list[list[tuple[int, int, int]]] = field(default_factory=list)
header_lines: list[str] = field(default_factory=list) # comments / mtllib
def n_v(self) -> int: return len(self.positions)
def n_vt(self) -> int: return len(self.texcoords)
def n_vn(self) -> int: return len(self.normals)
def n_f(self) -> int: return len(self.faces)
def read_obj(path: str) -> ObjMesh:
"""Read an OBJ file, ignoring undecodable bytes in comments."""
with open(path, "rb") as f:
raw = f.read()
text = raw.decode("latin-1", errors="replace")
mesh = ObjMesh()
for line in text.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("#") or line.startswith("mtllib") or line.startswith("o ") or line.startswith("g ") or line.startswith("s "):
mesh.header_lines.append(line)
continue
parts = line.split()
kind = parts[0]
if kind == "v":
mesh.positions.append((float(parts[1]), float(parts[2]), float(parts[3])))
elif kind == "vt":
mesh.texcoords.append((float(parts[1]), float(parts[2])))
elif kind == "vn":
mesh.normals.append((float(parts[1]), float(parts[2]), float(parts[3])))
elif kind == "f":
verts = []
for spec in parts[1:]:
seg = spec.split("/")
pi = int(seg[0]) - 1 if seg[0] else -1
ti = int(seg[1]) - 1 if len(seg) > 1 and seg[1] else -1
ni = int(seg[2]) - 1 if len(seg) > 2 and seg[2] else -1
verts.append((pi, ti, ni))
# Triangulate fan if quad/n-gon (shouldn't happen for our mesh)
for k in range(1, len(verts) - 1):
mesh.faces.append([verts[0], verts[k], verts[k + 1]])
# ignore everything else
return mesh
def write_obj(path: str, mesh: ObjMesh, header: Sequence[str] | None = None) -> None:
"""Write an OBJ file with the project's conventions (1-indexed, 4dp)."""
out: list[str] = []
if header is not None:
out.extend(header)
else:
out.append("# head3d extended face mesh")
out.append(f"# vertices={mesh.n_v()} texcoords={mesh.n_vt()} normals={mesh.n_vn()} faces={mesh.n_f()}")
out.append("")
for x, y, z in mesh.positions:
out.append(f"v {x:.4f} {y:.4f} {z:.4f}")
out.append("")
for u, v in mesh.texcoords:
out.append(f"vt {u:.4f} {v:.4f} 0.0000")
out.append("")
for nx, ny, nz in mesh.normals:
out.append(f"vn {nx:.4f} {ny:.4f} {nz:.4f}")
out.append("")
for face in mesh.faces:
toks = []
for pi, ti, ni in face:
a = str(pi + 1)
b = str(ti + 1) if ti >= 0 else ""
c = str(ni + 1) if ni >= 0 else ""
toks.append(f"{a}/{b}/{c}")
out.append("f " + " ".join(toks))
with open(path, "w", encoding="utf-8", newline="\r\n") as f:
f.write("\n".join(out))
f.write("\n")
+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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

+113
View File
@@ -0,0 +1,113 @@
"""接口2 渲染器:把发际线类型贴图按 502 点 mesh 贴到照片上(OpenCV 逐三角仿射 warp)。
原理技术方案 §4face_ext.obj 502 顶点里[468..485) 中间行 + [485..502) 发际线行
17 MP 顶部锚点连成 ~64 ribbon 三角形 UV 落在贴图顶部条带发际线曲线所在
warp 这些扩展三角形即可把贴图里的发际线曲线贴到额头/发际线区域且天然只画在 ribbon
"""
from __future__ import annotations
import os
import cv2
import numpy as np
from PIL import Image
from .obj_io import read_obj
from ._index_map_data import INDEX_MAP_468
_MESH_PATH = os.path.join(os.path.dirname(__file__), "mesh", "face_ext.obj")
_N_MP = 468
_mesh_cache = None
_INDEX_MAP = np.asarray(INDEX_MAP_468, dtype=np.int64) # OBJ顶点i → MP点索引
def mp_order_to_obj_order(points502_mp: np.ndarray) -> np.ndarray:
"""把 MP 顺序的 502 点重排成 face_ext.obj 的顶点顺序。
extract_hairline 输出为 MP 顺序[0..468) MP / [468..485) middle / [485..502) hairline
face_ext.obj 的前 468 顶点经 INDEX_MAP_468 重排obj_i mp_i扩展顶点
[468..502) 两侧同序直接对应
"""
out = np.empty_like(points502_mp)
out[:_N_MP] = points502_mp[_INDEX_MAP] # obj[0..468) = mp[INDEX_MAP]
out[_N_MP:] = points502_mp[_N_MP:] # 扩展行同序
return out
def load_ext_mesh(obj_path: str = _MESH_PATH):
"""解析 face_ext.obj,返回 (uv502, ext_faces)。结果缓存。
- uv502: (502, 2) float32每个顶点的 UVV_rawV=1 对应贴图顶部
- ext_faces: list[(i,j,k)]仅保留顶点索引含 468 的扩展三角形ribbon
obj v vt 一一对应face 用相同索引故按位置索引取 UV
"""
global _mesh_cache
if _mesh_cache is not None:
return _mesh_cache
mesh = read_obj(obj_path)
n_v = len(mesh.positions)
uv = np.zeros((n_v, 2), dtype=np.float32)
for face in mesh.faces:
for pi, ti, _ni in face:
if ti >= 0 and pi >= 0:
uv[pi] = mesh.texcoords[ti]
ext_faces = []
for face in mesh.faces:
idx = [pi for (pi, _t, _n) in face]
if max(idx) >= _N_MP: # 含扩展顶点 → ribbon 三角形
ext_faces.append(tuple(idx))
_mesh_cache = (uv, ext_faces)
return _mesh_cache
def load_texture_rgba(path: str) -> np.ndarray:
"""读发际线贴图为 (H, W, 4) uint8 RGBA。"""
return np.array(Image.open(path).convert("RGBA"))
def render_hairline_overlay(photo_bgr: np.ndarray,
points502_norm: np.ndarray,
ext_faces,
uv502: np.ndarray,
texture_rgba: np.ndarray) -> np.ndarray:
"""把 texture_rgba 的发际线曲线渲染到 photo_bgr 上,返回 BGR 预览图。
points502_norm: (502, 3) 归一化坐标x,y [0,1]**MP 顺序**extract_hairline 输出
"""
H, W = photo_bgr.shape[:2]
overlay = build_overlay_layer(H, W, points502_norm, ext_faces, uv502, texture_rgba)
# alpha 合成(RGBA→BGR:贴图 RGB 顺序需反成 BGR)
a = overlay[:, :, 3:4] / 255.0
rgb = overlay[:, :, :3][..., ::-1] # RGB→BGR
out = photo_bgr.astype(np.float32) * (1.0 - a) + rgb * a
return np.clip(out, 0, 255).astype(np.uint8)
def build_overlay_layer(H, W, points502_norm, ext_faces, uv502, texture_rgba) -> np.ndarray:
"""渲染发际线曲线层,返回 (H, W, 4) float32 RGBA(未合成到照片)。
供渲染合成render_hairline_overlay与遮罩mask.py alpha=曲线像素共用
"""
TH, TW = texture_rgba.shape[:2]
pts_obj = mp_order_to_obj_order(points502_norm)
img_xy = pts_obj[:, :2] * np.array([W, H], dtype=np.float32)
overlay = np.zeros((H, W, 4), np.float32)
tex = texture_rgba.astype(np.float32)
for (i, j, k) in ext_faces:
dst = img_xy[[i, j, k]].astype(np.float32)
# UV → 贴图像素;flipY:贴图 y = (1 - v_raw) * TH(与 head3d Three.js flipY=true 一致)
src = np.array([[uv502[v][0] * TW, (1.0 - uv502[v][1]) * TH] for v in (i, j, k)],
dtype=np.float32)
if cv2.contourArea(dst.astype(np.int32)) < 1.0: # 退化三角形跳过
continue
M = cv2.getAffineTransform(src, dst)
warped = cv2.warpAffine(tex, M, (W, H), flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0))
tri_mask = np.zeros((H, W), np.uint8)
cv2.fillConvexPoly(tri_mask, dst.astype(np.int32), 255)
sel = tri_mask > 0
overlay[sel] = warped[sel]
return overlay
+653
View File
@@ -0,0 +1,653 @@
"""接口2 服务层:模型单例 + 性别贴图映射 + 「照片→N 张发际线预览图」管线。
head3d extract_hairline 步骤包成单例复用避免每请求重建模型再按性别
对每张贴图调 render.render_hairline_overlay 生成预览图
"""
from __future__ import annotations
import glob
import os
import cv2
import numpy as np
from . import constants as C
from . import comfyui
from .face_landmarks import FaceLandmarker
from .face_parsing import FaceParser
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,
"high": os.path.join(_REPO, "hairline_texture_high"),
"low": os.path.join(_REPO, "hairline_texture_low"),
}
# torch 2.7.1+cu128 已支持 RTX 5090 (sm_120)SegFormer 走 GPU~0.05s/张)
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cuda")
_landmarker = None
_parser = None
_texture_maps: dict = {} # {level: {gender: [(key, path)]}},按档位缓存
def get_landmarker() -> FaceLandmarker:
global _landmarker
if _landmarker is None:
_landmarker = FaceLandmarker(static_image_mode=True)
return _landmarker
def get_parser() -> FaceParser:
global _parser
if _parser is None:
_parser = FaceParser(device=_SEG_DEVICE)
return _parser
def _gender_key(stem: str):
"""文件名 stem → (gender, key);非 girl_/man_ 前缀返回 (None, None)。"""
if stem.startswith("girl_"):
return "female", stem[5:].replace(" ", "").strip()
if stem.startswith("man_"):
return "male", stem[4:].replace(" ", "").strip()
return None, None
def get_texture_map(level: str = "middle") -> dict:
"""扫描指定档位贴图目录建 {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}")
cached = _texture_maps.get(level)
if cached is not None:
return cached
mapping: dict[str, list] = {"female": [], "male": []}
for path in sorted(glob.glob(os.path.join(_TEXTURE_DIRS[level], "*.png"))):
stem = os.path.splitext(os.path.basename(path))[0]
gender, key = _gender_key(stem)
if gender:
mapping[gender].append((key, path))
for g in mapping:
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
def extract_502(image_bgr: np.ndarray):
"""照片(BGR) → (points502 MP序, valid17)。无人脸返回 (None, None)。"""
ctx = extract_context(image_bgr)
if ctx is None:
return None, None
return ctx["points"], ctx["valid"]
def extract_context(image_bgr: np.ndarray):
"""照片(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_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)
points = assemble_full(landmarks, middle_3d, hairline_3d)
return {"landmarks": landmarks, "parse_map": parse_map, "points": points, "valid": valid}
def _black_texture_path(white_path: str) -> str:
"""白贴图路径 → 同名黑贴图路径(hairline_texture_black/)。"""
return os.path.join(_BLACK_TEXTURE_DIR, os.path.basename(white_path))
def generate_previews(image_bgr: np.ndarray, gender: str):
"""生成该性别全部发际线预览图(仅预览,不生发)。
Returns: list[dict] {"hairline_type", "image_bgr", "order"}无人脸返回 None
"""
if gender not in ("male", "female"):
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
ctx = extract_context(image_bgr)
if ctx is None:
return None
uv, ext_faces = load_ext_mesh()
results = []
for order, (key, path) in enumerate(get_texture_map()[gender], start=1):
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv,
load_texture_rgba(path))
results.append({"hairline_type": key, "image_bgr": preview, "order": order})
return results
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,
unet_name: str | None = None):
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
hair_styles1-indexed 列表指定生成哪几张发际线按贴图排序female: 1..7male: 1..6
None 时生成全部兼容旧调用
use_mask默认 True是否启用 inpaint 遮罩用于测试对比同接口3
False 时用**干净原图 + 空遮罩** ComfyUI不烧黑色模板线
prompt默认 NoneComfyUI 提示词 None 时替换工作流节点60文本
workflow_path默认 NoneComfyUI 工作流 JSON 路径None 用默认 add_hair.json
Returns: list[dict] {"hairline_type","order","overlay"((H,W,4) RGBA 透明层),
"grown_png"(bytes None)}
无人脸返回 None某张 ComfyUI 失败时该项 grown_png=None不抛异常
"""
if gender not in ("male", "female"):
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
ctx = extract_context(image_bgr)
if ctx is None:
return None
uv, ext_faces = load_ext_mesh()
textures = get_texture_map()[gender] # [(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))
# 禁用遮罩:干净原图 + 空遮罩,与模板无关 → 只跑一次 ComfyUI,下面 N 项复用
shared_grown = None
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(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)
results = []
h, w = image_bgr.shape[:2]
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)
if not use_mask:
grown_png = shared_grown
else:
grown_png = None
try:
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,
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)
results.append({"hairline_type": key, "order": order,
"overlay": overlay, "grown_png": grown_png})
return results
def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
redraw_defaults: dict,
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),
hairline_level 无关high/low 贴图与 middle 同名basename 映射即落回 middle 黑模板
use_mask=False 时用干净原图+空遮罩与贴图无关white_path 可为 None
失败返回 None不抛异常
"""
try:
if use_mask:
black = load_texture_rgba(_black_texture_path(white_path))
marked, mask = build_inpaint_mask(
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
else:
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", 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,
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 透明层只含发际线曲线与一张生发图
生发机制同接口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 取首个选中发型三档各自的发际线中点
"""
if gender not in ("male", "female"):
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
if not hair_styles:
raise ValueError("hair_styles 必填且不能为空")
ctx = extract_context(image_bgr)
if ctx is None:
return None
h, w = image_bgr.shape[:2]
uv, ext_faces = load_ext_mesh()
lm = ctx["landmarks"]
# 面部中轴 x = 眉心(9/151 中点)
face_cx = float((lm[9, 0] + lm[151, 0]) / 2 * w)
# 三档贴图表(同性别、同 key 顺序,因三个文件夹同名)
tex_by_level = {lv: get_texture_map(lv)[gender] for lv in _TEXTURE_DIRS}
# 生发图(同接口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。"""
ys, xs = np.where(overlay[:, :, 3] > 40)
if not xs.size:
return None
near = np.abs(xs - face_cx) <= max(2, int(w * 0.02))
col_ys = ys[near] if near.any() else ys[np.argsort(np.abs(xs - face_cx))[:20]]
return (int(round(face_cx)), int(round(float(col_ys.mean()))))
images, best_centers = [], None
for s in hair_styles: # s = 1-indexed 发型序号
key, _mid_path = tex_by_level["middle"][s - 1]
overlays = {}
for lv in _TEXTURE_DIRS:
white = load_texture_rgba(tex_by_level[lv][s - 1][1])
overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
# 生发图:从按性别算好的结果里按 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)发际线中点
if best_centers is None:
best_centers = {lv: _center_of(overlays[lv]) for lv in _TEXTURE_DIRS}
return {"images": images, "best_centers": best_centers}
# 接口3 送 ComfyUI 前限边,降低峰值显存,避免与接口2 切换时把 Flux 挤出。
# 统一 prompt 后 Flux 不再被 CLIP 挤出,接口3 可用较高分辨率。可用 GROW_B_MAX_SIDE 覆盖。
_GROW_B_MAX_SIDE = int(os.getenv("GROW_B_MAX_SIDE", "1024"))
def _downscale_max_side(img_bgr: np.ndarray, max_side: int) -> tuple[np.ndarray, float]:
"""长边超过 max_side 时等比例缩小;返回 (图, scale)scale=新/旧。"""
h, w = img_bgr.shape[:2]
m = max(h, w)
if max_side <= 0 or m <= max_side:
return img_bgr, 1.0
scale = max_side / float(m)
nw = max(1, int(round(w * scale)))
nh = max(1, int(round(h * scale)))
out = cv2.resize(img_bgr, (nw, nh), interpolation=cv2.INTER_AREA)
return out, scale
def _upscale_png_to(png_bytes: bytes, out_w: int, out_h: int) -> bytes:
"""把 Comfy 输出 PNG 双线性拉回原图尺寸(仅展示对齐,不增加推理细节)。"""
arr = np.frombuffer(png_bytes, np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
if img is None:
return png_bytes
if img.shape[1] == out_w and img.shape[0] == out_h:
return png_bytes
resized = cv2.resize(img, (out_w, out_h), interpolation=cv2.INTER_LINEAR)
ok, buf = cv2.imencode(".png", resized)
return buf.tobytes() if ok else png_bytes
def _prep_comfy_input(img_bgr: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray, float]:
"""单段 ComfyUI 生发(接口2男 / 接口3)送图前限边到 GROW_B_MAX_SIDE。
返回 (缩后图, 缩后遮罩, scale)scale<1 时调用方需把结果放大回原尺寸"""
h, w = img_bgr.shape[:2]
if _GROW_B_MAX_SIDE <= 0 or max(h, w) <= _GROW_B_MAX_SIDE:
return img_bgr, mask, 1.0
out, scale = _downscale_max_side(img_bgr, _GROW_B_MAX_SIDE)
nh, nw = out.shape[:2]
msk = cv2.resize(mask, (nw, nh), interpolation=cv2.INTER_NEAREST)
logger.info("接口2男/接口3 缩图送 Comfy: %dx%d%dx%d (max_side=%d)",
w, h, nw, nh, _GROW_B_MAX_SIDE)
return out, msk, scale
def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str = None):
"""接口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)
landmarks = get_landmarker().detect(rgb)
if landmarks is None:
return {"grown_png": None, "status": "no_face"}
parse_map = get_parser().parse(rgb)
path = detect_marker_hairline(marked_bgr, landmarks, parse_map)
if path is None:
return {"grown_png": None, "status": "no_line"}
line_w = max(2, int(w * 0.006))
curve_mask = path_to_curve_mask(path, h, w, thickness=max(3, line_w))
mask = mask_from_curve(curve_mask, landmarks, parse_map)
else:
mask = np.zeros((h, w), np.uint8) # 空遮罩:alpha 全 255,跳过检测
buf = io.BytesIO()
compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG", compress_level=1) # marked + 遮罩
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"}
if __name__ == "__main__":
import sys
g = sys.argv[2] if len(sys.argv) > 2 else "female"
img = cv2.imread(sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg")
os.makedirs("tests/output", exist_ok=True)
print("texture map:", {k: [kp[0] for kp in v] for k, v in get_texture_map().items()})
res = generate_previews(img, g)
if res is None:
print("无人脸")
sys.exit(1)
for r in res:
out = f"tests/output/preview_{g}_{r['hairline_type']}.png"
cv2.imwrite(out, r["image_bgr"])
print(f" order={r['order']} type={r['hairline_type']} -> {out}")
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.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

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

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