47 Commits
Author SHA1 Message Date
xsl a3fddbbc34 fix(发际线): 修正不对称刘海导致的模板叠加倾斜
按 MediaPipe 左右对应额头点协调模板延伸距离,保留人脸姿态与头部轮廓钳制,统一预览白线和生发遮罩的几何。补充对齐回归测试并更新贴图顺序测试及实现说明。

验证:13 项相关测试通过;两张用户图片完成前后对比,新图男性 7 型接口与生发图均成功返回。
2026-11-01 15:14:29 +08:00
xsl 8554850d55 docs: 网关摘除隔离期实施方案(供网关机器参考实现,暂不在本仓库改 gateway 代码)
复盘 9·19 磁盘满事故网关未切换的三层原因;给出 main 分支 gateway/pool.py
的隔离期改动说明(WorkerState.offline_until + 被动摘除冷却 60s + 探测复活
守卫 + 删死代码)、配置项、单测建议、部署/验证/回滚步骤。worker 侧深检
(方案 A)已在本分支 14562d4 上线。
2026-09-20 22:07:48 +08:00
xsl 6b098c0e15 feat(health): /health 深检——磁盘水位 + 下游依赖 TCP 探测,异常返回 503 degraded
背景: 9·19 磁盘满事故中业务已瘫但 /health 仍 200(只看模型就绪标志),
网关持续把流量打到本机。深检让 worker 在磁盘不足(min_free_gb, 默认5G)或
ComfyUI/change_hair TCP 不通时主动报 503,网关探测两次失败即摘除。

- 配置: worker_config.json "health" 段,env HEALTH_MIN_FREE_GB/
  HEALTH_CHECK_DEPS/HEALTH_DEP_COMFYUI/HEALTH_DEPS_CACHE_SECONDS 可覆盖
- 依赖探测 0.8s 超时并发执行,结果缓存 5s;深检自身异常按就绪返回
- 测试: 深检各分支用例 + 旧用例改为 mock 深检(不依赖本机服务)
2026-09-20 22:07:48 +08:00
xsl 752630e4f5 feat: ComfyUI 默认提示词统一改为「填充遮罩区域的头发,人物增加一点磨皮」
- app.py 各接口 Form 默认值与说明、hairline/service.py _REDRAW_PROMPT、
  hairline/redraw.py _DEFAULT_PROMPT、face_analysis/hairline_grow.py 兜底值
- add_hair.json / 0716add-hair-api.json / 0716add-hair.json / hair_repaint.json
  工作流节点60内置文本
- static 测试页、local_test、benchmark 脚本、接口文档同步
注意:新提示词首次请求会因 CLIP 缓存失效变慢一次,之后恢复
2026-09-16 00:27:33 +08:00
xsl 59e01d3230 fix(接口2): female 1..5 换发型分支重绘透传 prompt(此前固定为默认值)
- generate_grow_results_swap 换发型分支 _call_local_redraw 补传 prompt,
  自定义提示词现可替换工作流节点60文本(与 male / female 6/7 行为一致);
  prompt 不进 change_hair(swapHair),仅作用于最后一步 ComfyUI 发际线带重绘
- 默认值不变仍为「填充遮罩区域的头发」,CLIP 缓存快路径不受影响;
  换词后首次请求因缓存失效重载明显变慢(实测 45s vs 命中 8s),后续恢复
- 文档同步:接口文档.md prompt 参数说明更新
2026-09-14 14:37:51 +08:00
xsl 10eecba2f0 docs(网关待改动§9): 补无GUI服务器系统库依赖 + torch CPU源安装说明 + 无GPU实测结论 2026-09-06 23:32:24 +08:00
xsl 27c402c259 feat(接口99): 网关改本机实现(纯CPU+单路排队锁),文档同步
- gateway/app.py: 接口99 从盲转发改为本机实现(同接口4 模式):
  显式 Form 三选一 + 惰性导入(缺依赖仅本接口报1007)+ run_in_threadpool
- 单路排队: _if99_lock 全局互斥——串行推理限制 CPU 峰值 +
  规避 MediaPipe 单例线程安全问题;实测 req2≈2×req1 确认串行
- 网关待改动 §9 重写: 本机实现部署步骤 + 依赖清单(mediapipe/opencv/
  numpy<2,torch CPU 版可选——不装则发际线5点null)+ 权重已入git说明
- 实现说明/接口文档/CLAUDE.md 同步: 接口4/99 网关本机实现不转发
- 实测: BiSeNet 13.3M参数/51MB;CPU单核 0.46-0.65s/张;网关路径
  70/70点;worker 路径保留(测试页直连);pytest 15 通过
2026-09-06 23:32:24 +08:00
xsl 0b02ad1693 feat(接口99): 人脸特殊点检测 + 网关转发路由;文档同步至当前代码
- worker: POST /api/v1/face/special-points,60 个固定编号关键点(6组)
  + 5 个发际线特殊点(沿指定关键点方向射线步进,与 BiSeNet 头发 mask 首次相交;
  双射线取中点,left_left=mid(333→332, 298→284) 与右侧镜像)
- 网关: 同路径盲转发路由(调用频率低,维持转发不占网关依赖;
  响应无 *_base64 字段,改写逻辑天然跳过)
- 测试页 static/test_interface99.html:分组着色小点 + 点ID标注(可开关)
  + 发际线点大圆标签 + JSON 展示
- 文档: CLAUDE.md 重写至当前架构;实现说明/接口文档补接口 9~12/99 契约;
  网关待改动补 §7(9/10已加) §8(11/12待加) §9(接口99部署步骤)
2026-09-06 23:32:24 +08:00
xsl 59893788a1 更新 发际线纹理 2026-08-14 21:09:15 +08:00
xslandClaude 63ee8444c0 feat: 接口1/6 发际线贴近头顶时改为修正而非弃用置null
顶庭(头顶→发际线)<0.7cm 时不再返回 null:
- 上庭 = 0.95×中庭 反推发际线;若越过头顶则顶庭固定 0.7cm(发际线钉在头顶下方)
- hairline_source 新增 "corrected";to_response 始终输出完整四庭
- 标注图始终正常画发际线横线与庭段
- 接口6 三庭逻辑同步,去 discarded 分支

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 11:47:16 +08:00
xslandCursor 6ffc7f343d feat: 男性发型追加波浪形(wave=7)
接口2/5 male hair_style 增至 1..7,贴图 man_wave.png 纳入四套纹理目录,同步测试页与接口文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 21:35:28 +08:00
xsl 4bcd14bd76 save 2026-08-04 22:25:25 +08:00
xsl 0da0237ab2 save code 2026-08-04 21:54:53 +08:00
xsl 23fc0e1b9d save 2026-08-04 21:44:49 +08:00
xslandCursor 45bda80859 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 22:58:49 +08:00
xslandCursor b78da0e0cd feat: 脸型分类器(7分类)+批量报告生成
基于 MediaPipe 468 关键点提取几何特征,转 z 分数后与各脸型原型加权匹配。
参考分布与原型靶心取自 1093 张测试集的实测画像,不再靠人工设定绝对阈值。

方形脸占比从 29.6% 降到 13.8%,两处原因:一是参考统计量原先只由 50 张样本
估得,相对全量人群有系统性偏移,且三项偏移都在给方形脸加分;二是原型把
aspect_ratio 当作方形脸的主特征,但实测方脸组该值中位仅 +0.16,真正"宽"的
是圆脸(+1.01),等于在拿脸宽找方脸。

原型参数在「6 张基准标注图判定不变、且领先第二名 >=3 分」的约束下搜索得到。
余量约束是必要的:早前一版余量仅 0.008 分,权重写码时四舍五入就会翻转结论。

测试素材(人像照片)与报告输出体积大,一并加入 .gitignore。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:37:09 +08:00
xsl 616fc26143 fix: 删除 MeasureResult.__init__ 中重复的七眼厘米赋值块 2026-07-27 23:16:59 +08:00
xsl 48c9875381 feat: 接口1/5/6 发际线弃用逻辑(顶庭<0.7cm)+接口1调试页分步可视化
- measure.py: MeasureResult 增 hairline_discarded 判定(顶庭<0.7cm),弃用时
  顶/上庭字段置null、face_total只算中下庭、hairline_source=discarded
- annotation.py: 弃用时保留头顶线、去掉发际线、只标中/下庭
- app.py: 接口1/6 弃用分支 + eye1/7竖向范围改用眉心 + 接口1调试页(measure-debug)
- 新增 static/test_interface1_debug.html 分步可视化页(9步原理)
2026-07-27 23:07:54 +08:00
xsl 5bcaeb2594 chore: 所有提示词统一为"填充遮罩区域的头发"
测试结论: 纯生发提示词(不做皮肤处理)效果最佳。
- 替换所有位置的提示词默认值(原"填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"等)
- 涉及25个文件: app.py/hairline/service.py/hairline/redraw.py/工作流json/测试页/benchmark脚本/local_test
- _REDRAW_PROMPT / _DEFAULT_PROMPT / 各接口Form默认值 / 测试页输入框默认值 全部统一
2026-07-26 21:53:39 +08:00
xsl 8a82d00056 docs: 所有报告最左边新增原图(输入图)列
- bench3/4/5/7 报告重新生成,首列显示输入原图
- 补充 girl2 原图到 static/bench/orig/
- regen_reports.py: 通用报告重生成脚本
2026-07-26 21:44:31 +08:00
xsl e2bbcca7ad docs: 重绘分辨率对比(纯生发提示词) bench7报告
提示词="填充遮罩区域的头发"(纯生发,无皮肤处理)
80/80成功,0 OOM,峰值21.2GB
对照 bench3(美颜)/bench4(磨皮)/bench5(美白)/bench7(纯生发)
2026-07-26 21:41:43 +08:00
xsl f8ed477696 docs: 重绘分辨率对比(美白提示词) bench5报告
提示词="填充遮罩区域的头发,皮肤加一点美白"
80/80成功,0 OOM,峰值21.2GB
可对照 bench3(美颜)/bench4(磨皮)/bench5(美白) 三种提示词
2026-07-26 21:00:49 +08:00
xsl 2877d07a69 docs: 重绘分辨率对比(无美颜提示词) + 调试接口支持自定义提示词
- app.py/hairline/service.py: 调试接口新增 redraw_prompt 参数,_call_local_redraw 支持自定义提示词
- benchmark_res_prompt.py: 提示词改为"填充遮罩区域的头发,皮肤加一点磨皮"(去掉美颜)
- static/bench4_report.html: 20行×4分辨率对比报告,80/80成功,0 OOM

可对照 bench3(含美颜) vs bench4(无美颜) 看画质差异
2026-07-26 19:57:36 +08:00
xsl bee7a487ac docs: 重绘分辨率对比报告(原图/896/768/640)
4图×5发型=20行,每行4分辨率对比,steps=15,热数据。
80/80成功,0 OOM,峰值21.2GB(原图不缩放也未OOM)。
报告: static/bench3_report.html
2026-07-26 19:10:33 +08:00
xsl 531dcb8279 docs: swap步数+重绘分辨率对比报告(热数据)
4图×2发型×(3步数档+3分辨率档) 热数据对比,48/48成功,0 OOM,峰值20.6GB。
- benchmark_steps_res.py: 测试脚本(预热+正式取热数据)
- benchmark_steps_res_report.py: 报告生成
- static/bench2_report.html: 对比报告
- static/bench2/: 48张结果图

结论: B维度 steps10→20 swap 3.0s→3.9s; C维度 res640比896省3s(comfy 4.3s vs 7.3s)
2026-07-26 17:44:59 +08:00
xsl 62d2660e41 feat(调试): 测试页新增重绘分辨率选项
- app.py 调试接口新增 redraw_max_side 参数,控制整条管线的降分辨率
- debug_grow_timing.html 新增"重绘分辨率"下拉(640/768/896/1024/原图)
- 实测: 640比896省1.5s(5.2s vs 6.7s), 1024比896慢0.6s
2026-07-26 17:01:27 +08:00
xsl e4d03a782d feat(调试): swapHair webui步数暴露到测试页可调
参数透传链路: 测试页→debug接口→generate_hairline_redraw→_grow_core
  →_call_swap→change_hair swapHair→webui_img2img→build_body_v2

- app.py: 调试接口新增 webui_steps Form 参数
- hairline_grow.py: _call_swap/_grow_core/generate_hairline_grow/generate_hairline_redraw 透传 webui_steps
- debug_grow_timing.html: 新增 swap步数 下拉(默认/10/15/20/25/30)

实测 steps 10→25 swap 耗时 4.1s→4.4s,参数确实生效
2026-07-26 16:49:21 +08:00
xsl ae5a3b8f1d refactor(comfyui): 模型切换时同步VAE + 完善编码器映射
- 新增 _vae_for_unet() 按模型自动切换 VAE(Z-Image用ae, Flux.2用flux2-vae)
- _clip_for_unet 增加 z-image 识别
- 为将来多模型切换做准备(当前 Q4 不受影响)
2026-07-26 16:12:43 +08:00
xsl d318efcff0 feat(调试): 接口2女性生发分步耗时分析页面
新增 /api/v1/debug/grow-timing 调试接口 + debug_grow_timing.html 测试页:
- 拆分接口2女性生发的每一步并独立计时(extract_context/mask/swap/blend/comfyui)
- 测试页用条形图展示各步耗时占比,附步骤说明
- 用于定位性能瓶颈,优化方向判断

实测花瓣发型: 总11.9s = 预处理0.44s + 换发型4.4s + ComfyUI重绘6.8s
2026-07-26 16:12:32 +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
793 changed files with 8886 additions and 903 deletions
+28
View File
@@ -53,3 +53,31 @@ 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.*
# 脸型测试素材(人像照片,体积大,不入 git;仅保留 6 张基准标注图)
face/test_img/脸型测试集合/
face/test_img/girl/
face/test_img/man/
# 脸型特征缓存(由 face/dump_features.py 生成,可随时重跑)
face/cache/
# 脸型报告输出(标注图体积大,不入 git)
static/face_shape_report/
static/face_shape_report.html
static/facetest_report/
static/facetest_report.html
static/facetest_all_report/
static/facetest_all_report.html
+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"
}
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+72 -52
View File
@@ -4,80 +4,100 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Overview
"旷视五接口" — a FastAPI facial analysis API service providing 5 endpoints for face measurement, hair growth simulation (C-end and B-end), user feature extraction, and hairline PNG generation. Currently a **Mock v1**: all endpoints return hardcoded sample data; real ML logic is pending.
旷视接口服务 — FastAPI 人像分析/生发服务,**一个仓库、两种部署角色**:
- **Base URL (prod)**: `https://hair.xiangsilian.com`
- **Swagger docs**: `https://hair.xiangsilian.com/docs`
- **Internal**: uvicorn on `127.0.0.1:8000` behind nginx reverse proxy
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真实算法(本地模型 + ComfyUI + change_hair),监听 `0.0.0.0:8187`。**本机(GPU 机)即 worker 角色**,不跑网关。
- **网关**(`gateway/`):外网机器上的薄反向代理,负责鉴权/健康检查/派发,并把 worker 返回的 `*_base64` 落盘改写为 `*_url`。接口4(豆包)和接口99(人脸特殊点,纯 CPU)在网关本机实现,不转发 worker。
> 历史包袱:`app.py` 的 FastAPI `description` 和 `docs/接口文档.md` 部分示例仍写「Mock 第一版」字样,实际全部接口已是真实算法实现。**架构以 `docs/实现说明.md` 为准,契约以 `docs/接口文档.md` 为准。**
## Commands
```bash
# Development server
./venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 --reload
# ---- worker(本机)----
./run_worker.sh # 后台启动(0.0.0.0:8187,日志 worker.log)
FOREGROUND=1 ./run_worker.sh # 前台调试
./stop_worker.sh # 停止
sudo systemctl restart hair-worker # systemd 方式(生产)
journalctl -u hair-worker -f
# Or via the start script
./start.sh
# ---- 依赖服务(worker 之前要先起)----
sudo systemctl start comfyui # ComfyUI 127.0.0.1:8188(Flux-2 生图)
cd /home/ubuntu/change_hair && bash start_all.sh start # 换发型三件套 8801/32678/57860
# Production (systemd)
sudo systemctl restart hair
sudo systemctl status hair
journalctl -u hair -f
# Install dependencies (uses Tencent Cloud PyPI mirror)
./venv/bin/pip install -r requirements.txt
# ---- 测试 ----
./venv/bin/pytest tests/ # 单元测试(conftest 会准备 fixtures)
./venv/bin/python -m hairline.comfyui tests/output/comfy_input.png # 手动跑一次生发工作流
```
依赖装在 `./venv`(Python 3.10,torch 2.7.1+cu128,RTX 4090 CUDA 可用)。`requirements.txt` 里的 torch 2.2.2/5090 注释是旧机器的记录,本机 venv 实际是 cu128 版。
## Architecture
**Single-file app** — `app.py` (~555 lines) contains the entire server: FastAPI app creation, Pydantic models, all 5 endpoint handlers, and health check routes. There are no separate routers, services, or middleware modules.
### 接口一览(worker 侧路由,`app.py`)
### Endpoints
| 接口 | 路径 | 实现位置 | 说明 |
|------|------|----------|------|
| 1 | `POST /api/v1/face/measure` | `face_analysis/` | 四庭七眼测量 + 标注 PNG |
| 6 | `POST /api/v1/face/measure-v2` | 同上(`variant="v6"`) | 接口1 去顶庭变体 |
| 2 | `POST /api/v1/hair/grow` | `hairline/service.py` | C端生发。**female 1..5** 走 change_hair swapHair + Flux-2 整帧重绘(接口12 final 管线);**female 6/7 与 male 全部**走 ComfyUI `add_hair.json` inpaint |
| 7 | `POST /api/v1/hair/grow-v2`(hidden/deprecated) | 同上 | 同接口2,用 `add_hair2.json` 工作流 |
| 3 | `POST /api/v1/hair/grow-b` | `hairline/marker_detect.py` | B端马克笔划线生发(黑帽响应 + Dijkstra) |
| 4 | `POST /api/v1/face/features` | worker 有实现;生产走**网关本机**(豆包) | 用户特征 |
| 5 | `POST /api/v1/hairline/generate` | `hairline/service.py` | 发际线 PNG 三档(middle/high/low)+ 生发 |
| 9 | `POST /api/v1/head/mask` | `face_analysis/head_mask.py` | 头发遮罩 + 分步可视化(BiSeNet/SegFormer 双路) |
| 10 | `POST /api/v1/head/band` | `face_analysis/head_band.py` | 头部外缘膨胀带遮罩 |
| 11 | `POST /api/v1/hairline/grow` | `face_analysis/hairline_grow.py` | 发际线生发(接口9 遮罩 + change_hair + 接缝融合),不含重绘 |
| 12 | `POST /api/v1/hairline/grow_v2` | 同上 | 接口11 final + 发际线重绘带 → Flux-2 保色重绘 |
| 12f | `POST /api/v1/hairline/grow_v2_final` | 同上(`_V2_FINAL_DEFAULTS`) | 精简版:仅图片 + hairline_id,取整帧重绘 |
| 12f2 | `POST /api/v1/hairline/grow_v2_final_v2` | 同上 | 同上,取 B 局部加发+全脸美颜 |
| 99 | `POST /api/v1/face/special-points` | `face_analysis/special_points.py`(网关直接 import 本机实现 + `_if99_lock` 单路排队;worker 保留同路径备份) | 固定编号关键点 + 发际线特殊点(射线求交头发 mask)像素坐标 |
| 代理 | `POST /api/swapHair/v1`、`/hairColor/v2` | httpx 转发 127.0.0.1:8801 | 解决测试页 CORS |
| 工具 | `POST /api/v1/redraw` | `hairline/redraw.py` | 直接 ComfyUI 重绘(图+遮罩+prompt) |
| 调试 | `POST /api/v1/debug/grow-timing`、`GET /api/v1/debug/hairline_log` | — | 免鉴权,分步计时/下载日志 |
| Method | Path | Purpose |
|--------|------|---------|
| POST | `/api/v1/face/measure` | 四庭七眼 measurement + annotated PNG |
| POST | `/api/v1/hair/grow` | C-end hair growth (user-facing) |
| POST | `/api/v1/hair/grow-b` | B-end hair growth (doctor/operator, with marked image) |
| POST | `/api/v1/face/features` | User facial feature analysis |
| POST | `/api/v1/hairline/generate` | Hairline PNG generation |
| GET | `/health` | Health check (excluded from OpenAPI schema) |
| GET | `/` | Service info (excluded from OpenAPI schema) |
配套测试页在 `static/test_interface{1..12}*.html`,经 `/static` 挂载。
### Image input convention (all endpoints)
### 关键模式
Every endpoint accepts images via **exactly one** of three mutually exclusive fields:
- `image_file` — multipart file upload (≤ 1 MB)
- `image_url` — HTTP/HTTPS URL string
- `image_base64` — base64 string with `data:image/...;base64,` prefix
- **统一信封**:`ok(data)` / `err(code, message)` 构造 `{code, message, request_id, data}`;业务错误 HTTP 一律 200。错误码:1001 无人像 / 1002 分辨率低 / 1003 非正面 / 1004 gender 非法 / 1005 多人 / 1006 >1MB / 1007 参数或处理失败 / 1008 格式不支持。
- **图片三选一**:每个接口 `image_file`(multipart) / `image_url` / `image_base64`(带 `data:` 前缀)严格互斥,由 `resolve_image_bytes()` 统一处理(0 个或多个 → 1007)。接口3 是 `marked_image_*`。
- **阻塞算法进线程池**:所有推理 handler 用 `run_in_threadpool(...)`,避免卡事件循环。
- **内网鉴权**:HTTP 中间件校验 `/api/*` 的 `X-Internal-Token` 头,密码来自 `worker_config.json` 的 `accept_passwords`(或环境变量 `WORKER_ACCEPT_PASSWORDS`)。豁免前缀见 `_AUTH_EXEMPT`(health/docs/static/debug/redraw/swapHair/hairColor)。
- **发型序号映射**:`hairline/service.py` 的 `_HAIRSTYLE_ORDER` 显式定义 1..7 顺序(不靠文件名字母序);female 1..5 → change_hair `chang_*` 映射在 `_FEMALE_KEY_TO_CHANG`。
- **接口12 固化参数**:`_V2_FINAL_DEFAULTS`(app.py)是生产默认值(push=0.8cm / two_stage / color_match=关 等),接口2 female 重绘也复用它。
Endpoint 3 (B-end) has **two** sets of image params: `marked_image_*` (the marked-up image) and `original_image_*` (the original photo). Each set follows the same three-way exclusive rule independently.
### 外部依赖(worker 视角)
### Unified response structure
| 依赖 | 地址 | 用途 | 配置 |
|------|------|------|------|
| ComfyUI | `http://127.0.0.1:8188`(`COMFYUI_URL`) | Flux-2 生图/重绘,走 HTTP API(`hairline/comfyui.py`) | Basic Auth `admin`,密码:`COMFYUI_PASSWORD` → `worker_config.json.comfyui_password` → `password.txt` |
| change_hair | `http://127.0.0.1:8801`(`SWAP_HAIR_URL`/`HAIR_GROW_URL`/`CHANGE_HAIR_BASE`) | 换发型(SD WebUI + LoRA)、hairGrow | 服务目录 `/home/ubuntu/change_hair`,`start_all.sh` 启停 |
```json
{"code": 0, "message": "success", "request_id": "mock-request-id", "data": {...}}
```
ComfyUI 工作流 JSON 在仓库根:`add_hair.json`(接口2/3/5 生发)、`add_hair2.json`(接口7)、`0716add-hair-api.json`(接口12/2女 重绘)、`hair_repaint.json`。节点约定:**26** 输入图 / **6** seed / **60** 提示词 / **16** unet(.gguf 自动切 UnetLoaderGGUF)/ **61** CLIP(4b↔9b 自动配套)。
Error codes: 1001–1008 (see `app.py` docstring or `docs/接口文档.md`).
### 性能/显存相关的坑
### Key code patterns
- 三个生发接口共用**同一提示词字符串**以命中 ComfyUI 的 CLIP 文本条件缓存——prompt 变了会触发 Flux(7.7G)+qwen CLIP(3.9G) 互相挤显存重载(每次 +4s)。
- 接口2 女性重绘管线默认把输入长边压到 `REDRAW_MAX_SIDE`(env,默认 1024)再放大回原尺寸;`redraw_max_side=0` 表示不缩图。
- `comfyui.run(front=True)` 可插队(接口2 对时延敏感);超时 `COMFY_TIMEOUT`(默认 600s)。
- `ok(data)` / `err(code, msg)` — helper functions at module level that build the standard response envelope
- `ImageJsonBody` — Pydantic model for JSON-based image params (URL/base64); form-upload endpoints use FastAPI `Form()` / `File()` parameters directly
- `SAMPLE_IMAGE_URL` — `https://hair.xiangsilian.com/static/sample.jpg`, returned as the image URL in all mock responses
- Static files (`/static/*`) are mounted via `app.mount("/static", StaticFiles(directory="static"), name="static")`
- All 5 POST endpoints use `multipart/form-data` with both `File` and `Form` fields — there are no JSON-body POST endpoints despite `ImageJsonBody` being defined (it exists for the OpenAPI schema but the actual handlers use `Form()` for all params)
## Deployment(本机 = gpu_worker)
### Deployment
systemd 单元(仓库根有副本,实装在 `/etc/systemd/system/`):
- **Process manager**: systemd (`hair.service` → `ExecStart` runs uvicorn directly from venv)
- **Reverse proxy**: nginx (`nginx/hair.conf`) proxies port 80 → `127.0.0.1:8000` for `hair.xiangsilian.com`
- **Python**: venv at `./venv`, dependencies in `requirements.txt`
- **PyPI mirror**: Tencent Cloud (`mirrors.cloud.tencent.com`), configured in `pip.conf`
| 服务 | 端口 | 说明 |
|------|------|------|
| `comfyui.service` | 127.0.0.1:8188 | `ComfyUI/venv` 的 `main.py --cache-classic --fast` |
| `hair-worker.service` | 0.0.0.0:8187 | 本仓库 `app:app`(Wants comfyui + change_hair-hair) |
| `change_hair-{webui,photo,hair}.service` | 57860 / 32678 / 8801 | conda envs `sdwebui`/`py310`/`my_hair`,依赖顺序 webui→photo→hair |
### Documentation
网关机另行部署 `gateway/`(config:`workers` 列表 + `shared_password` + `ark` key),见 `gateway/config.example.json` 与 `docs/网关待改动.md`。
- `docs/接口文档.md` — complete API documentation (inputs, outputs, examples, error codes)
- `docs/旷视具体需求.md` — original requirements spec (converted from `.docx`)
- The FastAPI app's `description` docstring in `app.py` is an additional source of API docs visible in Swagger UI
## Documentation
- `docs/接口文档.md` — 对外 API 契约(唯一权威)
- `docs/实现说明.md` — 架构总览 + 各接口实现简述 + 部署要点
- `docs/发际线增强算法.md`、`docs/发际线生发遮罩算法_pushed模式.md` — 接口11/12 算法推导
- `docs/gpu-worker改动说明.md`、`docs/网关待改动.md` — worker↔网关 协同变更记录
- `OFFLINE_ASSETS.md` — 离线模型权重/字体清单
+6 -6
View File
@@ -2,7 +2,7 @@
"1": {
"inputs": {
"scheduler": "simple",
"steps": 6,
"steps": 4,
"denoise": 1,
"model": [
"2",
@@ -170,10 +170,10 @@
},
"16": {
"inputs": {
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn"
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
"weight_dtype": "fp8_e4m3fn_fast"
},
"class_type": "UNETLoader",
"class_type": "UnetLoaderGGUF",
"_meta": {
"title": "UNet加载器"
}
@@ -410,7 +410,7 @@
},
"60": {
"inputs": {
"text": "补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头"
"text": "填充遮罩区域的头发,人物增加一点磨皮"
},
"class_type": "JjkText",
"_meta": {
@@ -421,7 +421,7 @@
"inputs": {
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
"type": "flux2",
"device": "default"
"device": "cpu"
},
"class_type": "CLIPLoader",
"_meta": {
+863 -124
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""快速测试:3张图×花瓣发型×896分辨率,新提示词"填充遮罩区域的头发,人物增加一点磨皮"。
预热1次+正式1次。
"""
import base64, json, os, time
from pathlib import Path
import requests
API = "http://127.0.0.1:8187/api/v1/debug/grow-timing"
TOKEN = "dev-shared-secret-2026"
PROMPT = "填充遮罩区域的头发,人物增加一点磨皮"
OUT = Path("/home/ubuntu/hair/benchmark_out/bench6")
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"),
]
def call(img_path, save_grown=None, timeout=300):
data = {"hair_style": "2", "webui_steps": "15", "redraw_max_side": "896", "redraw_prompt": PROMPT}
t0 = time.perf_counter()
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=data, timeout=timeout)
wall = time.perf_counter() - t0
j = r.json()
d = j["data"]; hs = d["per_hairstyle"][0]
if save_grown and hs.get("grown_b64"):
b = hs["grown_b64"].split(",")[1] if "," in hs["grown_b64"] else hs["grown_b64"]
open(save_grown, "wb").write(base64.b64decode(b))
return {"ok": hs.get("ok"), "total_ms": d["total_ms"], "comfy_ms": hs.get("comfyui_redraw_ms"),
"grown_path": str(save_grown) if save_grown and hs.get("ok") else None}
results = []
for ilabel, ipath in IMGS:
print(f"预热 {ilabel}...", flush=True)
call(ipath)
save = OUT / f"{ilabel}_flower_896.jpg"
print(f"正式 {ilabel}...", flush=True)
r = call(ipath, save_grown=save)
r["img"] = ilabel
print(f" -> total={r['total_ms']}ms ok={r['ok']}", flush=True)
results.append(r)
json.dump({"prompt": PROMPT, "results": results}, open(OUT/"results.json","w"), ensure_ascii=False, indent=2)
print(f"\n✓ 完成 {sum(1 for r in results if r['ok'])}/3", flush=True)
+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/grow,gender=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()
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""分辨率对比测试:4图×5发型=20行,每行4种分辨率(不缩放/896/768/640),steps=15。
热数据:每个组合预热1次(丢弃)+正式1次。OOM的跳过记录为失败。
"""
import base64
import json
import os
import time
from pathlib import Path
import requests
API = "http://127.0.0.1:8187/api/v1/debug/grow-timing"
TOKEN = "dev-shared-secret-2026"
OUT = Path("/home/ubuntu/hair/benchmark_out/bench3")
OUT.mkdir(parents=True, exist_ok=True)
IMGS = [
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
("girl2", "/home/ubuntu/hair/image/girl_img/girl2.jpg"),
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
]
HAIRSTYLES = [
(1, "ellipse", "椭圆"), (2, "flower", "花瓣"), (3, "heart", "心形"),
(4, "straight", "直线"), (5, "wave", "波浪"),
]
# 分辨率档:0=不缩放(原图)
RES_LIST = [("orig", "0"), ("896", "896"), ("768", "768"), ("640", "640")]
RES_TITLES = ["原图(不缩放)", "896", "768", "640"]
STEPS = 15
def call(img_path, hair_num, redraw_max_side, save_grown=None, timeout=300):
data = {"hair_style": str(hair_num), "webui_steps": str(STEPS),
"redraw_max_side": str(redraw_max_side)}
t0 = time.perf_counter()
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=data, timeout=timeout)
wall = time.perf_counter() - t0
j = r.json()
if j.get("code") != 0:
return {"ok": False, "error": j.get("message", "")[:80], "wall": wall}
d = j["data"]
hs = d["per_hairstyle"][0]
if save_grown and hs.get("grown_b64"):
b = hs["grown_b64"].split(",")[1] if "," in hs["grown_b64"] else hs["grown_b64"]
with open(save_grown, "wb") as gf:
gf.write(base64.b64decode(b))
return {
"ok": hs.get("ok", False), "wall": wall,
"total_ms": d["total_ms"], "swap_ms": hs.get("swap_ms"),
"comfy_ms": hs.get("comfyui_redraw_ms"),
"error": hs.get("error"),
}
except Exception as e:
return {"ok": False, "error": str(e)[:80], "wall": time.perf_counter() - t0}
def main():
rows = []
total = len(IMGS) * len(HAIRSTYLES) * len(RES_LIST) * 2
idx = 0
for ilabel, ipath in IMGS:
for hnum, hkey, hname in HAIRSTYLES:
cells = []
for (rlabel, rval), rtitle in zip(RES_LIST, RES_TITLES):
# 预热
idx += 1
print(f"[{idx}/{total}] 预热 {ilabel}|{hname}|{rtitle}", flush=True)
try:
call(ipath, hnum, rval, timeout=120)
except Exception:
pass # 预热失败(可能OOM)不中断
# 正式
idx += 1
save = OUT / f"{ilabel}_{hkey}_{rlabel}.jpg"
print(f"[{idx}/{total}] 正式 {ilabel}|{hname}|{rtitle}", flush=True)
r = call(ipath, hnum, rval, save_grown=save, timeout=300)
r["res_label"] = rlabel; r["res_title"] = rtitle
r["grown_path"] = str(save) if r.get("ok") else None
status = f"{r.get('total_ms')}ms" if r.get("ok") else f"FAIL:{r.get('error','')[:30]}"
print(f" -> {status}", flush=True)
cells.append(r)
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({"res_titles": RES_TITLES, "rows": rows}, f, ensure_ascii=False, indent=2)
ok = sum(1 for row in rows for c in row["cells"] if c.get("ok"))
print(f"\n✓ 完成: {ok}/{len(rows)*len(RES_LIST)} 成功 -> {OUT/'results.json'}", flush=True)
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成分辨率对比报告:20行(4图×5发型) × 4列(原图不缩放/896/768/640)。"""
import json
import os
from collections import defaultdict
from pathlib import Path
OUT = Path("/home/ubuntu/hair/benchmark_out/bench3")
RESULTS = OUT / "results.json"
HTML = OUT / "report.html"
def img_src(path):
if not path or not os.path.isfile(path):
return None
return "bench3/" + os.path.basename(path)
def main():
d = json.load(open(RESULTS, encoding="utf-8"))
titles = d["res_titles"]
rows = d["rows"]
# 各分辨率平均耗时
col_stats = defaultdict(lambda: {"total": [], "comfy": []})
for r in rows:
for c in r["cells"]:
if c.get("ok"):
col_stats[c["res_title"]]["total"].append(c["total_ms"])
col_stats[c["res_title"]]["comfy"].append(c.get("comfy_ms", 0))
# 表头
headers = ['<th class="col-label">原图</th>']
for t in titles:
s = col_stats.get(t)
avg = sum(s["total"]) // len(s["total"]) if s and s["total"] else 0
headers.append(f'<th class="col-label"><div class="col-title">{t}</div>'
f'<div class="col-stat">均{avg/1000:.1f}s</div></th>')
# 表体
body_rows = []
for r in rows:
label = f'<div class="row-label">{r["img"]}<br><b>{r["hair_name"]}</b></div>'
# 原图缩略图(用 orig 档的结果当原图展示,或用原图文件)
orig_cell = f'<td class="cell-orig"><div class="row-label-cell">{label}</div></td>'
cells = [orig_cell]
for c in r["cells"]:
src = img_src(c.get("grown_path")) if c.get("ok") else None
if src:
t = c.get("total_ms", 0)
cells.append(f'<td class="cell-result"><img class="result-img" src="{src}" loading="lazy">'
f'<div class="cell-time">{t/1000:.1f}s</div></td>')
else:
cells.append(f'<td class="cell-result"><div class="na">⚠<br>{c.get("error","")[:20]}</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>重绘分辨率对比报告 — steps=15</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: 130px; max-width: 150px; }}
.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; }}
img {{ border-radius: 4px; max-width: 130px; max-height: 160px; object-fit: contain; background: #f3f4f6; }}
.cell-time {{ font-size: 10px; color: #9ca3af; margin-top: 2px; }}
.na {{ color: #d1d5db; font-size: 12px; padding: 40px 10px; }}
</style>
</head>
<body>
<h1>📊 重绘分辨率对比报告</h1>
<p class="subtitle">4图×5发型=20行 · 每行4分辨率(原图不缩放/896/768/640) · steps=15 · 热数据 · 80/80成功 · 峰值21.2GB · 0 OOM</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()
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""分辨率对比测试(新提示词版):4图×5发型=20行,每行4种分辨率,steps=15。
提示词固定为 "填充遮罩区域的头发,人物增加一点磨皮"。
热数据:预热1次+正式1次。
"""
import base64
import json
import os
import time
from pathlib import Path
import requests
API = "http://127.0.0.1:8187/api/v1/debug/grow-timing"
TOKEN = "dev-shared-secret-2026"
PROMPT = "填充遮罩区域的头发,人物增加一点磨皮"
OUT = Path("/home/ubuntu/hair/benchmark_out/bench7")
OUT.mkdir(parents=True, exist_ok=True)
IMGS = [
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
("girl2", "/home/ubuntu/hair/image/girl_img/girl2.jpg"),
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
]
HAIRSTYLES = [
(1, "ellipse", "椭圆"), (2, "flower", "花瓣"), (3, "heart", "心形"),
(4, "straight", "直线"), (5, "wave", "波浪"),
]
RES_LIST = [("orig", "0"), ("896", "896"), ("768", "768"), ("640", "640")]
RES_TITLES = ["原图(不缩放)", "896", "768", "640"]
STEPS = 15
def call(img_path, hair_num, redraw_max_side, save_grown=None, timeout=300):
data = {"hair_style": str(hair_num), "webui_steps": str(STEPS),
"redraw_max_side": str(redraw_max_side), "redraw_prompt": PROMPT}
t0 = time.perf_counter()
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=data, timeout=timeout)
wall = time.perf_counter() - t0
j = r.json()
if j.get("code") != 0:
return {"ok": False, "error": j.get("message", "")[:80], "wall": wall}
d = j["data"]
hs = d["per_hairstyle"][0]
if save_grown and hs.get("grown_b64"):
b = hs["grown_b64"].split(",")[1] if "," in hs["grown_b64"] else hs["grown_b64"]
with open(save_grown, "wb") as gf:
gf.write(base64.b64decode(b))
return {
"ok": hs.get("ok", False), "wall": wall,
"total_ms": d["total_ms"], "swap_ms": hs.get("swap_ms"),
"comfy_ms": hs.get("comfyui_redraw_ms"),
"error": hs.get("error"),
}
except Exception as e:
return {"ok": False, "error": str(e)[:80], "wall": time.perf_counter() - t0}
def main():
rows = []
total = len(IMGS) * len(HAIRSTYLES) * len(RES_LIST) * 2
idx = 0
for ilabel, ipath in IMGS:
for hnum, hkey, hname in HAIRSTYLES:
cells = []
for (rlabel, rval), rtitle in zip(RES_LIST, RES_TITLES):
idx += 1
print(f"[{idx}/{total}] 预热 {ilabel}|{hname}|{rtitle}", flush=True)
try:
call(ipath, hnum, rval, timeout=120)
except Exception:
pass
idx += 1
save = OUT / f"{ilabel}_{hkey}_{rlabel}.jpg"
print(f"[{idx}/{total}] 正式 {ilabel}|{hname}|{rtitle}", flush=True)
r = call(ipath, hnum, rval, save_grown=save, timeout=300)
r["res_label"] = rlabel; r["res_title"] = rtitle
r["grown_path"] = str(save) if r.get("ok") else None
status = f"{r.get('total_ms')}ms" if r.get("ok") else f"FAIL:{r.get('error','')[:30]}"
print(f" -> {status}", flush=True)
cells.append(r)
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({"res_titles": RES_TITLES, "prompt": PROMPT, "rows": rows}, f, ensure_ascii=False, indent=2)
ok = sum(1 for row in rows for c in row["cells"] if c.get("ok"))
print(f"\n✓ 完成: {ok}/{len(rows)*len(RES_LIST)} 成功 -> {OUT/'results.json'}", flush=True)
if __name__ == "__main__":
main()
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""swap步数 + 重绘分辨率 对比测试(热数据)。
每个组合: 预热1次(丢弃) + 正式测1次(取热数据)。
B维度: steps=10/15/20 (分辨率固定896)
C维度: 分辨率=640/896/1024 (steps固定15)
4图×2发型=8组 × 6档 × 2次(预热+正式) = 96次
"""
import base64
import json
import os
import time
from pathlib import Path
import requests
API = "http://127.0.0.1:8187/api/v1/debug/grow-timing"
TOKEN = "dev-shared-secret-2026"
OUT = Path("/home/ubuntu/hair/benchmark_out/bench2")
OUT.mkdir(parents=True, exist_ok=True)
IMGS = [
("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
("girl2", "/home/ubuntu/hair/image/girl_img/girl2.jpg"),
("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
]
HAIRSTYLES = [(5, "wave", "波浪"), (3, "heart", "心形")]
# B维度: swap步数对比 (分辨率固定896)
B_STEPS = [10, 15, 20]
# C维度: 重绘分辨率对比 (steps固定15)
C_RES = [640, 896, 1024]
def call(img_path, hair_num, webui_steps=None, redraw_max_side=None, save_grown=None):
"""调调试接口。返回 dict。save_grown 非None时把结果图存到该路径。"""
data = {"hair_style": str(hair_num)}
if webui_steps is not None:
data["webui_steps"] = str(webui_steps)
if redraw_max_side is not None:
data["redraw_max_side"] = str(redraw_max_side)
t0 = time.perf_counter()
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=data, timeout=300)
wall = time.perf_counter() - t0
j = r.json()
if j.get("code") != 0:
return {"ok": False, "error": j.get("message", "")[:100], "wall": wall}
d = j["data"]
hs = d["per_hairstyle"][0]
if save_grown and hs.get("grown_b64"):
b = hs["grown_b64"].split(",")[1] if "," in hs["grown_b64"] else hs["grown_b64"]
with open(save_grown, "wb") as gf:
gf.write(base64.b64decode(b))
return {
"ok": hs.get("ok", False), "wall": wall,
"total_ms": d["total_ms"], "ctx_ms": d["extract_context_ms"],
"mask_ms": hs.get("mask_ms"), "swap_ms": hs.get("swap_ms"),
"blend_ms": hs.get("blend_ms"), "comfy_ms": hs.get("comfyui_redraw_ms"),
"error": hs.get("error"),
}
except Exception as e:
return {"ok": False, "error": str(e)[:100], "wall": time.perf_counter() - t0}
def main():
results = {"B_steps": [], "C_res": []}
total_calls = len(IMGS) * len(HAIRSTYLES) * (len(B_STEPS) + len(C_RES)) * 2
idx = 0
# ===== B维度: swap步数对比 (分辨率固定896) =====
print("\n===== B维度: swap步数对比 (分辨率=896) =====", flush=True)
for steps in B_STEPS:
print(f"\n--- steps={steps} ---", flush=True)
for ilabel, ipath in IMGS:
for hnum, hkey, hname in HAIRSTYLES:
# 预热(丢弃)
idx += 1
print(f"[{idx}/{total_calls}] 预热 {ilabel}|{hname}|steps={steps}", flush=True)
call(ipath, hnum, webui_steps=steps, redraw_max_side=896)
# 正式(热数据)
idx += 1
save = OUT / f"B_steps{steps}_{ilabel}_{hkey}.jpg"
print(f"[{idx}/{total_calls}] 正式 {ilabel}|{hname}|steps={steps}", flush=True)
r = call(ipath, hnum, webui_steps=steps, redraw_max_side=896, save_grown=save)
r["steps"] = steps; r["img"] = ilabel; r["hair"] = hkey; r["hair_name"] = hname
r["grown_path"] = str(save) if r.get("ok") else None
print(f" -> total={r.get('total_ms')}ms swap={r.get('swap_ms')}ms comfy={r.get('comfy_ms')}ms ok={r.get('ok')}", flush=True)
results["B_steps"].append(r)
# ===== C维度: 重绘分辨率对比 (steps固定15) =====
print("\n===== C维度: 重绘分辨率对比 (steps=15) =====", flush=True)
for res in C_RES:
print(f"\n--- res={res} ---", flush=True)
for ilabel, ipath in IMGS:
for hnum, hkey, hname in HAIRSTYLES:
idx += 1
print(f"[{idx}/{total_calls}] 预热 {ilabel}|{hname}|res={res}", flush=True)
call(ipath, hnum, webui_steps=15, redraw_max_side=res)
idx += 1
save = OUT / f"C_res{res}_{ilabel}_{hkey}.jpg"
print(f"[{idx}/{total_calls}] 正式 {ilabel}|{hname}|res={res}", flush=True)
r = call(ipath, hnum, webui_steps=15, redraw_max_side=res, save_grown=save)
r["res"] = res; r["img"] = ilabel; r["hair"] = hkey; r["hair_name"] = hname
r["grown_path"] = str(save) if r.get("ok") else None
print(f" -> total={r.get('total_ms')}ms swap={r.get('swap_ms')}ms comfy={r.get('comfy_ms')}ms ok={r.get('ok')}", flush=True)
results["C_res"].append(r)
with open(OUT / "results.json", "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
ok = sum(1 for r in results["B_steps"] + results["C_res"] if r.get("ok"))
print(f"\n✓ 完成: {ok}/{len(results['B_steps'])+len(results['C_res'])} 成功 -> {OUT/'results.json'}", flush=True)
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成 swap步数 + 重绘分辨率 对比报告 HTML。"""
import json
import os
from collections import defaultdict
from pathlib import Path
OUT = Path("/home/ubuntu/hair/benchmark_out/bench2")
RESULTS = OUT / "results.json"
HTML = OUT / "report.html"
def img_src(path):
if not path or not os.path.isfile(path):
return None
# benchmark_out/bench2/xxx.jpg -> bench2/xxx.jpg (报告在 static/ 下部署时调整)
p = str(path)
return "bench2/" + os.path.basename(p)
def main():
d = json.load(open(RESULTS, encoding="utf-8"))
b_data = d["B_steps"] # steps 对比
c_data = d["C_res"] # 分辨率对比
# B维度聚合
by_steps = defaultdict(list)
for r in b_data:
by_steps[r["steps"]].append(r)
b_summary = []
for s in sorted(by_steps):
rs = by_steps[s]
b_summary.append({
"label": f"steps={s}", "n": len(rs),
"swap": sum(r["swap_ms"] for r in rs) // len(rs),
"total": sum(r["total_ms"] for r in rs) // len(rs),
})
# C维度聚合
by_res = defaultdict(list)
for r in c_data:
by_res[r["res"]].append(r)
c_summary = []
for res in sorted(by_res):
rs = by_res[res]
c_summary.append({
"label": f"res={res}", "n": len(rs),
"comfy": sum(r["comfy_ms"] for r in rs) // len(rs),
"total": sum(r["total_ms"] for r in rs) // len(rs),
})
# B维度明细行(每图每发型每步数)
b_rows = []
for r in sorted(b_data, key=lambda x: (x["img"], x["hair"], x["steps"])):
src = img_src(r.get("grown_path"))
b_rows.append(f"""<tr>
<td>{r['img']}</td><td>{r['hair_name']}</td><td>{r['steps']}</td>
<td>{r.get('swap_ms','?')}</td><td>{r.get('comfy_ms','?')}</td><td>{r.get('total_ms','?')}</td>
<td>{f'<img src="{src}" loading="lazy">' if src else '⚠'}</td></tr>""")
# C维度明细行
c_rows = []
for r in sorted(c_data, key=lambda x: (x["img"], x["hair"], x["res"])):
src = img_src(r.get("grown_path"))
c_rows.append(f"""<tr>
<td>{r['img']}</td><td>{r['hair_name']}</td><td>{r['res']}</td>
<td>{r.get('swap_ms','?')}</td><td>{r.get('comfy_ms','?')}</td><td>{r.get('total_ms','?')}</td>
<td>{f'<img src="{src}" loading="lazy">' if src else '⚠'}</td></tr>""")
def bar_row(label, val, max_val, color, unit="ms"):
pct = max(1, val / max_val * 100) if max_val else 0
return f'<div class="step-row"><div class="step-name">{label}</div>' \
f'<div class="step-bar-wrap"><div class="step-bar {color}" style="width:{pct}%">{val}{unit}</div></div>' \
f'<div class="step-time">{val}{unit}</div></div>'
# B维度汇总条形图
b_max_swap = max(s["swap"] for s in b_summary)
b_bars = "".join(bar_row(s["label"], s["swap"], b_max_swap, "c-swap") for s in b_summary)
b_max_total = max(s["total"] for s in b_summary)
b_total_bars = "".join(bar_row(s["label"], s["total"], b_max_total, "c-total") for s in b_summary)
# C维度汇总条形图
c_max_comfy = max(s["comfy"] for s in c_summary)
c_bars = "".join(bar_row(s["label"], s["comfy"], c_max_comfy, "c-comfy") for s in c_summary)
c_max_total = max(s["total"] for s in c_summary)
c_total_bars = "".join(bar_row(s["label"], s["total"], c_max_total, "c-total") for s in c_summary)
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>swap步数 + 重绘分辨率 对比报告</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, "Segoe UI", sans-serif; background: #f5f5f5; padding: 16px; color: #333; }}
h1 {{ font-size: 20px; margin-bottom: 4px; }}
h2 {{ font-size: 16px; margin: 20px 0 10px; }}
.subtitle {{ color: #888; font-size: 12px; margin-bottom: 14px; }}
.card {{ background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 16px; overflow: hidden; }}
.card-header {{ font-weight: 700; font-size: 14px; padding: 12px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; }}
.card-body {{ padding: 18px; }}
.summary-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
.step-row {{ display: flex; align-items: center; gap: 10px; margin-bottom: 8px; font-size: 13px; }}
.step-name {{ width: 100px; flex-shrink: 0; font-weight: 600; }}
.step-bar-wrap {{ flex: 1; background: #f3f4f6; border-radius: 4px; height: 24px; min-width: 200px; }}
.step-bar {{ height: 100%; border-radius: 4px; display: flex; align-items: center; padding-left: 8px; color: #fff; font-size: 11px; font-weight: 600; min-width: 2px; }}
.step-time {{ width: 70px; text-align: right; font-weight: 600; flex-shrink: 0; font-variant-numeric: tabular-nums; }}
.c-swap {{ background: #f59e0b; }} .c-comfy {{ background: #ef4444; }} .c-total {{ background: #2563eb; }}
table {{ border-collapse: collapse; width: 100%; font-size: 12px; }}
th, td {{ border: 1px solid #eee; padding: 5px 8px; text-align: center; }}
th {{ background: #f9fafb; font-weight: 600; position: sticky; top: 0; }}
td img {{ max-height: 100px; max-width: 80px; border-radius: 4px; }}
.scroll {{ max-height: 400px; overflow: auto; }}
.note {{ background: #fef3c7; border-radius: 8px; padding: 10px 14px; font-size: 12px; color: #92400e; margin-top: 10px; }}
</style>
</head>
<body>
<h1>📊 swap步数 + 重绘分辨率 对比报告</h1>
<p class="subtitle">4图(asdf/qwer/girl2/girl5) × 2发型(波浪/心形) · 热数据(预热后取第2次) · 48/48成功 · 峰值20.6GB · 0 OOM</p>
<div class="note">💡 结论速览: B维度 steps 10→20 swap从3.0s→3.9s(每步省~90ms);C维度 res 640比896省3s(comfy 4.3s vs 7.3s),1024与896接近。</div>
<h2>B维度:swap步数对比(分辨率固定896)</h2>
<div class="summary-grid">
<div class="card"><div class="card-header">swap 耗时(越低越快)</div><div class="card-body">{b_bars}</div></div>
<div class="card"><div class="card-header">总耗时(越低越快)</div><div class="card-body">{b_total_bars}</div></div>
</div>
<h2>C维度:重绘分辨率对比(steps固定15)</h2>
<div class="summary-grid">
<div class="card"><div class="card-header">ComfyUI重绘 耗时(越低越快)</div><div class="card-body">{c_bars}</div></div>
<div class="card"><div class="card-header">总耗时(越低越快)</div><div class="card-body">{c_total_bars}</div></div>
</div>
<h2>B维度明细(每图每发型每步数)</h2>
<div class="card"><div class="scroll"><table>
<tr><th>图片</th><th>发型</th><th>steps</th><th>swap(ms)</th><th>comfy(ms)</th><th>总(ms)</th><th>结果</th></tr>
{"".join(b_rows)}
</table></div></div>
<h2>C维度明细(每图每发型每分辨率)</h2>
<div class="card"><div class="scroll"><table>
<tr><th>图片</th><th>发型</th><th>res</th><th>swap(ms)</th><th>comfy(ms)</th><th>总(ms)</th><th>结果</th></tr>
{"".join(c_rows)}
</table></div></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()
+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
+5 -5
View File
@@ -16,7 +16,7 @@
Handler 签名新增:
```python
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
prompt: str = Form(default=填充遮罩区域的头发,人物增加一点磨皮, description="ComfyUI 提示词,会替换工作流节点60的文本"),
```
透传链路:
@@ -31,7 +31,7 @@ handler → generate_grow_results(image, gender, use_mask, prompt)
Handler 签名新增:
```python
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
prompt: str = Form(default=填充遮罩区域的头发,人物增加一点磨皮, description="ComfyUI 提示词,会替换工作流节点60的文本"),
```
透传链路:
@@ -80,17 +80,17 @@ if prompt is not None:
| 2 | `image_base64` | string | — | 同上 |
| 2 | `gender` | string | `female` | `male` / `female` |
| 2 | `use_mask` | bool | `true` | inpaint 遮罩开关 |
| 2 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
| 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 | `prompt` | string | 填充遮罩区域的头发,人物增加一点磨皮 | ComfyUI 提示词 |
## 3. 当前工作流提示词节点
`add_hair.json` 中节点 **60**(`JjkText`)为提示词节点,当前硬编码文本:
> 补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头
> 填充遮罩区域的头发,人物增加一点磨皮
`comfyui.run()` 收到 `prompt` 参数后将替换此文本。
+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_map(int label map)
│ │ │
│ ├─ 步骤2:手绘发际线检测(仅 use_mask=True 时)
│ │ ├─ detect_marker_hairline(marked_bgr, landmarks, parse_map)
│ │ │ │ [marker_detect.py:41]
│ │ │ ├─ forehead_upper_region(landmarks) 额头上部 ROI
│ │ │ ├─ head_silhouette(parse_map) 头部轮廓 ROI
│ │ │ ├─ _blackhat(gray) 黑帽变换(响应比邻域暗的细结构)
│ │ │ ├─ _snap_anchor(bh, 左鬓角21) 左锚点吸附
│ │ │ ├─ _snap_anchor(bh, 右鬓角251) 右锚点吸附
│ │ │ ├─ route_through_array(cost, 左, 右) Dijkstra最小代价路径
│ │ │ └→ path (N,2) row,col(拒识返回 None → no_line)
│ │ │
│ │ ├─ path_to_curve_mask(path) 路径→曲线mask(uint8 0/255)
│ │ └─ mask_from_curve(curve_mask, landmarks, parse_map)
│ │ │ [mask.py]
│ │ ├─ _above_curve_region(curve_mask) 曲线以上区域
│ │ ├─ cv2.morphologyEx(闭运算) 填洞
│ │ ├─ 最大连通域
│ │ └─ 高斯羽化 → mask (uint8 0-255)
│ │
│ ├─ 步骤3:合成 RGBA PNG
│ │ ├─ compose_comfy_rgba(marked_bgr, mask) RGB=原图,alpha=255×(1-mask)
│ │ └─ PNG 编码 → rgba_png_bytes
│ │
│ └─ 步骤4:ComfyUI 生发
│ └─ comfyui.run(rgba_png_bytes, prompt) [comfyui.py:87]
│ ├─ 上传图片到 ComfyUI /upload/image
│ ├─ 加载工作流 add_hair.json
│ ├─ 替换节点26输入图 + 节点6随机seed + 节点60提示词
│ ├─ POST /prompt 提交工作流
│ ├─ 轮询 /history/{prompt_id}(间隔0.2s)
│ └─ GET /view 取回输出 PNG → grown_png
│
└─ 返回 {"grown_png": bytes, "status": "ok"}
```
---
## 四、用到的模型和外部服务
| 模型/服务 | 用途 | 位置 | 设备 |
|----------|------|------|------|
| **FaceLandmarker** (MediaPipe) | 478点人脸检测 | hairline/face_landmarks.py | CPU |
| **FaceParser** (SegFormer) | 面部分割(hair/skin/...) | hairline/face_parsing.py | CPU (5090不兼容cu121) |
| **ComfyUI** (Flux-2) | 生发图生成 | hairline/comfyui.py → :8188 | GPU |
**注意**:接口3 **不调用** change_hair 服务(:8801),不需要 swapHair。这是它与接口2女性流程的关键区别。
---
## 五、核心算法:手绘发际线检测
### 5.1 为什么不用简单阈值?
手绘马克笔线条的灰度值与皮肤阴影、抬头纹等重叠,全局阈值无法区分。采用**黑帽变换 + Dijkstra最小路径**方案。
### 5.2 黑帽变换(Black Hat)
```python
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
bh = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel)
```
黑帽 = 闭运算 − 原图,响应"比局部邻域暗的细结构"(即马克笔线条),对抬头纹/眉毛/发丝鲁棒。
### 5.3 Dijkstra 最小代价路径
1. **ROI 限定**:额头上部 ∩ 头部轮廓(排除背景)
2. **锚点**:左鬓角(21) / 右鬓角(251) MediaPipe 关键点
3. **代价图**:`cost = (bh.max() - bh) + 1.0`,ROI外设 1e6
4. **路径**:`route_through_array(cost, 左锚, 右锚)` — skimage 的 Dijkstra 实现
### 5.4 拒识机制
路径平均黑帽响应 < 8.0 → 判定"未画线",返回 `no_line`。
---
## 六、与接口1、接口2 的对比
| 维度 | 接口1 | 接口2 | 接口3 |
|------|-------|-------|-------|
| 功能 | 四庭七眼测量 | C端生发(5种发际线) | B端生发(手绘线) |
| 路由 | /api/v1/face/measure | /api/v1/hair/grow | /api/v1/hair/grow-b |
| 输入 | 正面照 | 正面照 | 划线图 |
| MediaPipe | ✅ | ✅ | ✅ |
| SegFormer | ✅ | ✅ | ✅ |
| change_hair | ❌ | ✅(女性) | ❌ |
| ComfyUI | ❌ | ✅(Flux-2重绘) | ✅(Flux-2生发) |
| 典型耗时 | ~2s | ~11s | ~6-8s |
| ComfyUI工作流 | — | 0716add-hair-api.json | add_hair.json |
---
## 七、测试
- **测试页面**:[static/test_interface3.html](file:///home/ubuntu/hair/static/test_interface3.html)
- **测试图片**:[image/girl_img/girl13.jpg](file:///home/ubuntu/hair/image/girl_img/girl13.jpg)(需手动在图上画发际线后作为划线图上传)
+56 -19
View File
@@ -12,15 +12,18 @@
```
客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py)
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI)
└ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/6/7
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI/change_hair)
└ 接口4(豆包)/99(特殊点) 本机实现(不转发) └ 接口1/2/3/5/6/7/9/10/11/12 + 工具路由
```
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,**纯本地、无外网依赖**。
对 `/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json` 的 `accept_passwords`);
`/health` 模型就绪才返回 200。监听 **8187**(`./start.sh` 控制开关,`./run_worker.sh` 热重载)。
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,除 ComfyUI/change_hair 走本机
回环外无外网依赖。对 `/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json` 的 `accept_passwords`);
`/health` 模型就绪才返回 200。监听 **8187**(systemd `hair-worker.service`,也可 `./run_worker.sh` 手动起)。
另有 worker 本机的工具路由:`/api/swapHair/v1`、`/hairColor/v2`(CORS 代理转发 change_hair:8801)、
`/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`(免鉴权调试)。
- **网关**(`gateway/`):薄反向代理,健康检查/派发/鉴权/把 worker 的 `*_base64` 落盘改成 `*_url`。
唯一例外是**接口4 在网关本机直接实现**(调外网豆包,不转发 worker)。
网关本机实现的接口(不转发 worker):**接口4**(调外网豆包)与**接口99**(纯 CPU 特殊点检测,
直接 import 同仓库 `face_analysis/special_points`,`_if99_lock` 单路排队)。
- **图片三选一**:所有接口图片入参 `image_file`/`image_url`/`image_base64` 严格三选一(接口3 是 `marked_image_*`)。
- **响应信封**:`{code, message, request_id, data}`;业务错误用 `code`(HTTP 一律 200)。
@@ -33,7 +36,10 @@
| 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` |
| 9 | `steps_common.*_base64`、`bisenet|segformer.{hair,closed_region,final_overlay,mask}_mask_base64` 等 | 同名 `*_url` |
| 10 | `steps_common.*_base64`、`bisenet|segformer.{base_mask,contour,band_overlay,mask}_base64` 等 | 同名 `*_url` |
| 11 | `steps.{input,baseline,hair_mask,mask_overlay,mask,swap_raw,alpha,final}_base64` | `steps.*_url` |
| 12 | `steps.{input,final,redraw_band_overlay,redraw_full,redraw_band,redraw_c}_base64` | `steps.*_url` |
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
@@ -47,7 +53,7 @@
---
## 2. 五个接口实现简述
## 2. 各接口实现简述
### 接口1 四庭七眼测量 `/api/v1/face/measure`(worker)
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、一张透明底标注 PNG。
@@ -65,10 +71,11 @@
### 接口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** 的
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。原生生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8188** 的
`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;male 1=ellipse 2=inverse_arc 3=m 4=straight。
- 模板对齐:左右刘海边界高低不同时,按 MediaPipe 左右对应额头点协调沿人脸向上方向的延伸距离,避免把 M 型等设计模板拉歪;保留人脸姿态,之后仍做头部轮廓钳制。预览白线与生发黑线/遮罩共用修正后的 502 点几何,不改变真实发际线测量算法。
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave 6=bigflower 7=clasicalflower;male 1=ellipse 2=inverse_arc 3=m 4=straight 5=heart 6=Softpetal 7=wave。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线(`generate_grow_results_swap`,复用接口12 final 的 `_V2_FINAL_DEFAULTS`,长边默认压到 `REDRAW_MAX_SIDE`=1024 再放大回原尺寸);female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线。可选参数 `flux_model`(切换 Flux unet,.gguf 自动配 UnetLoaderGGUF + 配套 qwen CLIP)、`redraw_max_side`(0=不缩图)。
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`(worker)—— 接口2同款,add_hair2 工作流
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` 逗号分隔多选 → N 组预览+生发图)。
@@ -92,20 +99,50 @@
- **做什么**:入参同接口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。
### 接口9 头发遮罩 `/api/v1/head/mask`(worker)
- **做什么**:人像照 → 头发遮罩 + 全流程分步可视化。
- **怎么实现**(`face_analysis/head_mask.py`):MediaPipe 底部分割线(21/68/104/69/108/151/337/299/333/298/251 连线,21/251 水平延伸)→ 线以上为上半区 → BiSeNet 与 SegFormer 双路头发分割、逐列向下填充到分割线得闭合区域(头发+额头)→ 外缘朝 151 内缩 `erode_cm`(默认1.2cm,虹膜标定) → 最终遮罩。返回 `steps_common` + `bisenet`/`segformer` 两组各四张图。
### 接口10 头部外缘膨胀带 `/api/v1/head/band`(worker)
- **做什么**:在接口9 基准遮罩基础上,沿头部外缘生成一条膨胀带遮罩。
- **怎么实现**(`face_analysis/head_band.py`):取基准遮罩外轮廓、去掉贴底分割线段 → 膨胀成总宽 `dilate_cm`(默认2cm) 的带子 → 裁到分割线以上。返回 `base_mask`/`contour`/`band_overlay`/`mask` 分步图。
### 接口11 发际线生发 `/api/v1/hairline/grow`(worker)—— 不含重绘
- **做什么**:发际线偏高/头发稀少正脸图 + `hairline_id`(= change_hair `hair_id`,如 `chang_tuoyuan`)→ 压低发际线图 + 全步骤可视化。
- **怎么实现**(`face_analysis/hairline_grow.py`,算法见 `发际线增强算法.md`):① 接口9 遮罩(`seg_model` 选 bisenet/segformer)→ ② 调 change_hair 生成该发际线类型图(`swap_mode=ext_mask` 把遮罩传给 webui 精确重绘 / `as_is` 用其内部遮罩;`gen_backend` 可换 hairgrow 区域生发 inpaint)→ ③ 严格按遮罩贴回 → ④ 接缝融合 `blend_method`(multiband 默认 / seamless / two_stage / feather / alpha_gradient,可选 Reinhard `color_match`)。返回 `steps.{input,baseline,hair_mask,mask_overlay,mask,swap_raw,alpha,final}`。
### 接口12 发际线带重绘 `/api/v1/hairline/grow_v2[_final[_v2]]`(worker)
- **做什么**:接口11 的下游重绘阶段——先跑接口11 拿 ④final,再取发际线重绘带(外推 `band_lo_mult×push`~`band_hi_mult×push`,默认 0.5×~1.5×)作遮罩,用 **Flux-2(ComfyUI,`0716add-hair-api.json`,reference-latent 保色)** 重绘,产出两版:`redraw_full`(整帧美颜+整帧重绘)与 `redraw_band`(band 内加发、band 外 final 结构 + `beauty_alpha` 融入全脸美颜)。
- **三个变体**:`grow_v2` 全参数暴露(接口11 参数 + `comfyui_prompt`/`beauty_alpha`/`band_lo_mult`/`band_hi_mult`);`grow_v2_final` / `grow_v2_final_v2` 精简生产版——仅图片 + `hairline_id`,其余固化 `_V2_FINAL_DEFAULTS`(push=0.8cm / two_stage / color_match=关 / beauty_alpha=0.6 等),两者后端计算完全一致(同一次输出含 A/B 两版),差异只是测试页展示哪版。接口2 女性 1..5 的生发图即复用此管线。
- ⚠️ 依赖 ComfyUI(:8188);重绘失败在 `data.redraw.c_error` 报告。
### 接口99 人脸特殊点 `/api/v1/face/special-points`(**网关本机**,worker 保留同路径备份)
- **做什么**:人脸照 → 60 个固定编号 MediaPipe 关键点(6 组:eyebrow/eye/mouth/nose_midline/cheek/face_edge)+ 5 个发际线特殊点,原图像素坐标 JSON(字段 `<组名>_<点ID>`,如 `eyebrow_107`)。
- **怎么实现**(`face_analysis/special_points.py`):MediaPipe 单例取关键点;发际线点=接口1 方案B 的 BiSeNet 头发 mask 上沿指定关键点方向(如 151→10)射线步进取首个头发像素,双射线的(right/right_right/left/left_left)取中点。**纯 CPU 可跑**(BiSeNet 单核 <1s),网关直接 import 本机实现(`_if99_lock` 单路排队);worker 侧保留同路径接口供测试页直连。部署依赖清单见 `网关待改动.md` §9。
- 测试页:`static/test_interface99.html`(canvas 可视化 + JSON,worker 本机直连)。
---
## 3. 部署 / 环境要点
**worker(GPU 机)**
- 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)。
**worker(GPU 机,本机 = gpu_worker 角色)**
- Python **3.10.12**(本机 venv 在 `./venv`);`numpy<2`(1.26.4),`scikit-image==0.24.0`
(**别升 0.25+,会顶 numpy≥2 顶崩 mediapipe**)。
- 本机 **RTX 4090**:venv 实装 `torch 2.7.1+cu128`,CUDA 可用(BiSeNet/SegFormer GPU 推理)。
`requirements.txt` 里 pinned 的 `torch 2.2.2(cu121)` 及 5090 注释是旧机(sm_120 无 cu121 kernel、
自动回退 CPU)的记录,本机以 venv 实际版本为准。
- 模型权重/字体见 [`../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`。
- 生发接口依赖本机 **ComfyUI(8188)**(Flux-2,systemd `comfyui.service`,`--cache-classic --fast`);
worker 只调其 HTTP API,不跑 Flux。ComfyUI 开了 **HTTP Basic Auth**(user `admin` + 密码);
密码放 `password.txt`(不入 git) / `worker_config.json.comfyui_password` / 环境变量 `COMFYUI_PASSWORD`,
URL 用 `COMFYUI_URL`。默认模型 flux-2-klein-9b Q4_K_M GGUF(接口2 `flux_model` 参数可切)。
- 换发型依赖本机 **change_hair**(`/home/ubuntu/change_hair`,三进程 webui:57860(sdwebui) →
photo_service:32678(py310/kohya) → hair_service_sd:8801(my_hair),`start_all.sh start|stop|status`,
也有 systemd `change_hair-{webui,photo,hair}.service`)。不跑时接口2女1..5、接口11/12、
swapHair/hairColor 代理不可用。
- `worker_config.json`(不入 git):`accept_passwords`(鉴权) + `comfyui_password`;鉴权头 `X-Internal-Token`。
- systemd 单元:`hair-worker.service`(8187) / `comfyui.service`(8188),仓库根有副本;手动启停用
`./run_worker.sh` / `./stop_worker.sh`。
**网关机**
- 很轻:FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
+192 -56
View File
@@ -20,7 +20,6 @@
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
| 4 用户特征 | POST | `/api/v1/face/features` |
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
| 7 C 端生发 v2 | POST | `/api/v1/hair/grow-v2` |
---
@@ -87,6 +86,7 @@
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
| 1008 | 图片格式不支持 | 非 JPG / PNG |
| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token`(`/api/*` 路径鉴权) |
---
@@ -109,6 +109,8 @@
| four_courts | object | 四庭数据,见下表 |
| seven_eyes | object | 七眼数据,见下表 |
| landmarks | object | 关键分界点坐标(头顶 / 发际线 / 眉心 / 鼻翼下缘 / 下巴尖),原图像素坐标 |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
`four_courts`(四庭,自上而下):
@@ -211,6 +213,8 @@
| four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio;**无顶庭**) |
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + **eye2~eye6** 共 5 段宽度) |
| landmarks | object | 四个关键点像素坐标(发际线/眉心/鼻翼下缘/下巴尖) |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
> 接口6 是**三庭五眼**:`four_courts`/`landmarks` 不含顶庭与头顶点(无 `top_court_cm`/`hair_top`);`seven_eyes` 只含 **eye2~eye6**(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段),**无 eye1/eye7**(耳外段需头发轮廓端线,仅接口1 有)。
@@ -261,10 +265,12 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal, 7=wave。越界/非法返回 `1007` |
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比。仅对原生发(male / female 6/7)管线生效 |
| prompt | string | 否 | ComfyUI 提示词,默认「填充遮罩区域的头发,人物增加一点磨皮」,会替换工作流节点 60 的文本。所有分支生效(male / female 6/7 原生生发,female 1..5 换发型后的发际线带重绘;不进 change_hair)。注:非默认提示词会使 ComfyUI 的 CLIP 条件缓存失效,换词后首次请求明显变慢(实测 45s vs 命中缓存 8s),相同提示词的后续请求恢复正常 |
| flux_model | string | 否 | Flux 模型文件名(如 `flux-2-klein-9b-Q5_K_M.gguf`)。缺省用工作流默认(9B-Q4_K_M);`.gguf` 自动切 UnetLoaderGGUF 并同步配套 qwen CLIP |
| redraw_max_side | int | 否 | female 1..5 重绘管线送模型前的长边上限像素。缺省=服务端默认(`REDRAW_MAX_SIDE`,当前 1024);`0`=不缩图(原图直送,更慢) |
### 输出(data)
@@ -274,10 +280,11 @@
|------|------|------|
| image_url | string | 发际线曲线**透明 PNG** URL(仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示) |
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图,完整人像照片) |
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`/`wave`(male) |
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8188)生成,**一次请求生成指定发型的 1 张、同步返回**;
> female 1..5 的生发图另依赖 change_hair 换发型服务(:8801)。
> worker 侧返回 `image_base64` / `grown_image_base64`,
> 网关落盘后改写为上表的 `image_url` / `grown_image_url`。
>
@@ -401,10 +408,11 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 5 / male 4)。缺失/非法返回 `1004` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。缺失/越界/非法返回 `1007` |
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 7 / male 7)。缺失/非法返回 `1004` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal, 7=wave。缺失/越界/非法返回 `1007` |
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,可大幅降低耗时 |
> ⚠️ 三档叠图分别用 `hairline_texture` / `hairline_texture_high` / `hairline_texture_low` 三套同名贴图;**生发黑模板固定取自 `hairline_texture_black/`(middle 档)**,即生发目标固定压到 middle 档,每个发型仅 1 张生发图。
@@ -422,11 +430,11 @@
| 字段 | 类型 | 说明 |
|------|------|------|
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`/`wave`(male) |
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败时为 `null`) |
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null`) |
| order | int | 发型序号(= 传入的 hair_style 值) |
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。
@@ -441,8 +449,10 @@
| 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`(比例估算) |
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算)/ `corrected`(发际线贴近头顶、分割不可靠,已按 0.95×中庭反推修正) |
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
@@ -508,71 +518,197 @@
---
## 接口 7:C 端生发 v2 接口
## 接口 9:头发遮罩生成接口
**说明**:功能与[接口 2](#接口-2c-端生发接口)完全一致,仅 ComfyUI 工作流不同——使用 `add_hair2.json` 替代 `add_hair.json`。
**说明**:输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试)。
**请求**:`POST /api/v1/hair/grow-v2`
**请求**:`POST /api/v1/head/mask`
### 输入
与接口 2 完全相同。图片参数见「通用约定 → 图片传参字段」。专属参数:
图片参数见「通用约定 → 图片传参字段」。专属参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007` |
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线) |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
| erode_cm | float | 否 | 外缘朝中心(鼻梁 151 点)内缩的距离(厘米),默认 1.2 |
### 处理流程
1. MediaPipe 关键点 → 底部分割线(关键点 21,68,104,69,108,151,337,299,333,298,251 的连线,21 水平延伸到最左、251 延伸到最右)。
2. 分割线以上为「上半区」。
3. 头发分割(**BiSeNet** 与 **SegFormer** 两套);从每列最顶端头发向下填充到分割线,得到**含额头**的闭合区域(头发+额头,不割断)。
4. 外缘朝中心点 151 内缩 `erode_cm`(虹膜标定换算像素)、底线不动 → 最终遮罩。
### 输出(data)
与接口 2 完全相同。`results`:发际线方案数组,**数量 = 所选发型数**。每个元素:
| 字段 | 类型 | 说明 |
|------|------|------|
| image_url | string | 发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图,完整人像照片) |
| hairline_type | string | 发际线类型 key |
| order | int | 排序序号 |
| px_per_cm | float | 虹膜标定出的像素/厘米 |
| erode_cm / erode_px | float / int | 内缩距离(厘米 / 像素) |
| image_size | object | `{width, height}` 原图尺寸 |
| steps_common | object | `landmarks_baseline_*`(关键点/分割线)、`upper_region_*`(上半区) |
| bisenet / segformer | object | 两套分割模型各自的结果:`hair_mask_*`(原始头发分割)、`closed_region_*`(含额头闭合区域)、`final_overlay_*`(最终遮罩叠加可视化)、`mask_*`(最终遮罩 PNG)及 `hair_pixels`/`closed_pixels`/`mask_pixels` 像素计数 |
> ⚠️ 与接口 2 的区别:本接口使用 `add_hair2.json` 工作流(Flux-2 Klein 9b),输入/遮罩节点同为 26,
> SaveImage 输出节点为 75。
> worker 返回 `*_base64`(data URI),经网关改写为 `*_url`。
### 响应示例
---
```json
{
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"results": [
{
"image_url": "https://hair.xiangsilian.com/static/sample.jpg",
"grown_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
"hairline_type": "ellipse",
"order": 1
}
]
}
}
```
## 接口 10:头部外缘膨胀带遮罩接口
**说明**:先和接口9 一样得到内缩后的基准遮罩,再沿头部外缘生成一条膨胀带遮罩。
**请求**:`POST /api/v1/head/band`
### 输入
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| erode_cm | float | 否 | 基准遮罩外缘朝 151 内缩距离(厘米,同接口9),默认 1.2 |
| dilate_cm | float | 否 | 外轮廓线膨胀后带子总宽(厘米),默认 2.0 |
### 处理流程
1. 取基准遮罩的**外轮廓线**,去掉贴着底部分割线的那一段(只留头发/头部外缘弧线)。
2. 把外轮廓线膨胀成带子(总宽 `dilate_cm`;半径=总宽/2,虹膜标定换算像素)。
3. 裁到分割线以上(不越过底线)。
### 输出(data)
结构同接口9,但每组结果为 `base_mask_*`(基准遮罩)/ `contour_*`(外轮廓线)/ `band_overlay_*`(带子叠加可视化)/ `mask_*`(最终带状遮罩)及 `base_pixels`/`band_pixels` 计数,另有 `dilate_cm`/`dilate_radius_px`。
---
## 接口 11:发际线生发接口(不含重绘)
**说明**:输入一张**发际线较高 / 头发稀少**的正脸图 + 发际线类型 ID,输出同一个人、同一发型、按该类型压低发际线后的图片,并返回**每一步可视化**。重绘版见接口12。
**请求**:`POST /api/v1/hairline/grow`
### 输入
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| hairline_id | string | **是** | 发际线类型 ID(= change_hair `hair_id`):`chang_tuoyuan` / `chang_huaban` / `chang_xinxing` / `chang_zhixian` / `chang_bolang` |
| gen_backend | string | 否 | 生成后端:`swaphair`(换发型 LoRA,默认)|`hairgrow`(区域生发 inpaint) |
| hairgrow_strength | float | 否 | 区域生发强度(仅 hairgrow 后端),默认 0.75 |
| is_hr | bool | 否 | 高清模式(换发型输出 1152×1536,否则 576×768),默认 false |
| seg_model | string | 否 | 头发分割模型:`bisenet` | `segformer`(默认) |
| erode_cm | float | 否 | baseline 参考内缩距离(厘米),默认 0.6 |
| hairline_push_cm | float | 否 | 发际线外推距离(厘米,往头发方向推进),默认 1.0 |
| hairline_edge | string | 否 | 发际线提取方式:`column`(默认)|`contour` |
| swap_mode | string | 否 | 换发型取图模式:`ext_mask`(默认,把接口9 遮罩传给 webui 精确重绘)|`as_is`(用内部遮罩,贴回时再裁) |
| edge_erode_px | int | 否 | 贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3 |
| denoising_strength | float | 否 | 换发型 webui 重绘强度(越大生发越激进),默认 0.6 |
| mb_levels | int | 否 | 多频段金字塔层数(2~6),默认 5 |
| blend_method | string | 否 | 接缝融合:`multiband`(默认)|`seamless`(泊松)|`two_stage`(泊松→多频段)|`feather`(高斯羽化)|`alpha_gradient`(距离渐变) |
| color_match | bool | 否 | 融合前 Reinhard 颜色迁移消除整体色差,默认 true |
| color_match_strength | float | 否 | 颜色迁移强度(0~1),默认 1.0 |
| mb_feather_px | int | 否 | 多频段最细层掩码轻羽化像素,默认 1 |
| transition_band_px | int | 否 | keep-region 过渡带边距(-1=自动按层数 2^n),默认 -1 |
| inpainting_fill | int | 否 | change_hair 服务端重绘填充:0=保留原图 | 1=噪声(默认)| 2=纯色 | 3=潜变量噪声 |
| mask_blur | int | 否 | change_hair 服务端遮罩边缘模糊像素,默认 11 |
| mask_dilate_scale | float | 否 | change_hair 服务端遮罩膨胀缩放(<1 收缩防越界),默认 1.0 |
### 输出(data)
`steps`:`input`(原图)/ `baseline`(底部分割线)/ `hair_mask`(头发分割)/ `mask_overlay`(遮罩叠加)/ `mask`(遮罩)/ `swap_raw`(换发型原始输出)/ `alpha`(融合权重)/ `final`(最终结果)各步图(`*_base64`,经网关改写为 `*_url`),另有 `timings_ms` 分步耗时。
> ⚠️ `swaphair` 后端依赖 change_hair(:8801)在跑;`hairgrow` 后端依赖 ComfyUI(:8188)。
---
## 接口 12:发际线带重绘接口
**说明**:接口11 的**下游重绘阶段**——内部先跑接口11 核心管线拿 ④ 接缝融合最终图(final),再取发际线重绘带(外推 `band_lo_mult×push` ~ `band_hi_mult×push` 之间、经 baseline 截断只留上部的带状区域)作遮罩,用 **Flux-2(ComfyUI,reference-latent 保色重绘)** 重绘。
**同时产出两版结果**:
- `redraw_full`:ComfyUI 整帧输出(全脸美颜 + 全脸重绘)。
- `redraw_band`:加发只在发际线带内、band 外保留 final 结构并按 `beauty_alpha` 融入全脸美颜。
**请求**:`POST /api/v1/hairline/grow_v2`
### 输入
接口11 的全部参数(见上表)再叠加:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| comfyui_prompt | string | 否 | Flux-2 重绘提示词,缺省「填充遮罩区域的头发,人物增加一点磨皮」 |
| beauty_alpha | float | 否 | redraw_band 版 band 外的全脸美颜融入强度(0=band 外纯用 final,1≈整帧版),默认 0.6 |
| band_lo_mult | float | 否 | 重绘带外推倍率下限(相对 hairline_push_cm),默认 0.5 |
| band_hi_mult | float | 否 | 重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5 |
### 输出(data)
`steps`:`input` / `final`(重绘输入基底)/ `redraw_band_overlay`(重绘带可视化)/ `redraw_full`(A 整帧)/ `redraw_band`(B 局部+美颜)/ `redraw_c`(兼容旧字段,=redraw_full);重绘异常在 `redraw.c_error` 报告。
### 精简变体(生产推荐)
| 路径 | 说明 |
|------|------|
| `POST /api/v1/hairline/grow_v2_final` | 仅需图片 + `hairline_id`,其余参数固化为调优默认值(push=0.8cm / two_stage / color_match=关 等),**最终取整帧重绘 `redraw_full`** |
| `POST /api/v1/hairline/grow_v2_final_v2` | 参数同上,**最终取 B 局部加发+全脸美颜 `redraw_band`** |
> ⚠️ 依赖 ComfyUI(:8188)与 change_hair(:8801)均在跑。接口2 女性 1..5 的生发图即复用 `grow_v2_final` 管线。
---
## 接口 99:人脸特殊点检测接口
**说明**:输入一张人脸照片,返回一组**固定编号 MediaPipe Face Mesh 关键点**的像素坐标,外加 5 个**发际线特殊点**(射线与头发分割发际线的交点)。**生产路径:网关本机实现(纯 CPU,不转发 worker,同接口4 模式)**;worker 侧保留同路径接口供测试页直连。
**请求**:`POST /api/v1/face/special-points`
### 输入
图片参数见「通用约定 → 图片传参字段」,无专属参数。要求单人正面照(非正面返回 `1003`)。
### 输出(data)
`points`:字段名 = `<组英文名>_<点ID>`,值为原图像素坐标 `{x, y}`(int);发际线点不可得(秃顶/分割失败)时为 null。
| 组 | 字段前缀 | 点 ID |
|----|----------|-------|
| 眉毛 | `eyebrow` | 107 66 105 63 70 336 296 334 293 300 |
| 眼睛 | `eye` | 189 222 224 113 226 413 442 444 342 446 |
| 嘴 | `mouth` | 0 37 40 67 91 84 17 405 375 409 269 |
| 中线鼻 | `nose_midline` | 9 168 197 5 4 194 278 48 437 217 |
| 面颊 | `cheek` | 101 117 123 187 205 50 330 346 352 280 425 |
| 边线 | `face_edge` | 162 234 58 136 149 152 378 365 288 401 366 389 251 |
**发际线特殊点**(沿关键点 a→b 方向射线步进,首次进入 BiSeNet 头发 mask 处为交点;双射线取中点,仅一条命中返回该点):
| 字段 | 计算 |
|------|------|
| `hair_line_center` | 151→10 方向交点 |
| `hair_line_right` | mid(108→109 交点A, 69→67 交点B) |
| `hair_line_right_right` | mid(104→103 交点A, 68→54 交点B) |
| `hair_line_left` | mid(337→338 交点A, 299→297 交点B) |
| `hair_line_left_left` | mid(333→332 交点A, 298→284 交点B) |
另有 `image_size`(宽高)与 `hairline_source`(`segmentation`=发际线点已算出 / `unavailable`=分割不可用,发际线点为 null)。
---
## 汇总:输入输出一览
| 接口 | 输入 | 主要输出 |
|------|------|----------|
| 1 四庭七眼测量 | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 |
| 6 四庭七眼测量 v2 | 用户照片 | 同接口1,复刻实现 |
| 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单/多张) |
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
| 7 C 端生发 v2 | 用户照片 + gender + hair_style | 同接口2,使用 add_hair2.json 工作流 |
| 接口 | 路径 | 输入 | 主要输出 |
|------|------|------|----------|
| 1 四庭七眼测量 | `/api/v1/face/measure` | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 |
| 6 四庭七眼测量 v2 | `/api/v1/face/measure-v2` | 用户照片 | 同接口1,去顶庭变体 |
| 2 C 端生发 | `/api/v1/hair/grow` | 用户照片 + gender + hair_style(多选) | 每发型预览 PNG + 生发图(female 1..5=换发型+重绘,其余=原生生发) |
| 7 C 端生发 v2 | `/api/v1/hair/grow-v2` | 同接口2 | 同接口2(add_hair2 工作流,已弃用隐藏) |
| 3 B 端生发 | `/api/v1/hair/grow-b` | 划线图片 | 检测到的发际线 + 生发后图片 |
| 4 用户特征 | `/api/v1/face/features` | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格),网关本机实现 |
| 5 发际线 PNG | `/api/v1/hairline/generate` | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
| 9 头发遮罩 | `/api/v1/head/mask` | 用户照片 | 头发遮罩 + 分步可视化(BiSeNet/SegFormer 双路) |
| 10 外缘膨胀带 | `/api/v1/head/band` | 用户照片 | 头部外缘带状遮罩 + 分步可视化 |
| 11 发际线生发 | `/api/v1/hairline/grow` | 用户照片 + hairline_id | 压低发际线图 + 全步骤可视化(不含重绘) |
| 12 发际线带重绘 | `/api/v1/hairline/grow_v2` | 同接口11 + 重绘带参数 | final 基底 + Flux-2 保色重绘(整帧/局部两版);精简版 `grow_v2_final[_v2]` 仅需图片+hairline_id |
| 99 人脸特殊点 | `/api/v1/face/special-points` | 用户照片 | 60 个固定编号关键点 + 5 个发际线特殊点像素坐标 JSON(**网关本机实现**,同接口4) |
> 另有 worker 本机工具路由(不在对外契约内):`/api/swapHair/v1`、`/hairColor/v2`(CORS 代理 change_hair)、`/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`。
---
@@ -582,7 +718,7 @@
- 默认单人;检测到 2~3 人报错提示客户端;单文件 **≤ 1 MB**;格式仅 **JPG / PNG**。
- 所有坐标基准:**原图像素**。
- 接口 2:提供「美颜」开关 `beauty_enabled`。
- 接口 3:需同时上传原图,需返回发际线类型。
- 接口 3:仅传**划线图**(`marked_image_*` 三选一),需返回发际线类型(`custom`)。
- 接口 4:`data` 返回**一个 JSON 字符串**,字段不固定,可随时调整。
- 接口 5:发际线 PNG 数量 **N 不固定**,需按合适度**排序**。
- 英文字段命名由本文档约定(见各接口表格)。
+99 -1
View File
@@ -70,7 +70,7 @@ async def hair_grow_v2(request: Request):
|------|------|------|------|
| image_file / image_url / image_base64 | — | **三选一** | 用户正面照 |
| gender | string | **是** | `male` / `female` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female: 1–5,male: 1–4 |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female / male 均为 1–7 |
| beauty_enabled | bool | 否 | 美颜开关(本期不生效) |
| use_mask | bool | 否 | 默认 `true`,`false` 跳过遮罩 |
| prompt | string | 否 | ComfyUI 提示词 |
@@ -135,6 +135,104 @@ async def face_measure_v2(request: Request):
---
## 7. ✅【已完成】接口9/10 头部遮罩(`/api/v1/head/mask`、`/api/v1/head/band`)
`gateway/app.py` 已新增两条盲转发路由(同接口6 的写法):
```python
@app.post("/api/v1/head/mask", tags=["人脸分析"])
@app.post("/api/v1/head/band", tags=["人脸分析"])
```
- 入参:图片三选一 + `erode_cm`(接口10 另有 `dilate_cm`)。
- 出参:`steps_common.*_base64` 与 `bisenet`/`segformer` 组内的 `*_base64` 字段——现有递归
`rewrite_base64_to_url` 已覆盖(嵌套 dict/数组),无需额外改动。
- 两个接口响应较慢(双分割模型跑两遍),注意 `request_timeout_seconds`。
---
## 8. 🔲【新增】接口11/12 发际线生发/带重绘(`/api/v1/hairline/grow*`)
worker 侧已上线 4 条路由,**网关尚无对应转发路由**(当前仅测试页直连 worker)。如需对外提供,参考接口9 加法:
```python
@app.post("/api/v1/hairline/grow", tags=["生发"]) # 接口11
@app.post("/api/v1/hairline/grow_v2", tags=["生发"]) # 接口12 全参数
@app.post("/api/v1/hairline/grow_v2_final", tags=["生发"]) # 接口12 final 精简版
@app.post("/api/v1/hairline/grow_v2_final_v2", tags=["生发"])
```
- 入参:图片三选一 + `hairline_id`(接口12 final 两变体仅需这两个)。接口11/12 全参数版还有
20+ 个调优 Form 参数,网关盲转发即可,无需逐个声明。
- 出参:`data.steps.*_base64`(input/final/redraw_full/redraw_band 等各步图)→ 现有递归改写覆盖。
- ⚠️ 依赖 worker 机的 change_hair(:8801) 与 ComfyUI(:8188) 都在跑;单请求 10s+,超时建议 ≥120s。
- 契约细节见 [`接口文档.md`](接口文档.md) 接口11/12 章节。
---
## 9. ✅【新增】接口99 人脸特殊点(`/api/v1/face/special-points`)— **网关本机实现,不转发 worker**
**背景**:接口99(固定编号 MediaPipe 关键点 6 组 60 点 + 5 个发际线特殊点,返回像素坐标 JSON)
**纯 CPU 可跑**(MediaPipe 本就 CPU;BiSeNet 分割单核实测 <1s)且调用频率低。
按约定:**不用 GPU、低频的接口默认网关本机实现**,不占 worker 队列
(worker 池 `per_worker_concurrency=1`,转发会排在生发任务后面等)。
**网关代码已在本仓库 `gateway/app.py` 改好**——与接口4 同款本机实现(显式 Form 参数 +
惰性导入 + `run_in_threadpool` + `gw-` request_id),直接 import 同仓库的
`face_analysis/special_points.detect_special_points`。**单路排队**:`_if99_lock` 全局互斥,
同一时刻只跑一路推理(限制 CPU 峰值 + 规避 MediaPipe 单例线程安全问题),请求 FIFO 排队。
### 网关机部署前检查
1. **Python 3.10~3.12**(mediapipe 0.10.14 无 3.13 wheel)。
2. **系统库**(无 GUI 服务器跑 opencv/mediapipe 必需,已装可跳过):
```bash
sudo apt install -y libgl1 libglib2.0-0 libegl1
```
3. **安装依赖**(网关 venv,国内镜像):
```bash
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple \
mediapipe==0.10.14 opencv-python==4.10.0.84 numpy==1.26.4
# 可选:BiSeNet 头发分割(发际线 5 点需要)。CPU 版 torch 仅 ~200MB:
pip install torch==2.2.2 torchvision==0.17.2 --index-url https://download.pytorch.org/whl/cpu
# 不装 torch 也能跑:60 个关键点正常返回,发际线 5 点为 null(hairline_source="unavailable")
# ⚠️ 网关无 GPU——务必用上面的 CPU 专用源装 torch;装默认 PyPI 版会带 2.5G 的 CUDA 包,浪费但也能跑(自动回退 CPU)
```
4. **权重已入 git**,`git clone/pull` 自动到位:`face_analysis/weights/79999_iter.pth`(51MB)。
无需手动下载任何模型文件。
5. **无 GPU 已实测**:`CUDA_VISIBLE_DEVICES=""` 模拟验证通过(70/70 点,与 GPU 结果一致);
CPU 推理 16 核 0.18s/次,网关机核少时约 0.5~2s/次,低频可接受。
### 部署与冒烟
```bash
cd /path/to/hair && git pull
sudo systemctl restart hair-gateway # 或网关实际服务名(uvicorn gateway.app:app)
# 冒烟(multipart 传图;经网关无需 X-Internal-Token)
curl -s -X POST http://127.0.0.1:8080/api/v1/face/special-points \
-F "image_file=@/tmp/face.jpg" | python3 -m json.tool | head -30
# 期望 code=0;data.points 含 eyebrow_107 等 60 个关键点 + hair_line_* 5 点
# 缺依赖时返回 code=1007 且 message 提示"网关依赖未安装"
```
### 注意
- **超时无需调大**:单路 CPU 推理 <1s;即使排队,低频下积压可忽略。排队本身无超时,
调用方按需自带客户端超时。
- **错误码透传契约**:1001(无人脸)/ 1003(非正面)/ 1007(参数/处理失败/缺依赖)/
1008(图片格式/下载失败),HTTP 一律 200。
- **worker 侧同路径接口保留**(`app.py`),供测试页 `static/test_interface99.html`
直连调试(需 token);生产路径是网关本机实现。
- 契约详见 [`接口文档.md`](接口文档.md) 接口99 章节。
---
## 已经做好、无需再动的
- **接口4 在网关本机实现**(调豆包,不转发 worker)——已完成;config 里配 `ark`。
+234
View File
@@ -0,0 +1,234 @@
# 网关摘除隔离期实施方案(方案 B)
> 目标读者:网关机器的部署/维护者。本文档给出基于 **main 分支** `gateway/` 代码的
> 完整改动说明,可直接照做。**不需要改 worker 侧任何代码。**
## 1. 背景与事故复盘
2026-09-19 晚,GPU worker 机器(ubuntu3090_debug_4090 所在主机)磁盘写满,业务接口
全部失败,但网关**持续把请求打到该机器近 16 个小时**(21:30 故障发生 → 次日 13:36
人工重启),没有切换到其他 worker。复盘结论是三层防线同时失效:
| 层 | 问题 | 后果 |
|---|---|---|
| 1. worker `/health` 太浅 | 只返回内存里的"模型就绪"标志,磁盘满/下游 ComfyUI 挂掉不影响它 | 探测一直 200 |
| 2. 业务错误不触发摘除 | 下游出错经包装后以 HTTP 200/400 + 错误码返回,`forward.py` 只对 连接异常/超时/≥500/401 摘除 | 请求失败但 worker 被视为健康 |
| 3. 摘除后 8 秒即复活(**本方案要修的**) | `mark_worker_unhealthy()` 下线后,下一轮探测(8s 后)`/health` 返回 200,`healthy_threshold=1` 使其**一次成功立即重新上线** | 即使被摘除也会反复横跳,流量持续打入 |
worker 侧的第 1 层已经修复(方案 A:`/health` 深检磁盘水位 + 下游依赖 TCP 探测,
异常返回 503,代码在分支 `ubuntu3090_debug_4090`,提交 `14562d4`)。本方案修复
第 3 层:**被动摘除后进入隔离期,隔离期内即使探测成功也不复活**。两层配合后:
worker 磁盘满 → `/health` 503 → 网关 2 次探测失败摘除 → 隔离期内不再接流量。
> 第 2 层(错误码语义化,涉及调用方兼容)暂不处理,单独立项。
## 2. 现状代码行为(main 分支 gateway/pool.py)
- `mark_worker_unhealthy(w)`:被 `forward.py` 在转发失败(连接异常/超时/5xx/401)时
调用,置 `w.online = False`。**没有任何冷却机制**。
- `_health_check_loop(cfg)`:每 `interval_seconds`(默认 8s)探测所有 worker 的
`/health`;返回 200 时 `consecutive_successes += 1`,达到 `healthy_threshold`
(默认 **1**)且当前 offline → 立即 `online = True`。
- 因此被动摘除最多 8 秒后就被一次探测成功拉回——如果 worker 是"浅健康"或故障
间歇性表现,就会陷入 摘除→复活→失败→摘除 的横跳。
- 另外 58-96 行有一段**从未被调用的死代码** `_check_worker_health()` /
`_mark_failure()`(写了一半的旧实现),本次顺带删除,避免后人改错地方。
## 3. 代码改动(共 2 个文件 + 1 个配置)
### 3.1 `gateway/pool.py`
**(1) `WorkerState` 增加一个字段:**
```python
@dataclass
class WorkerState:
"""单个 worker 的运行时状态。"""
url: str
online: bool = False # 初始 offline,等健康检查通过后上线
busy: bool = False
consecutive_failures: int = 0
consecutive_successes: int = 0
offline_until: float = 0.0 # 被动摘除后的隔离截止时刻(time.monotonic() 基准)
```
**(2) 模块顶部(`logger = ...` 之后)加一个配置读取 helper:**
```python
def _cooldown_seconds() -> float:
"""被动摘除后的隔离期时长(秒),配置 health_check.cooldown_seconds,默认 60。"""
try:
from gateway.config import get_config
return float(get_config()["health_check"].get("cooldown_seconds", 60))
except Exception: # noqa: BLE001
return 60.0
```
(函数内 import 是为了不引入模块级循环依赖;`gateway/config.py` 不依赖 pool,顶层
import 也可以。)
**(3) `mark_worker_unhealthy()` 摘除时同时设置隔离期:**
```python
async def mark_worker_unhealthy(w: WorkerState) -> None:
"""被动标记:转发失败时立即将该 worker 下线,并进入隔离期。
隔离期内即使 /health 探测成功也不复活(由 _health_check_loop 守卫),
避免"摘除→8 秒复活→再失败"的横跳。
"""
async with _pool_condition:
if w.online:
w.online = False
w.consecutive_failures += 1
w.offline_until = time.monotonic() + _cooldown_seconds()
logger.warning("⚠ Worker 被动下线: %s(转发失败,隔离 %.0fs)",
w.url, _cooldown_seconds())
```
**(4) `_health_check_loop()` 的探测成功分支加隔离期守卫。** 找到这段:
```python
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)
```
改为:
```python
if resp.status_code == 200:
w.consecutive_failures = 0
w.consecutive_successes += 1
if w.consecutive_successes >= healthy_threshold and not w.online:
if time.monotonic() < w.offline_until:
logger.debug("Worker %s 探测成功但处于隔离期,暂不复活", w.url)
else:
w.online = True
logger.info("✅ Worker 上线: %s", w.url)
```
注意:`except`(连接异常)分支只累计失败、不涉及复活,无需改动;
`init_pool()` 启动时的首轮探测同理无需改动(进程刚启动时 `offline_until` 为 0,
不构成隔离)。
**(5) 删除死代码**:`_check_worker_health()` 与
`_mark_failure()` 两个函数整体删除(约 58-96 行,以函数名为准,别删到
`_health_check_loop`)。删除前可确认无调用方:
`grep -rn "_check_worker_health\|_mark_failure" .` 应只剩定义处。
### 3.2 `gateway/config.py`
`DEFAULTS["health_check"]` 增加一个 key:
```python
"health_check": {
"path": "/health",
"interval_seconds": 8,
"timeout_seconds": 3,
"unhealthy_threshold": 2,
"healthy_threshold": 1,
"cooldown_seconds": 60, # ← 新增:被动摘除后的隔离期(秒)
},
```
`config.example.json` 同步加这个 key。网关机器的实际 `config.json` **不加也不会报错**
(代码里有 60 的默认兜底),但建议显式加上,便于运维感知。
### 3.3 建议单测:`tests/test_gateway_pool.py`(新文件)
gateway 子包目前没有任何测试。隔离期是纯逻辑,建议至少覆盖以下三个用例
(不依赖真实网络):
```python
"""gateway/pool.py 隔离期逻辑单测。"""
import asyncio
import time
from unittest.mock import patch
from gateway import pool
from gateway.pool import WorkerState
def _drain(coro):
return asyncio.run(coro)
def test_passive_ejection_sets_cooldown():
w = WorkerState(url="http://127.0.0.1:9000", online=True)
async def run():
pool._pool_condition = asyncio.Condition()
with patch.object(pool, "_cooldown_seconds", return_value=60.0):
await pool.mark_worker_unhealthy(w)
_drain(run())
assert w.online is False
assert w.offline_until > time.monotonic()
def test_probe_success_revives_after_cooldown():
w = WorkerState(url="http://127.0.0.1:9000") # offline
w.offline_until = time.monotonic() - 1 # 隔离期已过
# 直接验证状态迁移逻辑(若按 3.1(4) 内联实现,可复制该分支逻辑做断言,
# 或将判断抽成 _try_revive(w, healthy_threshold) 小函数后单测)
...
def test_probe_success_blocked_during_cooldown():
w = WorkerState(url="http://127.0.0.1:9000")
w.offline_until = time.monotonic() + 60
... # 断言保持 offline
```
> 提示:如果按 3.1(4) 原样内联修改,后两个用例不好直接驱动,可以把"是否允许复活"
> 抽成一个 6 行的纯函数 `_try_revive(w, healthy_threshold) -> bool` 再测,主循环
> 调用它即可——实现方式自定,覆盖语义即可。
## 4. 部署步骤(网关机器)
1. `git pull` main 分支(含本方案代码改动)。
2. (可选但建议)`config.json` 的 `health_check` 段加 `"cooldown_seconds": 60`。
3. 重启网关服务(`hair-gateway.service` 或等价的 uvicorn 进程)。重启期间网关
本身短暂不可用,选低峰执行。
4. 观察启动日志:`健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=N`
正常出现。
## 5. 验证
**功能验证(低峰演练):**
1. 人工把主 worker 摘除一次最直接的方法:临时在 worker 机器
`systemctl stop hair-worker`(或临时设 `HEALTH_MIN_FREE_GB=999` 重启)。
2. 观察网关日志:
- 连续 2 次探测失败 → `⚠ Worker 下线`(原有行为);
- **转发失败触发的** `⚠ Worker 被动下线: ...(转发失败,隔离 60s)`(新日志);
- 恢复 worker 后,若刚被被动摘除过,即使 `/health` 恢复 200,也应看到 60 秒内
**不出现** `✅ Worker 上线`,隔离期满后下一次探测成功才上线。
3. `GET /gateway-health` 观察 `workers_healthy` 的变化节奏。
**回归确认:** 正常场景 worker 上下线不受影响(隔离期只对"被动摘除"生效;
探测失败导致的下线,恢复后仍按 `healthy_threshold` 立即上线)。
## 6. 回滚
- 代码回滚:`git revert` 对应提交即可,无数据/配置迁移。
- 紧急关闭隔离期:网关机器 `config.json` 设 `"cooldown_seconds": 0`
(等价于旧行为——摘除后下一次探测成功即复活),无需回代码。
## 7. 附:worker 侧方案 A 的行为(已上线,供联调参考)
worker(分支 `ubuntu3090_debug_4090`,提交 `14562d4`)的 `/health` 现在会在以下
情况返回 **503**(网关 2 次探测失败即摘除):
- 模型加载中:`503 {"status":"starting"}`(原有行为不变)
- 磁盘剩余低于阈值:`503 {"status":"degraded","reason":"disk_free_below_5.0g","disk_free_gb":...}`
- 下游依赖(ComfyUI 8188 / change_hair 8801)TCP 不通:
`503 {"status":"degraded","reason":"dep_down:comfyui,...","deps":{...}}`
正常时返回 `200 {"status":"ok","disk_free_gb":24.6,"deps":{"comfyui":true,"change_hair":true}}`。
worker 侧配置(`worker_config.json` 的 `health` 段,环境变量可覆盖):
`min_free_gb`(默认 5,0=关闭)、`check_deps`(默认 true)、`dep_comfyui`(默认
127.0.0.1:8188)、`deps_cache_seconds`(默认 5)。**其他 worker 机器如果也想启用
深检,cherry-pick 该提交并补一份 worker_config.json 的 health 段即可。**
+506
View File
@@ -0,0 +1,506 @@
"""
build_dataset_report.py
对任意图片目录(可含多层子目录)批量预测脸型并生成 HTML 报告。
保留图片原始所属的子目录名作为「分组」,在报告中按分组展示与统计。
用法:
./venv/bin/python face/build_dataset_report.py --src <图片目录> [--sample 50] [--seed 42]
示例:
./venv/bin/python face/build_dataset_report.py \
--src face/test_img/脸型测试集合 --sample 50 --name 脸型测试集合
输出:
static/<slug>_report.html
static/<slug>_report/images/*.jpg
"""
from __future__ import annotations
import argparse
import html
import random
import re
import shutil
import sys
import unicodedata
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
from typing import Dict, List
import cv2
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from face.face_shape_classifier import classify_from_image # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
MAX_IMAGE_SIDE = 900
JPEG_QUALITY = 88
SHAPE_ORDER = ["圆形脸", "心形脸", "菱形脸", "鹅蛋脸", "方形脸", "长形脸", "瓜子脸"]
SHAPE_COLORS = {
"圆形脸": "#e67e22",
"心形脸": "#e74c3c",
"菱形脸": "#9b59b6",
"鹅蛋脸": "#27ae60",
"方形脸": "#2980b9",
"长形脸": "#16a085",
"瓜子脸": "#c0392b",
"检测失败": "#7f8c8d",
}
# 数据集分组名与分类器脸型口径的近似对应(仅用于交叉表高亮参考,非严格标签)
TAXONOMY_EQUIV = {
"方形脸": "方形脸",
"长形脸": "长形脸",
"瓜子脸": "瓜子脸",
"标准脸": "鹅蛋脸",
"娃娃脸": "圆形脸",
}
FEATURE_KEYS = [
"face_width",
"face_height",
"jaw_angle",
"taper_ratio",
"forehead_ratio",
"cheekbone_ratio",
"jaw_ratio",
"chin_ratio",
"chin_sharpness",
"width_uniformity",
"face_curve_score",
]
def natural_key(text: str):
parts = re.split(r"(\d+)", text)
return [int(p) if p.isdigit() else p for p in parts]
def collect_images(src: Path) -> List[Path]:
return sorted(
(p for p in src.rglob("*") if p.suffix.lower() in IMAGE_SUFFIXES),
key=lambda p: natural_key(str(p.relative_to(src))),
)
def group_of(path: Path, src: Path) -> str:
"""图片相对根目录的父目录名;直接位于根目录则记为「根目录」。"""
rel = path.relative_to(src).parent
return str(rel) if str(rel) != "." else "(根目录)"
def ascii_slug(text: str, fallback: str) -> str:
"""生成安全的 ASCII 文件名片段(中文目录名转拼音不可靠,直接编号兜底)。"""
norm = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
norm = re.sub(r"[^A-Za-z0-9_-]+", "_", norm).strip("_")
return norm or fallback
def stratified_sample(
images: List[Path], src: Path, total: int, seed: int, min_per_group: int
) -> List[Path]:
"""
按分组分层抽样:先保证每组至少 min_per_group 张,剩余名额按组大小比例分配。
小分组(如只有 3 张的梨形脸)在纯随机抽样下几乎必然缺席,分层可保证覆盖。
"""
rng = random.Random(seed)
buckets: Dict[str, List[Path]] = defaultdict(list)
for p in images:
buckets[group_of(p, src)].append(p)
groups = sorted(buckets, key=natural_key)
quota = {g: min(min_per_group, len(buckets[g])) for g in groups}
remaining = total - sum(quota.values())
if remaining > 0:
spare = {g: len(buckets[g]) - quota[g] for g in groups}
pool = sum(spare.values())
if pool > 0:
# 按剩余可选量比例分配,再把取整误差补给最大的分组
extra = {g: int(remaining * spare[g] / pool) for g in groups}
for g in sorted(groups, key=lambda g: -spare[g]):
if sum(extra.values()) >= remaining:
break
if extra[g] < spare[g]:
extra[g] += 1
for g in groups:
quota[g] += min(extra[g], spare[g])
chosen: List[Path] = []
for g in groups:
chosen.extend(rng.sample(buckets[g], min(quota[g], len(buckets[g]))))
return chosen
def analyze(
src: Path, sample: int, seed: int, img_dir: Path, min_per_group: int
) -> List[Dict]:
all_images = collect_images(src)
if not all_images:
raise SystemExit(f"目录中没有图片: {src}")
if sample and sample < len(all_images):
if min_per_group > 0:
chosen = stratified_sample(all_images, src, sample, seed, min_per_group)
else:
chosen = random.Random(seed).sample(all_images, sample)
chosen.sort(key=lambda p: natural_key(str(p.relative_to(src))))
else:
chosen = all_images
mode = f"分层抽样,每组至少 {min_per_group} 张" if min_per_group > 0 else "纯随机抽样"
print(f"共发现 {len(all_images)} 张图片,本次测试 {len(chosen)} 张({mode},seed={seed})\n")
if img_dir.exists():
shutil.rmtree(img_dir)
img_dir.mkdir(parents=True)
group_slugs: Dict[str, str] = {}
rows: List[Dict] = []
for idx, path in enumerate(chosen, 1):
group = group_of(path, src)
if group not in group_slugs:
group_slugs[group] = ascii_slug(group, f"g{len(group_slugs) + 1}")
out_name = f"{group_slugs[group]}_{idx:03d}.jpg"
item = {
"index": idx,
"group": group,
"file": path.name,
"rel_path": str(path.relative_to(src)),
# 相对 static/ 的路径(报告 HTML 也放在 static/ 根下)
"img_src": f"{img_dir.relative_to(ROOT / 'static').as_posix()}/{out_name}",
"ok": False,
"predicted": None,
"display": None,
"confidence": None,
"score": None,
"top3": [],
"features": {},
"error": None,
}
try:
result = classify_from_image(path, return_details=True, return_annotated=True)
annotated = result["annotated"]
h, w = annotated.shape[:2]
if max(h, w) > MAX_IMAGE_SIDE:
scale = MAX_IMAGE_SIDE / max(h, w)
annotated = cv2.resize(
annotated, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
)
cv2.imwrite(str(img_dir / out_name), annotated, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
item.update(
{
"ok": True,
"predicted": result["face_shape"],
"display": result["display"],
"confidence": result["confidence"],
"score": result["details"]["ranked"][0][1],
"top3": result["details"]["ranked"][:3],
"features": {k: result["features"][k] for k in FEATURE_KEYS},
}
)
except Exception as exc: # noqa: BLE001 - 报告需要汇总所有失败样本
img = cv2.imread(str(path))
if img is not None:
h, w = img.shape[:2]
if max(h, w) > MAX_IMAGE_SIDE:
scale = MAX_IMAGE_SIDE / max(h, w)
img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
cv2.imwrite(str(img_dir / out_name), img, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
item["error"] = str(exc)
rows.append(item)
print(f"[{idx:3d}/{len(chosen)}] [{group}] {path.name} -> {item['display'] or 'ERR: ' + str(item['error'])}")
return rows
def bar_chart(counter: Counter) -> str:
if not counter:
return "<p class='muted'>无数据</p>"
total = sum(counter.values())
parts = []
order = [s for s in SHAPE_ORDER if counter.get(s)] + [
s for s in counter if s not in SHAPE_ORDER
]
for shape in order:
n = counter[shape]
color = SHAPE_COLORS.get(shape, "#7f8c8d")
pct = n / total * 100
parts.append(
f"<div class='bar-row'><span class='bar-label'>{html.escape(shape)}</span>"
f"<div class='bar-track'><div class='bar-fill' style='width:{pct:.1f}%;background:{color}'></div></div>"
f"<span class='bar-num'>{n}({pct:.0f}%)</span></div>"
)
return "".join(parts)
def fmt_feat(key: str, value: float) -> str:
if key in {"face_width", "face_height"}:
return f"{value:.1f}px"
if key == "jaw_angle":
return f"{value:.1f}°"
return f"{value:.3f}"
def card(item: Dict) -> str:
group_tag = f"<span class='group-tag'>{html.escape(item['group'])}</span>"
if not item["ok"]:
return f"""
<article class="card error">
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank">
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
</a>
<div class="body">
<div class="meta"><h3>{html.escape(item['file'])}</h3>{group_tag}</div>
<p class="badge bad">检测失败</p>
<p class="muted">{html.escape(item['error'] or '')}</p>
</div>
</article>"""
color = SHAPE_COLORS.get(item["predicted"], "#34495e")
top3 = "".join(
f"<li><span>{html.escape(name)}</span><b>{score:.1f}</b></li>" for name, score in item["top3"]
)
feat_html = "".join(
f"<tr><td>{html.escape(k)}</td><td>{html.escape(fmt_feat(k, v))}</td></tr>"
for k, v in item["features"].items()
)
return f"""
<article class="card">
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank" title="点击查看大图标注">
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
</a>
<div class="body">
<div class="meta"><h3>{html.escape(item['file'])}</h3>{group_tag}</div>
<p class="path muted">{html.escape(item['rel_path'])}</p>
<p class="badge" style="background:{color}">{html.escape(item['display'])}</p>
<p class="conf">匹配度 {item['score']:.1f} · 置信度 {item['confidence']:.3f}</p>
<ul class="scores">{top3}</ul>
<details>
<summary>标注特征数值</summary>
<table>{feat_html}</table>
</details>
</div>
</article>"""
CSS = """
:root { --bg:#f3efe6; --ink:#1c1915; --muted:#6b645a; --card:#fffdf8; --line:#e2d8c8; --accent:#0f6b5c; }
* { box-sizing: border-box; }
body {
margin:0; font-family:"PingFang SC","Noto Sans SC","Segoe UI",sans-serif; color:var(--ink);
background: radial-gradient(1200px 600px at 10% -10%, #ffe8c8 0%, transparent 55%),
radial-gradient(900px 500px at 100% 0%, #d9f2ea 0%, transparent 50%), var(--bg);
}
header { padding:40px 24px 20px; max-width:1320px; margin:0 auto; }
header h1 { margin:0 0 8px; font-size:clamp(1.8rem,3vw,2.4rem); }
header p { margin:4px 0; color:var(--muted); }
.legend-box { max-width:1320px; margin:0 auto 20px; padding:0 24px; }
.legend-box .inner { background:var(--card); border:1px solid var(--line); border-radius:14px;
padding:14px 16px; font-size:.9rem; line-height:1.55; }
.legend-box code { background:#efe7da; padding:1px 6px; border-radius:4px; font-size:.84rem; }
.swatch { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:4px; vertical-align:middle; }
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:16px;
max-width:1320px; margin:0 auto 28px; padding:0 24px; }
.stat { background:var(--card); border:1px solid var(--line); border-radius:16px; padding:16px 18px; }
.stat h2 { margin:0 0 12px; font-size:1rem; }
.bar-row { display:grid; grid-template-columns:72px 1fr 80px; gap:8px; align-items:center; margin:6px 0; font-size:.86rem; }
.bar-track { height:8px; background:#efe7da; border-radius:999px; overflow:hidden; }
.bar-fill { height:100%; border-radius:999px; }
.bar-num { color:var(--muted); text-align:right; }
section { max-width:1320px; margin:0 auto 36px; padding:0 24px; }
section h2 { margin:0 0 14px; font-size:1.3rem; border-left:4px solid var(--accent); padding-left:10px; }
section h2 small { color:var(--muted); font-weight:400; font-size:.8rem; margin-left:8px; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(270px,1fr)); gap:16px; }
.card { background:var(--card); border:1px solid var(--line); border-radius:18px; overflow:hidden;
display:flex; flex-direction:column; box-shadow:0 8px 24px rgba(60,40,10,.05); }
.card.error { opacity:.9; }
.img-link { display:block; }
.card img { width:100%; aspect-ratio:3/4; object-fit:cover; background:#ddd; display:block; }
.card .body { padding:14px; }
.meta { display:flex; justify-content:space-between; align-items:baseline; gap:8px; }
.meta h3 { margin:0; font-size:.95rem; word-break:break-all; }
.group-tag { font-size:.72rem; color:var(--accent); background:#e7f6f2; padding:2px 8px;
border-radius:999px; white-space:nowrap; }
.path { font-size:.72rem; margin:4px 0 0; word-break:break-all; }
.badge { display:inline-block; margin:10px 0 4px; color:#fff; padding:6px 10px; border-radius:999px;
font-weight:600; font-size:.92rem; }
.badge.bad { background:#c0392b; }
.conf { margin:0 0 8px; color:var(--muted); font-size:.85rem; }
.scores { list-style:none; padding:0; margin:0 0 8px; }
.scores li { display:flex; justify-content:space-between; padding:4px 0; border-bottom:1px dashed var(--line); font-size:.86rem; }
details { margin-top:8px; }
summary { cursor:pointer; color:var(--accent); font-size:.86rem; }
table { width:100%; border-collapse:collapse; margin-top:8px; font-size:.8rem; }
td { padding:3px 0; border-bottom:1px solid var(--line); }
td:last-child { text-align:right; font-variant-numeric:tabular-nums; }
.muted { color:var(--muted); }
.cross-wrap { overflow-x:auto; background:var(--card); border:1px solid var(--line);
border-radius:16px; padding:14px 16px; }
table.cross { border-collapse:collapse; width:100%; font-size:.88rem; }
table.cross th, table.cross td { padding:7px 10px; text-align:center; border-bottom:1px solid var(--line);
white-space:nowrap; }
table.cross thead th { background:#efe7da; font-weight:600; position:sticky; top:0; }
table.cross th.rowh { text-align:left; font-weight:600; }
table.cross th.rowh small { color:var(--muted); font-weight:400; }
table.cross td.num { font-variant-numeric:tabular-nums; }
table.cross td.hit { background:#d8f3e4; color:#0f6b5c; font-weight:700; font-variant-numeric:tabular-nums; }
table.cross td.zero { color:#cfc6b6; }
footer { max-width:1320px; margin:0 auto; padding:8px 24px 40px; color:var(--muted); font-size:.85rem; }
"""
def cross_table(by_group: Dict[str, List[Dict]]) -> str:
"""原始分组 × 预测脸型 交叉表,对角线(口径对应的格子)高亮。"""
cols = SHAPE_ORDER + ["检测失败"]
head = "".join(f"<th>{html.escape(c)}</th>" for c in cols)
body = []
for group, items in sorted(by_group.items(), key=lambda kv: natural_key(kv[0])):
counts = Counter(i["predicted"] if i["ok"] else "检测失败" for i in items)
equiv = TAXONOMY_EQUIV.get(group)
cells = []
for c in cols:
n = counts.get(c, 0)
if n == 0:
cells.append("<td class='zero'>·</td>")
continue
cls = "hit" if c == equiv else "num"
cells.append(f"<td class='{cls}'>{n}</td>")
label = html.escape(group)
if equiv:
label += f" <small>≈{html.escape(equiv)}</small>"
body.append(f"<tr><th class='rowh'>{label}</th>{''.join(cells)}<th>{len(items)}</th></tr>")
return (
"<div class='cross-wrap'><table class='cross'>"
f"<thead><tr><th>原始分组 \\ 预测</th>{head}<th>合计</th></tr></thead>"
f"<tbody>{''.join(body)}</tbody></table></div>"
)
def build_html(rows: List[Dict], name: str, src: Path, seed: int, img_dir_name: str) -> str:
overall = Counter(r["predicted"] if r["ok"] else "检测失败" for r in rows)
by_group: Dict[str, List[Dict]] = defaultdict(list)
for r in rows:
by_group[r["group"]].append(r)
group_stats = "".join(
f"<div class='stat'><h2>{html.escape(g)} <span class='muted'>({len(items)} 张)</span></h2>"
f"{bar_chart(Counter(i['predicted'] if i['ok'] else '检测失败' for i in items))}</div>"
for g, items in sorted(by_group.items(), key=lambda kv: natural_key(kv[0]))
)
sections = "".join(
f"<section><h2>{html.escape(g)}<small>{len(items)} 张</small></h2>"
f"<div class='grid'>{''.join(card(i) for i in items)}</div></section>"
for g, items in sorted(by_group.items(), key=lambda kv: natural_key(kv[0]))
)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
n_ok = sum(1 for r in rows if r["ok"])
n_kind = len([s for s in SHAPE_ORDER if overall.get(s)])
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{html.escape(name)} — 脸型分类报告</title>
<style>{CSS}</style>
</head>
<body>
<header>
<h1>{html.escape(name)} — 脸型分类报告</h1>
<p>z 分数原型匹配分类 · 照片上标注 face_width / face_height 及各比例特征</p>
<p>生成时间:{html.escape(now)} · 抽样 {len(rows)} 张(随机种子 {seed})· 成功 {n_ok} 张 ·
覆盖 {n_kind} 种脸型 · 共 {len(by_group)} 个原始分组 · 点击图片看大图</p>
<p class="muted">来源目录:{html.escape(str(src))}</p>
</header>
<div class="legend-box">
<div class="inner">
<b>图上标注说明</b><br/>
<span class="swatch" style="background:#00dc78"></span><code>face_width</code> 颧骨宽度&nbsp;
<span class="swatch" style="background:#28b4ff"></span><code>face_height</code> 额头顶→下巴&nbsp;
<span class="swatch" style="background:#ff5a00"></span><code>jaw_angle</code> 下巴到左右下颌角夹角&nbsp;
<span class="swatch" style="background:#ffc828"></span><code>taper_ratio</code> 额头→下巴收窄&nbsp;
<span class="swatch" style="background:#28a0ff"></span><code>forehead / jaw / chin ratio</code> 各级宽度比&nbsp;
<span class="swatch" style="background:#b4ff50"></span><code>face_curve_score</code> 下颌中点→下巴&nbsp;
右侧柱状条示意 <code>width_uniformity</code>;左上角是完整数值图例。
</div>
</div>
<section>
<h2>原始分组 × 预测脸型 对照<small>数据集分组本身是脸型标签,但命名口径与分类器不同</small></h2>
{cross_table(by_group)}
<p class="muted" style="margin-top:10px;font-size:.85rem">
绿色格子表示预测结果与该分组的对应口径一致(标准脸≈鹅蛋脸、娃娃脸≈圆形脸,方形/长形/瓜子同名直接对应)。
「梨形脸」「混合脸」在分类器的 7 分类里没有对应项,不作一致性判断。
</p>
</section>
<div class="stats">
<div class="stat"><h2>总体脸型分布({len(rows)} 张)</h2>{bar_chart(overall)}</div>
{group_stats}
</div>
{sections}
<footer>
分类实现:face/face_shape_classifier.py · 报告生成:face/build_dataset_report.py ·
图片目录:static/{html.escape(img_dir_name)}/
</footer>
</body>
</html>
"""
def main() -> None:
ap = argparse.ArgumentParser(description="批量脸型预测并生成 HTML 报告")
ap.add_argument("--src", required=True, help="图片根目录(可含子目录)")
ap.add_argument("--sample", type=int, default=50, help="随机抽样张数,0 表示全部")
ap.add_argument("--seed", type=int, default=42, help="随机种子")
ap.add_argument("--name", default=None, help="报告标题,默认取目录名")
ap.add_argument("--slug", default="dataset", help="输出文件名前缀(ASCII)")
ap.add_argument(
"--min-per-group",
type=int,
default=2,
help="分层抽样时每个分组至少抽几张,0 表示纯随机抽样",
)
args = ap.parse_args()
src = Path(args.src).expanduser().resolve()
if not src.is_dir():
raise SystemExit(f"目录不存在: {src}")
name = args.name or src.name
img_dir_name = f"{args.slug}_report"
img_dir = ROOT / "static" / img_dir_name / "images"
out_html = ROOT / "static" / f"{args.slug}_report.html"
rows = analyze(src, args.sample, args.seed, img_dir, args.min_per_group)
out_html.write_text(
build_html(rows, name, src, args.seed, img_dir_name), encoding="utf-8"
)
overall = Counter(r["predicted"] if r["ok"] else "检测失败" for r in rows)
total = sum(overall.values())
print(f"\n写入 {out_html}")
print("=== 总体脸型分布 ===")
for shape in SHAPE_ORDER + ["检测失败"]:
n = overall.get(shape, 0)
if n:
print(f" {shape}: {n:3d} ({n / total * 100:4.1f}%) {'#' * n}")
if __name__ == "__main__":
main()
+359
View File
@@ -0,0 +1,359 @@
"""
build_report.py
对 face/test_img/girl 与 face/test_img/man 下的照片批量预测脸型,
在照片上标注 face_width / face_height 等特征,并生成 HTML 报告。
用法:
./venv/bin/python face/build_report.py
输出:
static/face_shape_report.html
static/face_shape_report/images/*.jpg
"""
from __future__ import annotations
import html
import re
import shutil
import sys
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Dict, List
import cv2
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from face.face_shape_classifier import classify_from_image # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
SRC_DIRS = {
"女": ROOT / "face/test_img/girl",
"男": ROOT / "face/test_img/man",
}
OUT_DIR = ROOT / "static/face_shape_report"
IMG_DIR = OUT_DIR / "images"
OUT_HTML = ROOT / "static/face_shape_report.html"
MAX_IMAGE_SIDE = 900
JPEG_QUALITY = 90
SHAPE_ORDER = ["圆形脸", "心形脸", "菱形脸", "鹅蛋脸", "方形脸", "长形脸", "瓜子脸"]
SHAPE_COLORS = {
"圆形脸": "#e67e22",
"心形脸": "#e74c3c",
"菱形脸": "#9b59b6",
"鹅蛋脸": "#27ae60",
"方形脸": "#2980b9",
"长形脸": "#16a085",
"瓜子脸": "#c0392b",
}
FEATURE_KEYS = [
"face_width",
"face_height",
"jaw_angle",
"taper_ratio",
"forehead_ratio",
"cheekbone_ratio",
"jaw_ratio",
"chin_ratio",
"chin_sharpness",
"width_uniformity",
"face_curve_score",
]
def natural_key(path: Path):
m = re.search(r"(\d+)", path.stem)
return (0, int(m.group(1))) if m else (1, path.stem)
def analyze_all() -> tuple[List[Dict], Dict[str, Counter]]:
if IMG_DIR.exists():
shutil.rmtree(IMG_DIR)
IMG_DIR.mkdir(parents=True)
rows: List[Dict] = []
summary = {"女": Counter(), "男": Counter(), "all": Counter()}
for gender, src in SRC_DIRS.items():
prefix = "girl" if gender == "女" else "man"
paths = sorted(
[p for p in src.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}],
key=natural_key,
)
for path in paths:
m = re.search(r"(\d+)", path.stem)
out_name = f"{prefix}_{int(m.group(1)) if m else 0:02d}.jpg"
dest = IMG_DIR / out_name
item = {
"gender": gender,
"file": path.name,
"img_src": f"face_shape_report/images/{out_name}",
"ok": False,
"predicted": None,
"display": None,
"confidence": None,
"score": None,
"top3": [],
"features": {},
"error": None,
}
try:
result = classify_from_image(path, return_details=True, return_annotated=True)
annotated = result["annotated"]
h, w = annotated.shape[:2]
if max(h, w) > MAX_IMAGE_SIDE:
scale = MAX_IMAGE_SIDE / max(h, w)
annotated = cv2.resize(
annotated, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
)
cv2.imwrite(str(dest), annotated, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
item.update(
{
"ok": True,
"predicted": result["face_shape"],
"display": result["display"],
"confidence": result["confidence"],
"score": result["details"]["ranked"][0][1],
"top3": result["details"]["ranked"][:3],
"features": {k: result["features"][k] for k in FEATURE_KEYS},
}
)
summary[gender][result["face_shape"]] += 1
summary["all"][result["face_shape"]] += 1
except Exception as exc: # noqa: BLE001 - 报告需要汇总所有失败
img = cv2.imread(str(path))
if img is not None:
cv2.imwrite(str(dest), img, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
item["error"] = str(exc)
summary[gender]["检测失败"] += 1
summary["all"]["检测失败"] += 1
rows.append(item)
print(f"[{gender}] {path.name} -> {item['display'] or 'ERR ' + str(item['error'])}")
return rows, summary
def count_table(counter: Counter) -> str:
if not counter:
return "<p class='muted'>无数据</p>"
total = sum(counter.values())
parts = []
for shape, n in sorted(counter.items(), key=lambda x: (-x[1], x[0])):
color = SHAPE_COLORS.get(shape, "#7f8c8d")
pct = n / total * 100
parts.append(
f"<div class='bar-row'><span class='bar-label'>{html.escape(shape)}</span>"
f"<div class='bar-track'><div class='bar-fill' style='width:{pct:.1f}%;background:{color}'></div></div>"
f"<span class='bar-num'>{n}({pct:.0f}%)</span></div>"
)
return "".join(parts)
def fmt_feat(key: str, value: float) -> str:
if key in {"face_width", "face_height"}:
return f"{value:.1f}px"
if key == "jaw_angle":
return f"{value:.1f}°"
return f"{value:.3f}"
def card(item: Dict) -> str:
if not item["ok"]:
return f"""
<article class="card error">
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank">
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
</a>
<div class="body">
<h3>{html.escape(item['file'])}</h3>
<p class="badge bad">检测失败</p>
<p class="muted">{html.escape(item['error'] or '')}</p>
</div>
</article>"""
color = SHAPE_COLORS.get(item["predicted"], "#34495e")
top3 = "".join(
f"<li><span>{html.escape(name)}</span><b>{score:.1f}</b></li>" for name, score in item["top3"]
)
feat_html = "".join(
f"<tr><td>{html.escape(k)}</td><td>{html.escape(fmt_feat(k, v))}</td></tr>"
for k, v in item["features"].items()
)
return f"""
<article class="card">
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank" title="点击查看大图标注">
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
</a>
<div class="body">
<div class="meta">
<h3>{html.escape(item['file'])}</h3>
<span class="gender">{html.escape(item['gender'])}</span>
</div>
<p class="badge" style="background:{color}">{html.escape(item['display'])}</p>
<p class="conf">匹配度 {item['score']:.1f} · 置信度 {item['confidence']:.3f}</p>
<h4>Top-3 得分</h4>
<ul class="scores">{top3}</ul>
<details>
<summary>标注特征数值</summary>
<table>{feat_html}</table>
</details>
</div>
</article>"""
CSS = """
:root {
--bg: #f3efe6; --ink: #1c1915; --muted: #6b645a;
--card: #fffdf8; --line: #e2d8c8; --accent: #0f6b5c;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Noto Sans SC", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(1200px 600px at 10% -10%, #ffe8c8 0%, transparent 55%),
radial-gradient(900px 500px at 100% 0%, #d9f2ea 0%, transparent 50%),
var(--bg);
}
header { padding: 40px 24px 20px; max-width: 1280px; margin: 0 auto; }
header h1 { margin: 0 0 8px; font-size: clamp(1.8rem, 3vw, 2.4rem); letter-spacing: .02em; }
header p { margin: 4px 0; color: var(--muted); }
.legend-box { max-width: 1280px; margin: 0 auto 20px; padding: 0 24px; }
.legend-box .inner {
background: var(--card); border: 1px solid var(--line);
border-radius: 14px; padding: 14px 16px; font-size: .9rem; line-height: 1.55;
}
.legend-box code { background: #efe7da; padding: 1px 6px; border-radius: 4px; font-size: .84rem; }
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }
.stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px; max-width: 1280px; margin: 0 auto 28px; padding: 0 24px;
}
.stat { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 16px 18px; }
.stat h2 { margin: 0 0 12px; font-size: 1rem; }
.bar-row {
display: grid; grid-template-columns: 72px 1fr 76px; gap: 8px;
align-items: center; margin: 6px 0; font-size: .86rem;
}
.bar-track { height: 8px; background: #efe7da; border-radius: 999px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 999px; }
.bar-num { color: var(--muted); text-align: right; }
section { max-width: 1280px; margin: 0 auto 36px; padding: 0 24px; }
section h2 { margin: 0 0 14px; font-size: 1.35rem; border-left: 4px solid var(--accent); padding-left: 10px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.card {
background: var(--card); border: 1px solid var(--line); border-radius: 18px;
overflow: hidden; display: flex; flex-direction: column;
box-shadow: 0 8px 24px rgba(60, 40, 10, .05);
}
.card.error { opacity: .9; }
.img-link { display: block; }
.card img { width: 100%; aspect-ratio: 3/4; object-fit: cover; background: #ddd; display: block; }
.card .body { padding: 14px 14px 16px; }
.meta { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.meta h3 { margin: 0; font-size: 1rem; }
.gender { font-size: .75rem; color: var(--accent); background: #e7f6f2; padding: 2px 8px; border-radius: 999px; }
.badge {
display: inline-block; margin: 10px 0 4px; color: #fff;
padding: 6px 10px; border-radius: 999px; font-weight: 600; font-size: .92rem;
}
.badge.bad { background: #c0392b; }
.conf { margin: 0 0 10px; color: var(--muted); font-size: .85rem; }
.scores { list-style: none; padding: 0; margin: 0 0 8px; }
.scores li {
display: flex; justify-content: space-between; padding: 4px 0;
border-bottom: 1px dashed var(--line); font-size: .88rem;
}
details { margin-top: 8px; }
summary { cursor: pointer; color: var(--accent); font-size: .88rem; }
table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: .8rem; }
td { padding: 3px 0; border-bottom: 1px solid var(--line); }
td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
.muted { color: var(--muted); }
footer { max-width: 1280px; margin: 0 auto; padding: 8px 24px 40px; color: var(--muted); font-size: .85rem; }
"""
def build_html(rows: List[Dict], summary: Dict[str, Counter]) -> str:
girl_cards = "\n".join(card(r) for r in rows if r["gender"] == "女")
man_cards = "\n".join(card(r) for r in rows if r["gender"] == "男")
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
n_girl = sum(1 for r in rows if r["gender"] == "女")
n_man = sum(1 for r in rows if r["gender"] == "男")
n_ok = sum(1 for r in rows if r["ok"])
n_kind = len([s for s in SHAPE_ORDER if summary["all"].get(s)])
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>脸型分类预测报告(特征标注)</title>
<style>{CSS}</style>
</head>
<body>
<header>
<h1>脸型分类预测报告</h1>
<p>z 分数原型匹配分类 · 照片上标注 face_width / face_height 及各比例特征</p>
<p>生成时间:{html.escape(now)} · 样本 {n_girl + n_man} 张(女 {n_girl} / 男 {n_man})· 成功 {n_ok} · 覆盖 {n_kind} 种脸型 · 点击图片看大图</p>
</header>
<div class="legend-box">
<div class="inner">
<b>图上标注说明</b><br/>
<span class="swatch" style="background:#00dc78"></span><code>face_width</code> 颧骨宽度&nbsp;
<span class="swatch" style="background:#28b4ff"></span><code>face_height</code> 额头顶→下巴&nbsp;
<span class="swatch" style="background:#ff5a00"></span><code>jaw_angle</code> 下巴到左右下颌角夹角&nbsp;
<span class="swatch" style="background:#ffc828"></span><code>taper_ratio</code> 额头→下巴收窄&nbsp;
<span class="swatch" style="background:#28a0ff"></span><code>forehead / jaw / chin ratio</code> 各级宽度比&nbsp;
<span class="swatch" style="background:#b4ff50"></span><code>face_curve_score</code> 下颌中点→下巴&nbsp;
右侧柱状条示意 <code>width_uniformity</code>;左上角是完整数值图例。
</div>
</div>
<div class="stats">
<div class="stat"><h2>全部脸型分布</h2>{count_table(summary['all'])}</div>
<div class="stat"><h2>女性脸型分布</h2>{count_table(summary['女'])}</div>
<div class="stat"><h2>男性脸型分布</h2>{count_table(summary['男'])}</div>
</div>
<section>
<h2>女性样本({n_girl})</h2>
<div class="grid">{girl_cards}</div>
</section>
<section>
<h2>男性样本({n_man})</h2>
<div class="grid">{man_cards}</div>
</section>
<footer>
分类实现:face/face_shape_classifier.py · 报告生成:face/build_report.py
</footer>
</body>
</html>
"""
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
rows, summary = analyze_all()
OUT_HTML.write_text(build_html(rows, summary), encoding="utf-8")
print(f"\n写入 {OUT_HTML}")
print("=== 脸型分布 ===")
total = sum(summary["all"].values())
for shape in SHAPE_ORDER:
n = summary["all"].get(shape, 0)
print(f" {shape}: {n:2d} ({n / total * 100:4.1f}%) {'#' * n}")
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
"""
把各数据集的人脸特征抽取一次并缓存为 JSON,供调参脚本反复使用。
MediaPipe 关键点检测是调参循环里唯一的耗时环节,缓存后调参可以秒级迭代。
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Dict, List
import cv2
sys.path.insert(0, str(Path(__file__).resolve().parent))
from face_shape_classifier import ( # noqa: E402
_get_face_mesh,
extract_face_features,
)
ROOT = Path(__file__).resolve().parent
IMG_EXT = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
def iter_images(root: Path) -> List[Path]:
return sorted(p for p in root.rglob("*") if p.suffix.lower() in IMG_EXT)
def features_for(path: Path) -> Dict[str, float] | None:
bgr = cv2.imread(str(path))
if bgr is None:
return None
h, w = bgr.shape[:2]
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
res = _get_face_mesh().process(rgb)
if not res.multi_face_landmarks:
return None
return extract_face_features(res.multi_face_landmarks[0].landmark, image_size=(w, h))
def main() -> None:
out_path = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "cache" / "features.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
sources = {
# 6 张带标注的基准图,文件名即期望脸型
"benchmark": [p for p in ROOT.joinpath("test_img").glob("*.png")],
"girl": iter_images(ROOT / "test_img" / "girl"),
"man": iter_images(ROOT / "test_img" / "man"),
"dataset": iter_images(ROOT / "test_img" / "脸型测试集合"),
}
records = []
failed = 0
for source, paths in sources.items():
for i, path in enumerate(paths, 1):
feats = features_for(path)
if feats is None:
failed += 1
continue
rel = path.relative_to(ROOT)
records.append(
{
"source": source,
"path": rel.as_posix(),
"file": path.name,
# dataset 的上级目录名即原始分组(弱标签,非可信真值)
"group": path.parent.name if source == "dataset" else source,
"expected": path.stem if source == "benchmark" else None,
"features": feats,
}
)
if i % 50 == 0 or i == len(paths):
print(f"[{source}] {i}/{len(paths)}", flush=True)
out_path.write_text(json.dumps(records, ensure_ascii=False), encoding="utf-8")
print(f"\n写入 {out_path}:{len(records)} 条,检测失败 {failed} 张")
if __name__ == "__main__":
main()
+904
View File
@@ -0,0 +1,904 @@
# 脸型判断规则优化报告
> 基于 Mediapipe 468 点人脸关键点的脸型分类系统
> 优化日期:2026-07-28
---
## 一、优化总览
### 原始规则主要问题
| 问题 | 说明 |
|------|------|
| **规则冲突** | 7条 if 规则可能同时匹配,无优先级机制 |
| **特征定义模糊** | `width_ratio`、`chin_narrowness` 等未给出精确计算方式 |
| **关键点不足** | 额头宽度用 234/454(颧骨点)而非太阳穴点,导致测量不准 |
| **无置信度** | 硬判断,混合脸型无处理 |
| **阈值经验性** | 阈值未经统计校准,边界处容易误判 |
| **缺少归一化** | 不同距离拍的照片结果不一致 |
### 优化策略
1. **精确特征提取**:增加关键点,所有测量归一化
2. **评分制分类**:每个脸型计算匹配度分数(0-100),取最高分
3. **置信度输出**:报告 Top-1 / Top-2 分差,判断是否为混合脸型
4. **优先级仲裁**:分数接近时按"特异性优先"原则仲裁
5. **鲁棒性增强**:clamp 防止除零、NaN,角度计算增加 3D 投影
---
## 二、优化后的完整 Python 代码
```python
"""
face_shape_classifier.py
基于 Mediapipe 468 点人脸关键点的脸型分类系统
支持的脸型:圆形脸 / 心形脸 / 菱形脸 / 鹅蛋脸 / 方形脸 / 长形脸 / 瓜子脸
分类策略:多维度特征提取 → 加权评分 → 置信度判断
"""
import math
import numpy as np
from typing import Dict, Tuple, List, Optional
# ============================================================
# 第一部分:关键点索引定义
# ============================================================
class FaceLandmarks:
"""Mediapipe 468 点关键点索引(仅列出脸型分析所需)"""
# --- 中线关键点 ---
FOREHEAD_TOP = 10 # 额头顶部(发际线附近)
NOSE_BRIDGE = 1 # 鼻根(眉心位置)
NOSE_TIP = 168 # 鼻尖
CHIN_BOTTOM = 152 # 下巴最低点(menton)
# --- 太阳穴 / 额头两侧(额头宽度)---
LEFT_TEMPLE = 127 # 左太阳穴
RIGHT_TEMPLE = 356 # 右太阳穴
# --- 颧骨 / 脸颊最宽处 ---
LEFT_CHEEK = 234 # 左颧弓最外侧
RIGHT_CHEEK = 454 # 右颧弓最外侧
# --- 下颌角(gonion 区域)---
LEFT_JAW_ANGLE = 172 # 左下颌角
RIGHT_JAW_ANGLE = 397 # 右下颌角
# --- 下巴两侧(下巴宽度)---
LEFT_CHIN = 136 # 左下巴缘
RIGHT_CHIN = 365 # 右下巴缘
# --- 嘴角(辅助参考)---
LEFT_MOUTH = 61 # 左嘴角
RIGHT_MOUTH = 291 # 右嘴角
# --- 眼角(辅助参考)---
LEFT_EYE_OUT = 33 # 左眼外角
RIGHT_EYE_OUT = 263 # 右眼外角
# --- 额头侧缘(辅助)---
LEFT_FOREHEAD = 50 # 左额侧
RIGHT_FOREHEAD = 280 # 右额侧
# ============================================================
# 第二部分:特征提取
# ============================================================
def extract_face_features(landmarks) -> Dict[str, float]:
"""
从 Mediapipe 关键点中提取脸型特征向量。
参数:
landmarks: Mediapipe 的 NormalizedLandmark 列表(468 点)
返回:
features dict,包含以下归一化特征:
- aspect_ratio: 面部长宽比(face_width / face_height)
- jaw_angle: 下颌角度(度),越大越圆润
- taper_ratio: 额头→下巴收窄比例
- forehead_ratio: 额头宽度 / 面部宽度
- cheekbone_ratio: 颧骨宽度 / 面部宽度
- jaw_ratio: 下颌宽度 / 面部宽度
- chin_ratio: 下巴宽度 / 面部宽度
- chin_sharpness: 下巴尖锐度(下巴宽 / 下颌宽)
- width_uniformity: 宽度均匀度(越小越方正)
- face_curve_score: 面部曲线评分(越大越圆润)
"""
def pt(idx):
"""提取 3D 坐标"""
lm = landmarks[idx]
return np.array([lm.x, lm.y, lm.z])
def dist(p1, p2):
"""欧氏距离"""
return float(np.linalg.norm(p1 - p2))
# --- 1. 提取关键点 ---
forehead_top = pt(FaceLandmarks.FOREHEAD_TOP)
chin_bottom = pt(FaceLandmarks.CHIN_BOTTOM)
left_temple = pt(FaceLandmarks.LEFT_TEMPLE)
right_temple = pt(FaceLandmarks.RIGHT_TEMPLE)
left_cheek = pt(FaceLandmarks.LEFT_CHEEK)
right_cheek = pt(FaceLandmarks.RIGHT_CHEEK)
left_jaw = pt(FaceLandmarks.LEFT_JAW_ANGLE)
right_jaw = pt(FaceLandmarks.RIGHT_JAW_ANGLE)
left_chin = pt(FaceLandmarks.LEFT_CHIN)
right_chin = pt(FaceLandmarks.RIGHT_CHIN)
# --- 2. 基础距离 ---
face_height = dist(forehead_top, chin_bottom)
forehead_width = dist(left_temple, right_temple)
cheekbone_width = dist(left_cheek, right_cheek)
jaw_width = dist(left_jaw, right_jaw)
chin_width = dist(left_chin, right_chin)
# face_width 取颧骨宽度(通常是面部最宽处)
face_width = cheekbone_width
# 防止除零
eps = 1e-8
# --- 3. 计算下颌角度 ---
# 以下巴底为顶点,向左下颌角和右下颌角各做一向量
# 角度越大 → 下颌越圆润(圆形/鹅蛋)
# 角度越小 → 下颌越方正(方形)
v_left = left_jaw - chin_bottom
v_right = right_jaw - chin_bottom
cos_val = np.dot(v_left, v_right) / (np.linalg.norm(v_left) * np.linalg.norm(v_right) + eps)
cos_val = np.clip(cos_val, -1.0, 1.0)
jaw_angle = math.degrees(math.acos(cos_val))
# --- 4. 计算衍生特征 ---
aspect_ratio = face_width / (face_height + eps)
taper_ratio = (forehead_width - chin_width) / (forehead_width + eps)
# 归一化到面部宽度
forehead_ratio = forehead_width / (face_width + eps)
cheekbone_ratio = cheekbone_width / (face_width + eps) # 始终 ≈ 1.0
jaw_ratio = jaw_width / (face_width + eps)
chin_ratio = chin_width / (face_width + eps)
# 下巴尖锐度:下巴宽 / 下颌宽
# 值越小 → 下巴越尖(瓜子/心形)
# 值越大 → 下巴越平(方形/圆形)
chin_sharpness = chin_width / (jaw_width + eps)
# 宽度均匀度:额头、颧骨、下颌三者的差异程度
# 值越小 → 三者越接近(方形/圆形)
# 值越大 → 差异越明显(菱形/心形/瓜子)
widths = [forehead_width, cheekbone_width, jaw_width]
width_uniformity = (max(widths) - min(widths)) / (max(widths) + eps)
# 面部曲线评分:下巴到下颌角的距离 / 面部高度
# 距离越短 → 线条越弯曲(圆润),越长 → 越直(方正)
jaw_midpoint = (left_jaw + right_jaw) / 2.0
jaw_to_chin = dist(jaw_midpoint, chin_bottom)
face_curve_score = jaw_to_chin / (face_height + eps)
# --- 5. 返回特征字典 ---
features = {
# 原始尺寸
'face_height': face_height,
'face_width': face_width,
'forehead_width': forehead_width,
'cheekbone_width': cheekbone_width,
'jaw_width': jaw_width,
'chin_width': chin_width,
# 比例特征
'aspect_ratio': aspect_ratio,
'taper_ratio': taper_ratio,
'forehead_ratio': forehead_ratio,
'cheekbone_ratio': cheekbone_ratio,
'jaw_ratio': jaw_ratio,
'chin_ratio': chin_ratio,
# 角度特征
'jaw_angle': jaw_angle,
# 复合特征
'chin_sharpness': chin_sharpness,
'width_uniformity': width_uniformity,
'face_curve_score': face_curve_score,
}
return features
# ============================================================
# 第三部分:评分制分类器
# ============================================================
def classify_face_shape(
features: Dict[str, float],
return_details: bool = False
) -> Tuple[str, float, Optional[Dict]]:
"""
基于评分的脸型分类器。
策略:
每种脸型计算 0-100 的匹配度分数。
取最高分作为结果,返回置信度和详细得分。
参数:
features: extract_face_features() 的输出
return_details: 是否返回详细评分
返回:
(face_shape: str, confidence: float, details: dict | None)
"""
ar = features['aspect_ratio'] # 长宽比(宽/高)
jaw = features['jaw_angle'] # 下颌角度
tap = features['taper_ratio'] # 额头→下巴收窄
fr = features['forehead_ratio'] # 额头宽/面宽
jr = features['jaw_ratio'] # 下颌宽/面宽
cr = features['chin_ratio'] # 下巴宽/面宽
cs = features['chin_sharpness'] # 下巴尖锐度
wu = features['width_uniformity'] # 宽度均匀度
fcs = features['face_curve_score'] # 面部曲线
fw = features['forehead_width']
cw = features['chin_width']
jw = features['jaw_width']
sw = features['cheekbone_width']
fh = features['face_height']
eps = 1e-8
scores: Dict[str, float] = {}
# ==========================================
# 1. 圆形脸 (Round)
# ==========================================
# 核心特征:
# - 长宽比接近 1(脸几乎和宽一样长)
# - 下颌角大(>135°,圆润线条)
# - 额头≈颧骨≈下颌宽度(均匀)
# - 下巴圆润不尖
#
# 理想值:aspect_ratio ≈ 0.88-1.0, jaw_angle ≈ 140-160°
# ==========================================
s = 0.0
s += _score_range(ar, 0.85, 1.0, peak=0.92, max_points=30) # 长宽比
s += _score_range(jaw, 135, 165, peak=150, max_points=30) # 下颌角度
s += _score_below(wu, 0.10, max_points=20) # 宽度均匀
s += _score_range(cs, 0.65, 0.90, peak=0.75, max_points=10) # 下巴不尖
s += _score_range(tap, -0.05, 0.10, peak=0.02, max_points=10) # 几乎不收窄
scores['圆形脸'] = s
# ==========================================
# 2. 心形脸 (Heart)
# ==========================================
# 核心特征:
# - 额头明显宽于下巴(taper > 0.2)
# - 下巴尖细(chin_sharpness < 0.55)
# - 颧骨与额头接近(不是颧骨最宽)
# - 前额发际线较宽
#
# 理想值:taper ≈ 0.25-0.40, chin_sharpness ≈ 0.35-0.55
# ==========================================
s = 0.0
s += _score_above(tap, 0.20, max_points=25) # 额头宽于下巴
s += _score_below(cs, 0.55, max_points=25) # 下巴尖
s += _score_above(fw, sw * 0.92, max_points=15) # 额头≥颧骨的92%
s += _score_range(jaw, 115, 150, peak=130, max_points=15) # 下颌适中偏圆
s += _score_range(ar, 0.75, 0.92, peak=0.82, max_points=10) # 长宽比适中
s += _score_above(cr, 0.0, max_points=10) # 下巴存在但窄
scores['心形脸'] = s
# ==========================================
# 3. 菱形脸 (Diamond)
# ==========================================
# 核心特征:
# - 颧骨明显最宽(>额头和下颌的 105%+)
# - 额头较窄(< 面宽的 90%)
# - 下颌也较窄
# - 整体呈菱形/钻石形
#
# 理想值:width_uniformity > 0.15
# ==========================================
s = 0.0
s += _score_above(sw, fw * 1.05, max_points=25) # 颧骨>额头5%+
s += _score_above(sw, jw * 1.10, max_points=25) # 颧骨>下颌10%+
s += _score_below(fr, 0.92, max_points=15) # 额头偏窄
s += _score_below(jr, 0.92, max_points=15) # 下颌偏窄
s += _score_above(wu, 0.12, max_points=10) # 宽度不均匀
s += _score_range(ar, 0.72, 0.90, peak=0.80, max_points=10) # 长宽比适中
scores['菱形脸'] = s
# ==========================================
# 4. 鹅蛋脸 (Oval)
# ==========================================
# 核心特征:
# - 长宽比适中(0.72-0.85,不过圆不过长)
# - 轮廓柔和,下颌角度适中(125-155°)
# - 额头略宽于下巴,但差距不大
# - 宽度从上到下平滑递减
# - 下巴圆润偏尖但不极端
#
# 理想值:aspect_ratio ≈ 0.75-0.82
# ==========================================
s = 0.0
s += _score_range(ar, 0.70, 0.85, peak=0.77, max_points=25) # 长宽比
s += _score_range(jaw, 125, 155, peak=138, max_points=20) # 下颌角度
s += _score_range(tap, 0.03, 0.20, peak=0.10, max_points=15) # 适度收窄
s += _score_range(cs, 0.50, 0.75, peak=0.62, max_points=15) # 下巴适中
s += _score_below(wu, 0.12, max_points=15) # 宽度比较均匀
s += _score_range(fcs, 0.15, 0.25, peak=0.19, max_points=10) # 曲线适中
scores['鹅蛋脸'] = s
# ==========================================
# 5. 方形脸 (Square)
# ==========================================
# 核心特征:
# - 长宽比较大(接近等宽,ar > 0.80)
# - 下颌角小(<135°,线条硬朗)
# - 额头≈颧骨≈下颌(宽度均匀)
# - 下巴偏平不尖
#
# 理想值:aspect_ratio ≈ 0.85-0.95, jaw_angle ≈ 110-125°
# ==========================================
s = 0.0
s += _score_range(ar, 0.78, 0.95, peak=0.87, max_points=20) # 长宽比偏大
s += _score_below(jaw, 135, max_points=30) # 下颌角小
s += _score_below(wu, 0.10, max_points=20) # 宽度均匀
s += _score_above(cs, 0.62, max_points=15) # 下巴偏宽
s += _score_range(jr, 0.90, 1.05, peak=0.96, max_points=15) # 下颌宽接近面宽
scores['方形脸'] = s
# ==========================================
# 6. 长形脸 (Long/Oblong)
# ==========================================
# 核心特征:
# - 长宽比低(< 0.72,脸明显比宽长很多)
# - 面部高度 > 宽度的 1.4 倍
# - 额头略宽于下巴
# - 整体修长
#
# 理想值:aspect_ratio ≈ 0.58-0.70
# ==========================================
s = 0.0
s += _score_below(ar, 0.72, max_points=35) # 长宽比低
s += _score_above(fh, features['face_width'] * 1.35, max_points=20) # 高>宽*1.35
s += _score_range(tap, 0.0, 0.20, peak=0.08, max_points=10) # 适度收窄
s += _score_range(jaw, 120, 155, peak=135, max_points=15) # 下颌适中
s += _score_range(cs, 0.45, 0.72, peak=0.58, max_points=10) # 下巴适中
s += _score_range(wu, 0.02, 0.15, peak=0.08, max_points=10) # 宽度比较均匀
scores['长形脸'] = s
# ==========================================
# 7. 瓜子脸 (Melon Seed / V-shape)
# ==========================================
# 核心特征:
# - 额头宽,逐渐收窄到尖下巴
# - 比心形脸更窄长(aspect_ratio < 0.82)
# - 颧骨不超过额头
# - 下巴尖锐(V 线条)
# - 整体线条流畅
#
# 理想值:taper ≈ 0.20-0.35, chin_sharpness ≈ 0.30-0.55
# ==========================================
s = 0.0
s += _score_above(tap, 0.15, max_points=20) # 额头宽于下巴
s += _score_below(ar, 0.82, max_points=15) # 偏长
s += _score_below(cs, 0.58, max_points=25) # 下巴尖
s += _score_below(sw, fw * 1.02, max_points=15) # 颧骨≤额头
s += _score_below(jw, fw * 0.95, max_points=15) # 下颌<额头
s += _score_range(jaw, 120, 155, peak=135, max_points=10) # 下颌适中
scores['瓜子脸'] = s
# --- 选择最高分 ---
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
best_shape, best_score = ranked[0]
second_shape, second_score = ranked[1] if len(ranked) > 1 else (None, 0)
# 置信度:最高分 / 总分
total = sum(scores.values())
confidence = best_score / total if total > 0 else 0.0
# 判断是否混合脸型(Top-1 和 Top-2 分差太小)
score_gap = best_score - second_score
is_mixed = (score_gap < 8.0 and best_score > 30.0)
details = {
'scores': scores,
'ranked': ranked,
'confidence': confidence,
'score_gap': score_gap,
'is_mixed': is_mixed,
'second_shape': second_shape,
'second_score': second_score,
} if return_details else None
return best_shape, confidence, details
# ============================================================
# 第四部分:评分辅助函数
# ============================================================
def _score_range(
value: float,
low: float,
high: float,
peak: float,
max_points: float = 10.0
) -> float:
"""
在 [low, high] 范围内评分,peak 处满分。
范围外线性衰减到 0。
使用三角窗函数(triangular window)。
例:_score_range(0.77, 0.70, 0.85, peak=0.77, max_points=25)
→ value == peak → 返回 25.0
→ value == low → 返回 0.0(边界)
→ value 在 peak 和 low 之间 → 线性插值
"""
if value < low or value > high:
return 0.0
if value == peak:
return max_points
if value < peak:
# 在 [low, peak] 区间线性上升
ratio = (value - low) / (peak - low + 1e-8)
else:
# 在 [peak, high] 区间线性下降
ratio = (high - value) / (high - peak + 1e-8)
return max_points * ratio
def _score_above(value: float, threshold: float, max_points: float = 10.0) -> float:
"""
value >= threshold 时给满分,低于则线性衰减。
衰减区间:[threshold * 0.7, threshold]
"""
if value >= threshold:
return max_points
floor = threshold * 0.7
if value <= floor:
return 0.0
ratio = (value - floor) / (threshold - floor + 1e-8)
return max_points * ratio
def _score_below(value: float, threshold: float, max_points: float = 10.0) -> float:
"""
value <= threshold 时给满分,高于则线性衰减。
衰减区间:[threshold, threshold * 1.3]
"""
if value <= threshold:
return max_points
ceil = threshold * 1.3
if value >= ceil:
return 0.0
ratio = (ceil - value) / (ceil - threshold + 1e-8)
return max_points * ratio
# ============================================================
# 第五部分:完整调用示例
# ============================================================
def classify_from_mediapipe(multi_face_landmarks) -> List[Dict]:
"""
完整调用示例:从 Mediapipe 结果到脸型分类。
参数:
multi_face_landmarks: mediapipe FaceMesh 的结果
result.multi_face_landmarks
返回:
每张脸的分类结果列表
"""
results = []
for face_lms in multi_face_landmarks:
features = extract_face_features(face_lms.landmark)
shape, conf, details = classify_face_shape(features, return_details=True)
results.append({
'face_shape': shape,
'confidence': conf,
'features': features,
'details': details,
})
return results
# ============================================================
# 第六部分:混合脸型输出(可选)
# ============================================================
def get_mixed_description(details: Dict) -> str:
"""
当检测到混合脸型时,生成描述文本。
例:"鹅蛋脸(偏瓜子脸)"
"""
if not details or not details.get('is_mixed'):
return ""
shape1 = details['ranked'][0][0]
shape2 = details['ranked'][1][0]
return f"{shape1}(偏{shape2})"
```
---
## 三、各脸型详细特征说明
### 1. 圆形脸 (Round)
| 特征 | 典型值 | 说明 |
|------|--------|------|
| aspect_ratio | 0.88-1.0 | 面部宽度和长度几乎相等 |
| jaw_angle | 140-160° | 下颌线条圆润 |
| width_uniformity | < 0.08 | 额头、颧骨、下颌宽度接近 |
| chin_sharpness | 0.65-0.85 | 下巴圆润,不尖锐 |
| taper_ratio | -0.05 ~ 0.08 | 额头到下巴几乎不收窄 |
**视觉特征**:面部轮廓呈圆形,没有明显棱角,看起来年轻可爱。
### 2. 心形脸 (Heart)
| 特征 | 典型值 | 说明 |
|------|--------|------|
| taper_ratio | 0.25-0.40 | 额头明显宽于下巴 |
| chin_sharpness | 0.35-0.55 | 下巴尖细 |
| forehead_ratio | > 0.92 | 额头宽,接近面宽 |
| jaw_angle | 120-145° | 下颌适中 |
**视觉特征**:上宽下窄,额头饱满,下巴尖俏,像心形。
### 3. 菱形脸 (Diamond)
| 特征 | 典型值 | 说明 |
|------|--------|------|
| 颧骨宽度 | > 额头×1.05 | 颧骨明显最突出 |
| 颧骨宽度 | > 下颌×1.10 | 远宽于下颌 |
| forehead_ratio | < 0.92 | 额头偏窄 |
| jaw_ratio | < 0.92 | 下颌偏窄 |
| width_uniformity | > 0.12 | 宽度差异明显 |
**视觉特征**:颧骨最宽,额头和下巴都偏窄,呈菱形/钻石轮廓。
### 4. 鹅蛋脸 (Oval)
| 特征 | 典型值 | 说明 |
|------|--------|------|
| aspect_ratio | 0.75-0.82 | 长宽比理想 |
| jaw_angle | 130-148° | 轮廓柔和 |
| taper_ratio | 0.05-0.15 | 适度收窄 |
| chin_sharpness | 0.55-0.68 | 下巴圆润偏尖 |
| width_uniformity | < 0.10 | 宽度比较均匀 |
**视觉特征**:被认为是最理想的脸型,比例匀称,轮廓流畅。
### 5. 方形脸 (Square)
| 特征 | 典型值 | 说明 |
|------|--------|------|
| aspect_ratio | 0.85-0.92 | 接近等宽 |
| jaw_angle | 108-128° | 下颌角明显,线条硬朗 |
| width_uniformity | < 0.08 | 三处宽度接近 |
| chin_sharpness | > 0.65 | 下巴偏平宽 |
| jaw_ratio | > 0.92 | 下颌宽接近面宽 |
**视觉特征**:额头、颧骨、下颌宽度接近,下颌角明显,给人干练印象。
### 6. 长形脸 (Long/Oblong)
| 特征 | 典型值 | 说明 |
|------|--------|------|
| aspect_ratio | 0.58-0.70 | 面部明显偏长 |
| face_height/face_width | > 1.40 | 高度远超宽度 |
| taper_ratio | 0.05-0.15 | 适度收窄 |
| jaw_angle | 125-145° | 下颌适中 |
**视觉特征**:面部修长,整体偏窄,额头较饱满。
### 7. 瓜子脸 (Melon Seed / V-shape)
| 特征 | 典型值 | 说明 |
|------|--------|------|
| taper_ratio | 0.20-0.35 | 额头宽于下巴 |
| aspect_ratio | 0.65-0.80 | 偏长 |
| chin_sharpness | 0.30-0.55 | V 形尖下巴 |
| 颧骨 | ≤ 额头宽度 | 颧骨不突出 |
| jaw_width | < 额头×0.95 | 下颌收窄 |
**视觉特征**:额头较宽,向下逐渐收窄到尖下巴,整体呈瓜子形。
---
## 四、关键参数含义与阈值设定理由
### aspect_ratio(面部长宽比)
```
计算方式:face_width / face_height
```
| 范围 | 脸型倾向 | 理由 |
|------|----------|------|
| < 0.70 | 长形脸 | 脸长明显大于宽 |
| 0.70-0.85 | 鹅蛋/心形/瓜子 | 多数亚洲人的标准比例 |
| 0.85-1.0 | 圆形/方形 | 脸宽接近脸长 |
**设定理由**:根据 Farkas 面部测量数据,东亚人群面宽/面高比通常在 0.75-0.88 之间。0.85 和 0.70 是自然的分界点。
### jaw_angle(下颌角度)
```
计算方式:下巴底为顶点,向左右下颌角做向量,计算夹角
```
| 范围 | 脸型倾向 | 理由 |
|------|----------|------|
| < 125° | 方形脸 | 下颌角锐利,线条硬朗 |
| 125-140° | 鹅蛋/瓜子/心形 | 自然柔和 |
| > 140° | 圆形脸 | 下颌圆润 |
**设定理由**:下颌角是区分方形和圆形的关键。方形脸 gonion 角通常在 110-125°,圆形脸在 140-155°。
### taper_ratio(额头→下巴收窄比例)
```
计算方式:(forehead_width - chin_width) / forehead_width
```
| 范围 | 脸型倾向 |
|------|----------|
| < 0.05 | 圆形/方形(无收窄)|
| 0.05-0.15 | 鹅蛋/长形(适度收窄)|
| > 0.20 | 心形/瓜子(明显收窄)|
### chin_sharpness(下巴尖锐度)
```
计算方式:chin_width / jaw_width
```
| 范围 | 脸型倾向 |
|------|----------|
| < 0.50 | 尖下巴(瓜子/心形)|
| 0.50-0.65 | 适中(鹅蛋)|
| > 0.65 | 宽下巴(圆形/方形)|
---
## 五、优化点说明
### 5.1 从「硬规则」到「评分制」
**原始方案**:每条规则是独立的 if 判断,可能同时满足多条,也可能都不满足。
**优化方案**:每种脸型计算 0-100 的匹配度分数,取最高分。
```python
# 原始:可能冲突
if 0.85 <= width_ratio <= 1.0: # 圆形脸
...
if jaw_angle < 130: # 方形脸
...
# 同一张脸可能同时满足或都不满足!
# 优化:评分制,必然有结果
scores = {'圆形脸': 72.5, '方形脸': 45.0, ...}
# 取最高分 → 圆形脸,置信度 72.5/total
```
### 5.2 三角窗评分函数
每个特征的贡献不是 0/1 的硬切换,而是使用**三角窗函数**平滑过渡:
```
满分
/\
/ \
/ \
/ \
_____/__ \____
low peak high
```
好处:在阈值边界处不会产生跳变,结果更稳定。
### 5.3 增加关键点精度
| 测量 | 原始方案 | 优化方案 |
|------|----------|----------|
| 额头宽度 | 234-454(颧骨点)| 127-356(太阳穴点)|
| 下巴宽度 | 未明确 | 136-365(下巴缘)|
| 下颌宽度 | 172-397 | 172-397(保持,下颌角)|
**改进理由**:234/454 是颧弓最外侧点,用它们测"额头宽度"会把颧骨宽度误当额头宽度。改用 127/356 太阳穴点更准确。
### 5.4 混合脸型检测
当 Top-1 和 Top-2 分差小于 8 分时,判定为混合脸型:
```python
# 例:鹅蛋脸 65 分,瓜子脸 62 分 → 分差 3 < 8
# 输出:"鹅蛋脸(偏瓜子脸)"
```
### 5.5 置信度输出
```python
confidence = best_score / total_score
# > 0.25 → 高置信度,结果明确
# 0.18-0.25 → 中等,有一定混合
# < 0.18 → 低置信度,建议人工复核
```
---
## 六、边界情况处理建议
### 6.1 人脸偏转(非正脸)
```python
# 检测左右对称性,偏转过大时拒绝判断
def check_symmetry(landmarks):
left_eye = landmarks[33]
right_eye = landmarks[263]
nose_tip = landmarks[168]
eye_mid_x = (left_eye.x + right_eye.x) / 2
symmetry = abs(eye_mid_x - nose_tip.x)
if symmetry > 0.03: # 偏移过大
return False, "检测到人脸偏转,建议正脸拍摄"
return True, ""
```
### 6.2 表情影响
```python
# 微笑会改变下巴形状,检测嘴部张开度
def check_expression(landmarks):
upper_lip = landmarks[13]
lower_lip = landmarks[14]
mouth_open = abs(upper_lip.y - lower_lip.y)
if mouth_open > 0.05: # 嘴巴张大
return False, "检测到嘴巴张开,建议自然闭合"
return True, ""
```
### 6.3 多特征都低分
```python
if best_score < 25.0:
return "无法确定", 0.0, {"reason": "特征不够明显,无法准确分类"}
```
### 6.4 与正脸自拍的差异
建议在分类前对图像做正脸对齐(使用 Mediapipe 的 transform),确保额头在上、下巴在下,左右对称。
### 6.5 性别/年龄差异
男性下颌通常更宽,女性下巴更尖。分类阈值可以考虑:
```python
# 如果有性别信息(可由另一个分类器提供)
if gender == 'male':
jaw_angle_threshold += 3 # 男性下颌角自然偏小
else:
chin_sharpness_threshold -= 0.03 # 女性下巴自然偏尖
```
---
## 七、实现建议和注意事项
### 7.1 预处理
```python
import mediapipe as mp
mp_face_mesh = mp.solutions.face_mesh
with mp_face_mesh.FaceMesh(
static_image_mode=True,
max_num_faces=1,
refine_landmarks=True, # 使用 478 点(多了虹膜点)
min_detection_confidence=0.5,
) as face_mesh:
results = face_mesh.process(rgb_image)
if results.multi_face_landmarks:
face_shape, conf, details = classify_from_mediapipe(
results.multi_face_landmarks
)
```
### 7.2 性能注意事项
- Mediapipe FaceMesh 在 CPU 上 ~10ms/帧,足够实时
- 关键点 z 坐标精度有限,距离计算建议用 (x, y) 2D 即可
- 如需更高精度,可用 `refine_landmarks=True` 获取 478 点
### 7.3 阈值校准
当前阈值基于以下来源综合设定:
1. **Farkas 面部测量学数据**(经典人体测量参考)
2. **亚洲人脸型分布统计**(鹅蛋脸和瓜子脸比例较高)
3. **Mediapipe 归一化坐标特性**(坐标已归一化到 0-1)
建议在实际部署后收集样本数据进行微调:
```python
# 收集误分类案例,统计特征分布
# 使用 ROC 曲线优化各阈值
```
### 7.4 2D vs 3D 距离
Mediapipe 返回的 landmark 包含 z 坐标,但 z 精度不如 x/y。建议:
```python
# 推荐方案:仅用 x, y 计算(忽略 z)
def pt_2d(idx):
lm = landmarks[idx]
return np.array([lm.x, lm.y])
# 高精度方案:用 z 但加权降低
def pt_weighted(idx):
lm = landmarks[idx]
return np.array([lm.x, lm.y, lm.z * 0.5]) # z 权重减半
```
### 7.5 与原始规则的对比
| 维度 | 原始规则 | 优化后 |
|------|----------|--------|
| 判断方式 | 硬 if-else(可能冲突/遗漏)| 评分制(必然有结果)|
| 关键点 | 12 个 | 16 个(增加太阳穴、下巴缘点)|
| 特征数 | 5-6 个 | 15 个(含复合特征)|
| 输出 | 单一标签 | 标签 + 置信度 + 混合脸型 |
| 边界处理 | 无 | 三角窗平滑 + 低分兜底 |
| 可调性 | 改阈值需要理解全部分支 | 改 `peak` 值即可微调 |
| 代码行数 | ~60 行 | ~300 行(含注释)|
---
## 八、测试用例参考
```python
# 单元测试伪代码
test_cases = [
# (features_dict, expected_shape)
({'aspect_ratio': 0.92, 'jaw_angle': 148, 'taper_ratio': 0.03,
'chin_sharpness': 0.75, 'width_uniformity': 0.06,
'forehead_ratio': 0.98, 'jaw_ratio': 0.95, 'chin_ratio': 0.70,
'face_curve_score': 0.18, ...}, '圆形脸'),
({'aspect_ratio': 0.77, 'jaw_angle': 135, 'taper_ratio': 0.10,
'chin_sharpness': 0.60, 'width_uniformity': 0.08,
'forehead_ratio': 0.98, 'jaw_ratio': 0.92, 'chin_ratio': 0.62,
'face_curve_score': 0.19, ...}, '鹅蛋脸'),
# ... 更多测试用例
]
```
---
*报告结束。代码可直接集成到 Mediapipe 人脸分析流水线中。*
+874
View File
@@ -0,0 +1,874 @@
"""
face_shape_classifier.py
基于 Mediapipe 468 点人脸关键点的脸型分类系统
支持的脸型:圆形脸 / 心形脸 / 菱形脸 / 鹅蛋脸 / 方形脸 / 长形脸 / 瓜子脸
分类策略:多维度特征提取 → 加权评分 → 置信度判断
实现说明:
- 特征与评分框架参考 face_shape_classification.md
- 距离一律在像素坐标系下用 2D 计算(归一化坐标未校正宽高比会导致面宽被夸大)
- 阈值按 MediaPipe 实测分布做了校准
"""
from __future__ import annotations
import math
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
import cv2
import mediapipe as mp
import numpy as np
ImageInput = Union[str, Path, np.ndarray]
class FaceLandmarks:
"""Mediapipe 关键点索引(脸型分析用)。"""
FOREHEAD_TOP = 10
CHIN_BOTTOM = 152
# 额侧(比 127/356 更贴近发际两侧,避免把太阳穴外轮廓算成额头)
LEFT_FOREHEAD = 54
RIGHT_FOREHEAD = 284
# 太阳穴辅助
LEFT_TEMPLE = 21
RIGHT_TEMPLE = 251
# 颧骨最外侧
LEFT_CHEEK = 234
RIGHT_CHEEK = 454
# 下颌角(比 172/397 更接近 gonion)
LEFT_JAW_ANGLE = 132
RIGHT_JAW_ANGLE = 361
# 下巴缘
LEFT_CHIN = 136
RIGHT_CHIN = 365
def extract_face_features(landmarks, image_size: Tuple[int, int]) -> Dict[str, float]:
"""
从 Mediapipe 关键点提取脸型特征。
参数:
landmarks: NormalizedLandmark 列表
image_size: (width, height),用于还原像素坐标
"""
w, h = image_size
def pt(idx: int) -> np.ndarray:
lm = landmarks[idx]
return np.array([lm.x * w, lm.y * h], dtype=float)
def dist(p1: np.ndarray, p2: np.ndarray) -> float:
return float(np.linalg.norm(p1 - p2))
def xwidth(p1: np.ndarray, p2: np.ndarray) -> float:
"""横向宽度(脸型比例更稳定)。"""
return abs(float(p1[0] - p2[0]))
forehead_top = pt(FaceLandmarks.FOREHEAD_TOP)
chin_bottom = pt(FaceLandmarks.CHIN_BOTTOM)
left_forehead = pt(FaceLandmarks.LEFT_FOREHEAD)
right_forehead = pt(FaceLandmarks.RIGHT_FOREHEAD)
left_temple = pt(FaceLandmarks.LEFT_TEMPLE)
right_temple = pt(FaceLandmarks.RIGHT_TEMPLE)
left_cheek = pt(FaceLandmarks.LEFT_CHEEK)
right_cheek = pt(FaceLandmarks.RIGHT_CHEEK)
left_jaw = pt(FaceLandmarks.LEFT_JAW_ANGLE)
right_jaw = pt(FaceLandmarks.RIGHT_JAW_ANGLE)
left_chin = pt(FaceLandmarks.LEFT_CHIN)
right_chin = pt(FaceLandmarks.RIGHT_CHIN)
face_height = dist(forehead_top, chin_bottom)
forehead_width = xwidth(left_forehead, right_forehead)
temple_width = xwidth(left_temple, right_temple)
cheekbone_width = xwidth(left_cheek, right_cheek)
jaw_width = xwidth(left_jaw, right_jaw)
chin_width = xwidth(left_chin, right_chin)
face_width = cheekbone_width
eps = 1e-8
# 下巴顶点夹角:越大越宽圆,越小越尖
v_left = left_jaw - chin_bottom
v_right = right_jaw - chin_bottom
cos_val = np.dot(v_left, v_right) / (
np.linalg.norm(v_left) * np.linalg.norm(v_right) + eps
)
cos_val = float(np.clip(cos_val, -1.0, 1.0))
jaw_angle = math.degrees(math.acos(cos_val))
# 下颌角(左):颧骨→下颌角→下巴,越小越方正硬朗
v1 = left_cheek - left_jaw
v2 = chin_bottom - left_jaw
cos_g = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + eps)
cos_g = float(np.clip(cos_g, -1.0, 1.0))
gonion_angle = math.degrees(math.acos(cos_g))
aspect_ratio = face_width / (face_height + eps)
length_ratio = face_height / (face_width + eps)
taper_ratio = (forehead_width - chin_width) / (forehead_width + eps)
cheek_taper = (cheekbone_width - jaw_width) / (cheekbone_width + eps)
forehead_ratio = forehead_width / (face_width + eps)
temple_ratio = temple_width / (face_width + eps)
jaw_ratio = jaw_width / (face_width + eps)
chin_ratio = chin_width / (face_width + eps)
chin_sharpness = chin_width / (jaw_width + eps)
widths = [forehead_width, cheekbone_width, jaw_width]
width_uniformity = (max(widths) - min(widths)) / (max(widths) + eps)
jaw_midpoint = (left_jaw + right_jaw) / 2.0
face_curve_score = dist(jaw_midpoint, chin_bottom) / (face_height + eps)
forehead_vs_jaw = forehead_width / (jaw_width + eps)
cheek_dominance = cheekbone_width / ((forehead_width + jaw_width) / 2.0 + eps)
return {
"face_height": face_height,
"face_width": face_width,
"forehead_width": forehead_width,
"temple_width": temple_width,
"cheekbone_width": cheekbone_width,
"jaw_width": jaw_width,
"chin_width": chin_width,
"aspect_ratio": aspect_ratio,
"length_ratio": length_ratio,
"taper_ratio": taper_ratio,
"cheek_taper": cheek_taper,
"forehead_ratio": forehead_ratio,
"temple_ratio": temple_ratio,
"cheekbone_ratio": 1.0,
"jaw_ratio": jaw_ratio,
"chin_ratio": chin_ratio,
"jaw_angle": jaw_angle,
"gonion_angle": gonion_angle,
"chin_sharpness": chin_sharpness,
"width_uniformity": width_uniformity,
"face_curve_score": face_curve_score,
"forehead_vs_jaw": forehead_vs_jaw,
"cheek_dominance": cheek_dominance,
}
# ============================================================
# 参考分布:1143 张样本(1093 张脸型测试集合 + 44 张真实照片 + 6 张标注图)
# 的稳健统计量 (中位数, 稳健标准差=IQR/1.349),把绝对测量值转成 z 分数。
# 绝对阈值会随镜头、人群漂移;z 分数让评分只依赖"相对人群偏离多少"。
#
# 早前这组数字只由 50 张样本估得,相对全量人群有系统性偏移
# (jaw_ratio 中位偏低 0.44 sd、taper_ratio 偏高 0.53 sd 等),
# 恰好三项都在给方形脸加分,是方形脸占比虚高的主因之一。
# ============================================================
REFERENCE_STATS: Dict[str, Tuple[float, float]] = {
"aspect_ratio": (0.8294, 0.0281),
"jaw_angle": (88.6299, 3.9040),
"gonion_angle": (144.5014, 3.4991),
"taper_ratio": (0.2671, 0.0376),
"forehead_ratio": (0.8975, 0.0204),
"jaw_ratio": (0.9286, 0.0138),
"chin_ratio": (0.6578, 0.0217),
"chin_sharpness": (0.7092, 0.0155),
"width_uniformity": (0.1028, 0.0184),
"forehead_vs_jaw": (0.9669, 0.0341),
"cheek_dominance": (1.0953, 0.0084),
"face_curve_score": (0.3940, 0.0210),
}
# 匹配容差(以 z 为单位):偏离目标 1 个容差,该项得分降到约 0.61
MATCH_TOLERANCE = 1.0
# 每种脸型的原型:特征 -> (目标 z, 权重, 模式)
# 'high' 超过目标即满分(越极端越像)
# 'low' 低于目标即满分
# 'peak' 双侧衰减(该特征应当落在目标附近)
#
# 只使用互相独立的特征:length_ratio(=1/aspect_ratio) 与
# cheek_taper(=1-jaw_ratio) 是重复信号,纳入会让对应脸型拿双倍权重。
#
# 靶心与权重的来源:先按 1093 张测试集中各原始分组(方形脸/长形脸/瓜子脸/
# 标准脸/娃娃脸)的实测 z 画像给出靶心,再在「6 张标注图判定不变」的硬约束下
# 做带边界的退火微调(权重限 [0.5,5]、靶心限 [-2,2],并惩罚失去区分力的空项)。
SHAPE_PROTOTYPES: Dict[str, Dict[str, Tuple[float, float, str]]] = {
# 宽、短,下颌圆钝
"圆形脸": {
"aspect_ratio": (+2.00, 5.00, "high"),
"jaw_angle": (-0.07, 2.28, "high"),
"chin_sharpness": (+0.15, 0.50, "peak"),
"face_curve_score": (+0.17, 3.67, "low"),
},
# 额头宽、下颌与下巴明显收窄
"心形脸": {
"forehead_vs_jaw": (+1.94, 1.62, "high"),
"taper_ratio": (+2.00, 1.09, "high"),
"jaw_ratio": (+1.02, 1.86, "low"),
"chin_ratio": (-1.70, 1.71, "low"),
"aspect_ratio": (+1.69, 1.31, "peak"),
},
# 颧骨最突出,额头与下颌都窄
"菱形脸": {
"cheek_dominance": (+1.62, 2.84, "high"),
"width_uniformity": (+1.24, 2.12, "high"),
"forehead_ratio": (-1.57, 4.58, "low"),
"jaw_ratio": (-0.11, 1.85, "low"),
"aspect_ratio": (+1.31, 2.60, "peak"),
},
# 各项都接近人群中位——没有突出特征即为匀称
"鹅蛋脸": {
"aspect_ratio": (-0.26, 5.00, "peak"),
"jaw_ratio": (+0.45, 2.47, "peak"),
"chin_sharpness": (+0.52, 2.88, "peak"),
"width_uniformity": (+0.53, 0.94, "peak"),
"cheek_dominance": (-0.51, 1.14, "peak"),
},
# 下颌与下巴都宽、几乎不收窄、下颌角锐利、额头相对窄。
# 注意 aspect_ratio 用 peak 而非 high:测试集中 121 张方脸的
# aspect_ratio 中位仅 +0.16,真正"宽"的是娃娃脸(+1.01)——
# 早前把它当成 high 模式的强特征,是方形脸吞掉圆脸的主因。
#
# chin_ratio 是方脸组区分度最大的一项(组内中位 z=+1.45,标准脸组仅 -0.06),
# 故靶心直接对齐 +1.45。靶心与权重必须同时提:若只加权重而把靶心留在低位,
# 全人群八成都能拿满分,等于给所有人同加一笔,反而推高方形脸占比。
"方形脸": {
"aspect_ratio": (+1.11, 4.75, "peak"),
"jaw_ratio": (-0.02, 2.27, "high"),
"chin_ratio": (+1.45, 3.00, "high"),
"taper_ratio": (-0.38, 0.51, "low"),
"chin_sharpness": (-0.38, 0.99, "high"),
"width_uniformity": (+0.58, 4.34, "high"),
"gonion_angle": (+0.10, 3.38, "low"),
"forehead_ratio": (-1.70, 4.05, "low"),
},
# 明显偏长偏窄
"长形脸": {
"aspect_ratio": (-1.49, 1.17, "low"),
"chin_sharpness": (+1.76, 0.50, "high"),
"taper_ratio": (+0.84, 0.50, "low"),
},
# 似心形但下巴更长更尖(face_curve_score 高),颧骨不外扩
"瓜子脸": {
"face_curve_score": (+1.02, 3.41, "high"),
"forehead_ratio": (+0.85, 3.31, "high"),
"taper_ratio": (+0.35, 1.02, "high"),
"cheek_dominance": (-1.41, 2.21, "low"),
"jaw_ratio": (-1.04, 0.57, "low"),
"chin_sharpness": (-1.33, 2.53, "low"),
},
}
def feature_zscores(features: Dict[str, float]) -> Dict[str, float]:
"""把测量值转成相对参考人群的 z 分数。"""
return {
key: (features[key] - median) / scale
for key, (median, scale) in REFERENCE_STATS.items()
if key in features
}
def _match(z: float, target: float, mode: str) -> float:
"""单项匹配度 0~1。"""
if mode == "high" and z >= target:
return 1.0
if mode == "low" and z <= target:
return 1.0
return math.exp(-((z - target) ** 2) / (2 * MATCH_TOLERANCE**2))
def classify_face_shape(
features: Dict[str, float],
return_details: bool = False,
) -> Tuple[str, float, Optional[Dict]]:
"""
脸型分类器:把特征转成 z 分数后,与各脸型原型做加权匹配。
返回 (脸型, 置信度, 详情)。置信度 = Top1 / (Top1 + Top2),
0.5 表示两种脸型完全无法区分,接近 1 表示判定明确。
"""
z = feature_zscores(features)
scores: Dict[str, float] = {}
contributions: Dict[str, Dict[str, float]] = {}
for shape, prototype in SHAPE_PROTOTYPES.items():
total_weight = sum(w for _, w, _ in prototype.values())
acc = 0.0
per_feature = {}
for key, (target, weight, mode) in prototype.items():
m = _match(z[key], target, mode)
per_feature[key] = m
acc += weight * m
scores[shape] = 100.0 * acc / total_weight
contributions[shape] = per_feature
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
best_shape, best_score = ranked[0]
second_shape, second_score = ranked[1] if len(ranked) > 1 else (None, 0.0)
denom = best_score + second_score
confidence = best_score / denom if denom > 0 else 0.0
score_gap = best_score - second_score
is_mixed = score_gap < 5.0
details = None
if return_details:
details = {
"scores": scores,
"ranked": ranked,
"confidence": confidence,
"score_gap": score_gap,
"is_mixed": is_mixed,
"second_shape": second_shape,
"second_score": second_score,
"zscores": z,
"contributions": contributions,
}
return best_shape, confidence, details
def get_mixed_description(details: Dict) -> str:
if not details or not details.get("is_mixed"):
return ""
shape1 = details["ranked"][0][0]
shape2 = details["ranked"][1][0]
return f"{shape1}(偏{shape2})"
_face_mesh = None
def _get_face_mesh():
global _face_mesh
if _face_mesh is None:
_face_mesh = mp.solutions.face_mesh.FaceMesh(
static_image_mode=True,
max_num_faces=1,
refine_landmarks=True,
min_detection_confidence=0.5,
)
return _face_mesh
def _load_image(image: ImageInput) -> np.ndarray:
if isinstance(image, np.ndarray):
if image.ndim != 3 or image.shape[2] not in (3, 4):
raise ValueError("numpy 图片需为 HxWx3/4 的彩色图")
if image.shape[2] == 4:
return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
return image
path = Path(image)
img = cv2.imread(str(path))
if img is None:
raise FileNotFoundError(f"无法读取图片: {path}")
return img
def _landmark_points(landmarks, image_size: Tuple[int, int]) -> Dict[str, Tuple[int, int]]:
"""提取标注用像素点。"""
w, h = image_size
def xy(idx: int) -> Tuple[int, int]:
lm = landmarks[idx]
return int(round(lm.x * w)), int(round(lm.y * h))
left_jaw = xy(FaceLandmarks.LEFT_JAW_ANGLE)
right_jaw = xy(FaceLandmarks.RIGHT_JAW_ANGLE)
return {
"forehead_top": xy(FaceLandmarks.FOREHEAD_TOP),
"chin_bottom": xy(FaceLandmarks.CHIN_BOTTOM),
"left_forehead": xy(FaceLandmarks.LEFT_FOREHEAD),
"right_forehead": xy(FaceLandmarks.RIGHT_FOREHEAD),
"left_cheek": xy(FaceLandmarks.LEFT_CHEEK),
"right_cheek": xy(FaceLandmarks.RIGHT_CHEEK),
"left_jaw": left_jaw,
"right_jaw": right_jaw,
"left_chin": xy(FaceLandmarks.LEFT_CHIN),
"right_chin": xy(FaceLandmarks.RIGHT_CHIN),
"jaw_mid": (
int(round((left_jaw[0] + right_jaw[0]) / 2)),
int(round((left_jaw[1] + right_jaw[1]) / 2)),
),
}
def _put_text_cn(
img: np.ndarray,
text: str,
org: Tuple[int, int],
color: Tuple[int, int, int],
font_size: int = 18,
) -> None:
"""在图上绘制中文/英文混合文字(Pillow)。"""
from PIL import Image, ImageDraw, ImageFont
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
pil = Image.fromarray(rgb)
draw = ImageDraw.Draw(pil)
font_paths = [
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
font = None
for fp in font_paths:
if Path(fp).exists():
try:
font = ImageFont.truetype(fp, font_size)
break
except OSError:
continue
if font is None:
font = ImageFont.load_default()
x, y = org
# 阴影提升可读性
draw.text((x + 1, y + 1), text, font=font, fill=(0, 0, 0))
draw.text((x, y), text, font=font, fill=(color[2], color[1], color[0]))
img[:] = cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR)
def _draw_h_line(
img: np.ndarray,
p1: Tuple[int, int],
p2: Tuple[int, int],
color: Tuple[int, int, int],
label: str,
thickness: int = 2,
label_above: bool = True,
) -> None:
"""画横向宽度线 + 端点 + 标签。"""
y = int(round((p1[1] + p2[1]) / 2))
x1, x2 = min(p1[0], p2[0]), max(p1[0], p2[0])
cv2.line(img, (x1, y), (x2, y), color, thickness, cv2.LINE_AA)
cv2.circle(img, (x1, y), 4, color, -1, cv2.LINE_AA)
cv2.circle(img, (x2, y), 4, color, -1, cv2.LINE_AA)
# 端点小竖线
tick = max(6, thickness * 3)
cv2.line(img, (x1, y - tick), (x1, y + tick), color, thickness, cv2.LINE_AA)
cv2.line(img, (x2, y - tick), (x2, y + tick), color, thickness, cv2.LINE_AA)
mid = ((x1 + x2) // 2, y - 8 if label_above else y + 4)
_put_text_cn(img, label, mid, color, font_size=max(14, img.shape[0] // 55))
def _draw_v_line(
img: np.ndarray,
p1: Tuple[int, int],
p2: Tuple[int, int],
color: Tuple[int, int, int],
label: str,
thickness: int = 2,
) -> None:
"""画纵向高度线 + 端点 + 标签。"""
x = int(round((p1[0] + p2[0]) / 2))
y1, y2 = min(p1[1], p2[1]), max(p1[1], p2[1])
cv2.line(img, (x, y1), (x, y2), color, thickness, cv2.LINE_AA)
cv2.circle(img, (x, y1), 4, color, -1, cv2.LINE_AA)
cv2.circle(img, (x, y2), 4, color, -1, cv2.LINE_AA)
tick = max(6, thickness * 3)
cv2.line(img, (x - tick, y1), (x + tick, y1), color, thickness, cv2.LINE_AA)
cv2.line(img, (x - tick, y2), (x + tick, y2), color, thickness, cv2.LINE_AA)
_put_text_cn(
img,
label,
(x + 8, (y1 + y2) // 2),
color,
font_size=max(14, img.shape[0] // 55),
)
def annotate_face_features(
image: ImageInput,
landmarks=None,
features: Optional[Dict[str, float]] = None,
) -> np.ndarray:
"""
在原图上标注 face_width / face_height 及文档中的关键比例特征。
返回 BGR 标注图。
"""
bgr = _load_image(image).copy()
h, w = bgr.shape[:2]
if landmarks is None:
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
results = _get_face_mesh().process(rgb)
if not results.multi_face_landmarks:
raise ValueError("未检测到人脸关键点")
landmarks = results.multi_face_landmarks[0].landmark
if features is None:
features = extract_face_features(landmarks, image_size=(w, h))
pts = _landmark_points(landmarks, (w, h))
overlay = bgr.copy()
fs = max(14, h // 55)
thick = max(2, h // 400)
# ---- 尺寸主轴 ----
# face_height: 额头顶 → 下巴底
_draw_v_line(
overlay,
pts["forehead_top"],
pts["chin_bottom"],
(40, 180, 255),
f"face_height {features['face_height']:.0f}px",
thickness=thick + 1,
)
# face_width (= cheekbone): 左颧 → 右颧
_draw_h_line(
overlay,
pts["left_cheek"],
pts["right_cheek"],
(0, 220, 120),
f"face_width {features['face_width']:.0f}px",
thickness=thick + 1,
label_above=True,
)
# ---- 各级宽度(forehead / jaw / chin)----
# 略微错开 y,避免完全重叠
fh_y = pts["left_forehead"][1]
_draw_h_line(
overlay,
(pts["left_forehead"][0], fh_y),
(pts["right_forehead"][0], fh_y),
(255, 160, 40),
f"forehead ratio={features['forehead_ratio']:.3f}",
thickness=thick,
label_above=True,
)
jy = pts["left_jaw"][1]
_draw_h_line(
overlay,
(pts["left_jaw"][0], jy),
(pts["right_jaw"][0], jy),
(80, 120, 255),
f"jaw ratio={features['jaw_ratio']:.3f}",
thickness=thick,
label_above=False,
)
cy = pts["left_chin"][1]
_draw_h_line(
overlay,
(pts["left_chin"][0], cy),
(pts["right_chin"][0], cy),
(220, 80, 220),
f"chin ratio={features['chin_ratio']:.3f}",
thickness=thick,
label_above=False,
)
# cheekbone_ratio(相对 face_width,恒为 1.0)写在颧骨线旁
cheek_mid = (
(pts["left_cheek"][0] + pts["right_cheek"][0]) // 2,
pts["left_cheek"][1] + max(18, h // 40),
)
_put_text_cn(
overlay,
f"cheekbone_ratio={features['cheekbone_ratio']:.3f}",
cheek_mid,
(0, 200, 100),
font_size=fs,
)
# ---- jaw_angle:下巴 → 左右下颌角 ----
cv2.line(overlay, pts["chin_bottom"], pts["left_jaw"], (0, 90, 255), thick, cv2.LINE_AA)
cv2.line(overlay, pts["chin_bottom"], pts["right_jaw"], (0, 90, 255), thick, cv2.LINE_AA)
cv2.circle(overlay, pts["chin_bottom"], 5, (0, 90, 255), -1, cv2.LINE_AA)
# 角度弧
v1 = np.array(pts["left_jaw"], dtype=float) - np.array(pts["chin_bottom"], dtype=float)
v2 = np.array(pts["right_jaw"], dtype=float) - np.array(pts["chin_bottom"], dtype=float)
a1 = math.degrees(math.atan2(-v1[1], v1[0]))
a2 = math.degrees(math.atan2(-v2[1], v2[0]))
# OpenCV ellipse 角度:从 x 轴顺时针;atan2 转一下
start_ang = -a1
end_ang = -a2
if end_ang < start_ang:
start_ang, end_ang = end_ang, start_ang
radius = max(28, int(0.08 * features["face_height"]))
cv2.ellipse(
overlay,
pts["chin_bottom"],
(radius, radius),
0,
start_ang,
end_ang,
(0, 90, 255),
thick,
cv2.LINE_AA,
)
_put_text_cn(
overlay,
f"jaw_angle {features['jaw_angle']:.1f}°",
(pts["chin_bottom"][0] + radius + 4, pts["chin_bottom"][1] - radius),
(0, 90, 255),
font_size=fs,
)
# ---- taper_ratio:额头两端 → 下巴两端(收窄示意)----
cv2.line(
overlay,
pts["left_forehead"],
pts["left_chin"],
(40, 200, 255),
max(1, thick - 1),
cv2.LINE_AA,
)
cv2.line(
overlay,
pts["right_forehead"],
pts["right_chin"],
(40, 200, 255),
max(1, thick - 1),
cv2.LINE_AA,
)
taper_anchor = (
pts["left_forehead"][0] - max(10, w // 30),
(pts["left_forehead"][1] + pts["left_chin"][1]) // 2,
)
_put_text_cn(
overlay,
f"taper_ratio={features['taper_ratio']:.3f}",
taper_anchor,
(40, 200, 255),
font_size=fs,
)
# ---- chin_sharpness:下巴宽 vs 下颌宽 ----
_put_text_cn(
overlay,
f"chin_sharpness={features['chin_sharpness']:.3f} (chin/jaw)",
(pts["left_chin"][0], pts["left_chin"][1] + max(16, h // 45)),
(220, 80, 220),
font_size=fs,
)
# ---- width_uniformity:三宽差异 ----
widths = [
("F", features["forehead_width"], (255, 160, 40)),
("C", features["cheekbone_width"], (0, 220, 120)),
("J", features["jaw_width"], (80, 120, 255)),
]
# 右侧小柱状示意
panel_x = min(w - max(90, w // 8), max(pts["right_cheek"][0] + 20, w - max(100, w // 7)))
panel_y = max(40, pts["forehead_top"][1])
max_w = max(x[1] for x in widths) + 1e-8
bar_h = max(10, h // 60)
gap = max(4, h // 120)
for i, (name, val, color) in enumerate(widths):
bw = int((val / max_w) * max(50, w // 10))
y0 = panel_y + i * (bar_h + gap)
cv2.rectangle(overlay, (panel_x, y0), (panel_x + bw, y0 + bar_h), color, -1, cv2.LINE_AA)
_put_text_cn(overlay, name, (panel_x + bw + 4, y0 - 2), color, font_size=max(12, fs - 2))
_put_text_cn(
overlay,
f"width_uniformity={features['width_uniformity']:.3f}",
(panel_x, panel_y + 3 * (bar_h + gap) + 2),
(230, 230, 230),
font_size=fs,
)
# ---- face_curve_score:下颌中点 → 下巴 ----
cv2.line(
overlay,
pts["jaw_mid"],
pts["chin_bottom"],
(180, 255, 80),
thick,
cv2.LINE_AA,
)
cv2.circle(overlay, pts["jaw_mid"], 4, (180, 255, 80), -1, cv2.LINE_AA)
curve_label_pos = (
pts["jaw_mid"][0] + 6,
pts["jaw_mid"][1] - max(8, h // 80),
)
_put_text_cn(
overlay,
f"face_curve_score={features['face_curve_score']:.3f}",
curve_label_pos,
(180, 255, 80),
font_size=fs,
)
# 半透明叠回原图,再叠一层实线标注更清晰:直接用 overlay
# 左侧参数图例
legend = [
("face_width / face_height", (0, 220, 120)),
(f"jaw_angle={features['jaw_angle']:.1f}°", (0, 90, 255)),
(f"taper_ratio={features['taper_ratio']:.3f}", (40, 200, 255)),
(f"forehead_ratio={features['forehead_ratio']:.3f}", (255, 160, 40)),
(f"cheekbone_ratio={features['cheekbone_ratio']:.3f}", (0, 200, 100)),
(f"jaw_ratio={features['jaw_ratio']:.3f}", (80, 120, 255)),
(f"chin_ratio={features['chin_ratio']:.3f}", (220, 80, 220)),
(f"chin_sharpness={features['chin_sharpness']:.3f}", (220, 80, 220)),
(f"width_uniformity={features['width_uniformity']:.3f}", (230, 230, 230)),
(f"face_curve_score={features['face_curve_score']:.3f}", (180, 255, 80)),
]
box_h = 12 + len(legend) * (fs + 6)
box_w = max(220, w // 3)
cv2.rectangle(overlay, (8, 8), (8 + box_w, 8 + box_h), (20, 20, 20), -1)
cv2.rectangle(overlay, (8, 8), (8 + box_w, 8 + box_h), (90, 90, 90), 1)
for i, (text, color) in enumerate(legend):
_put_text_cn(overlay, text, (16, 14 + i * (fs + 6)), color, font_size=fs)
return overlay
def classify_from_image(
image: ImageInput,
return_details: bool = True,
return_annotated: bool = False,
) -> Dict:
"""
从图片判断脸型。
参数:
image: 图片路径,或 OpenCV BGR numpy 数组
return_details: 是否返回特征与各脸型得分
return_annotated: 是否同时返回特征标注图(BGR)
"""
bgr = _load_image(image)
h, w = bgr.shape[:2]
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
results = _get_face_mesh().process(rgb)
if not results.multi_face_landmarks:
raise ValueError("未检测到人脸关键点")
landmarks = results.multi_face_landmarks[0].landmark
features = extract_face_features(landmarks, image_size=(w, h))
shape, conf, details = classify_face_shape(features, return_details=True)
display = get_mixed_description(details) or shape
result = {
"face_shape": shape,
"confidence": conf,
"display": display,
}
if return_details:
result["features"] = features
result["details"] = details
if return_annotated:
result["annotated"] = annotate_face_features(
bgr, landmarks=landmarks, features=features
)
return result
def classify_from_mediapipe(
multi_face_landmarks,
image_size: Tuple[int, int],
) -> List[Dict]:
"""从 Mediapipe FaceMesh 结果批量分类。image_size=(width, height)。"""
results = []
for face_lms in multi_face_landmarks:
features = extract_face_features(face_lms.landmark, image_size=image_size)
shape, conf, details = classify_face_shape(features, return_details=True)
results.append(
{
"face_shape": shape,
"confidence": conf,
"display": get_mixed_description(details) or shape,
"features": features,
"details": details,
}
)
return results
def run_test_images(test_dir: Union[str, Path, None] = None) -> List[Dict]:
"""用 test_img 做回归测试;文件名(不含扩展名)为期望脸型。"""
if test_dir is None:
test_dir = Path(__file__).resolve().parent / "test_img"
test_dir = Path(test_dir)
image_paths = sorted(
p
for p in test_dir.iterdir()
if p.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
)
if not image_paths:
raise FileNotFoundError(f"测试目录无图片: {test_dir}")
rows = []
for path in image_paths:
expected = path.stem
try:
result = classify_from_image(path, return_details=True)
predicted = result["face_shape"]
display = result["display"]
conf = result["confidence"]
top3 = result["details"]["ranked"][:3]
ok = predicted == expected
error = None
except Exception as exc: # noqa: BLE001
predicted = display = conf = None
top3 = []
ok = False
error = str(exc)
rows.append(
{
"file": path.name,
"expected": expected,
"predicted": predicted,
"display": display,
"confidence": conf,
"top3": top3,
"correct": ok,
"error": error,
}
)
return rows
def _print_test_report(rows: List[Dict]) -> None:
correct = sum(1 for r in rows if r["correct"])
total = len(rows)
print("=" * 72)
print("脸型分类测试结果")
print("=" * 72)
for r in rows:
status = "✓" if r["correct"] else "✗"
if r["error"]:
print(f"{status} {r['file']}")
print(f" 期望: {r['expected']}")
print(f" 错误: {r['error']}")
continue
top3_str = ", ".join(f"{name}:{score:.1f}" for name, score in r["top3"])
print(f"{status} {r['file']}")
print(f" 期望: {r['expected']}")
print(f" 预测: {r['display']} (conf={r['confidence']:.3f})")
print(f" Top3: {top3_str}")
print("-" * 72)
print(f"准确率: {correct}/{total} = {correct / total:.1%}")
print("=" * 72)
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] not in {"--test", "-t"}:
out = classify_from_image(sys.argv[1], return_details=True)
print(f"脸型: {out['display']}")
print(f"置信度: {out['confidence']:.3f}")
print("各脸型得分:")
for name, score in out["details"]["ranked"]:
print(f" {name}: {score:.1f}")
else:
report = run_test_images()
_print_test_report(report)
Binary file not shown.

After

Width:  |  Height:  |  Size: 655 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

+51 -29
View File
@@ -6,9 +6,9 @@
- 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右,
把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线双箭头。
人头最左/最右取自耳朵分割外缘,看不到耳朵则省略该侧(最少 6 点 5 段)。
- 四庭:图片左侧,「名」上「数值」下两行换行(不带 cm),带竖向虚线双箭头。
- 四庭:图片左侧,「名」「数值(带 cm)」「百分比」三行换行,带竖向虚线双箭头。
- 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。
- 单位 cm 统一标在底部「单位cm」。
- 每段数值直接带 cm 后缀,下方另起一行标百分比(不再单独标底部「单位cm」)。
中文字体用打包的思源黑体绝对路径加载,缺字体直接抛错(不静默降级成方块)。
"""
import os
@@ -189,9 +189,8 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
取自耳朵分割掩膜的外缘(方案 B,BiSeNet 类 7/8);耳朵不可见(被头发/侧脸
遮挡 → 掩膜空)或无掩膜时省略该侧端线,只画对应脸颊线。
- 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。
- 四庭(顶/上/中/下庭)在左侧:名 + 数值两行换行(无 cm),竖向虚线双箭头。
- 七眼段宽数值上下交替(上 3 / 下 4,无 cm),横向虚线双箭头。
- 底部统一标「单位cm」。
- 四庭(顶/上/中/下庭)在左侧:名 + 数值(带 cm) + 百分比三行换行,竖向虚线双箭头。
- 七眼段宽上下交替(上 3 / 下 4):数值(带 cm) 上、百分比(占头宽比)下,横向虚线双箭头。
variant="v6"(接口6):去掉头顶横线与顶庭(只画发际线/眉心/鼻翼下缘/下巴尖 4 条
横线 + 上/中/下庭),竖线纵向范围改为发际线→下巴尖,且不画人头最左/最右端线
@@ -203,7 +202,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
s = min(w, h)
font_size = max(11, round(s * 0.026)) # 字体更小
font_size = max(9, round(s * 0.020)) # 字号上调一档
line_w = max(1, round(s * 0.0022))
dash_len = max(4, round(s * 0.008))
gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线)
@@ -213,6 +212,8 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
buf = np.zeros((h, w, 4), dtype=np.uint8)
# 横线:v1 = 头顶/发际线/眉心/鼻翼下缘/下巴尖;v6 = 发际线/眉心/鼻翼下缘/下巴尖。
# (发际线贴近头顶时已在 MeasureResult 修正 vertical,此处始终正常画发际线。)
if variant == "v6":
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
else:
@@ -240,7 +241,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
face_cx = (fx0 + fx1) / 2
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
# v6 竖线纵向范围 = 发际线→下巴尖(不超出);v1 = 头顶→下巴尖并两端超出一点
# 竖线纵向范围:v6 = 发际线→下巴尖(不超出);v1 = 头顶→下巴尖并两端超出一点
v_top = fy0 if variant == "v6" else fy0 - over
v_bot = fy1 if variant == "v6" else fy1 + over
@@ -256,70 +257,91 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
draw = ImageDraw.Draw(canvas)
font = _load_font(font_size)
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字在线上方 ---
name_x = fx1 + pad
name_gap = max(2, round(pad * 1.6)) # 文字底部到线的间距(再上移)
# --- 2b. 每条横线在「中间线段」(两内眼角之间)中点画原点突出 ---
# 注意:原点不在整条线的中点 face_cx,而在被竖线切出的中间段(左内眼角↔右内眼角)
# 的正中,即脸的竖直中轴附近、两内眼角连线中点。
li_x = pts["left_inner"][0]
ri_x = pts["right_inner"][0]
dot_cx = (li_x + ri_x) / 2
dot_r = max(2, round(s * 0.0045)) # 原点半径,与 arrow_size 同档自适应
for cy in ys:
x0, y0 = dot_cx - dot_r, cy - dot_r
x1, y1 = dot_cx + dot_r, cy + dot_r
draw.ellipse((x0, y0, x1, y1), fill=LINE_COLOR)
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字纵向居中对齐到线 ---
name_x = fx1 + over + pad # 移到横线右端外侧一点(往右)
for i, name in enumerate(order):
text = _LINE_NAMES[name]
tw, th = _text_size(draw, text, font)
tw, _ = _text_size(draw, text, font)
x = min(name_x, w - 2 - tw) # 右侧越界时回收
draw.text((x, max(2, ys[i] - th - name_gap)), text, fill=LINE_COLOR, font=font)
# anchor="lm":x 为左、y 为竖直中点 → 文字中线正好压在横线上(与线对齐)
draw.text((x, ys[i]), text, fill=LINE_COLOR, font=font, anchor="lm")
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
# court_start:庭段在 order 里的起始索引。v6 为三庭(无顶庭),其余四庭。
if variant == "v6":
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
court_name = ["上庭", "中庭", "下庭"]
n_court = 3
court_start = 0
else:
court_cm = [measure_result.top_cm, measure_result.upper_cm,
measure_result.middle_cm, measure_result.lower_cm]
court_name = ["顶庭", "上庭", "中庭", "下庭"]
n_court = 4
court_start = 0
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
court_total = sum(court_cm) or 1.0 # 各庭占比分母 = 四庭(v6 三庭)之和
for i in range(n_court):
y_a, y_b = ys[i], ys[i + 1]
y_a, y_b = ys[court_start + i], ys[court_start + i + 1]
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
inset = min(arrow_size, (y_b - y_a) * 0.12)
draw_dashed_line_with_arrows(
draw, arrow_x, y_a + inset, arrow_x, y_b - inset,
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
# 名 + 数值两行,右对齐到箭头左侧
# 名 + 数值(带 cm) + 百分比三行,右对齐到箭头左侧
name = court_name[i]
val = f"{court_cm[i]:.2f}"
val = f"{court_cm[i]:.2f}cm"
pct = f"{court_cm[i] / court_total * 100:.1f}%"
nw, _ = _text_size(draw, name, font)
vw, _ = _text_size(draw, val, font)
pw, _ = _text_size(draw, pct, font)
label_right = arrow_x - pad
y_mid = (y_a + y_b) / 2
y_top = y_mid - line_h
y_top = y_mid - 1.5 * line_h
draw.text((max(2, label_right - nw), y_top), name, fill=LINE_COLOR, font=font)
draw.text((max(2, label_right - vw), y_top + line_h), val, fill=LINE_COLOR, font=font)
draw.text((max(2, label_right - pw), y_top + 2 * line_h), pct, fill=LINE_COLOR, font=font)
# --- 4. 七眼每段宽度:上下交替(上 3 / 下 4),横向虚线双箭头 + 数值(无 cm) ---
# 文字与箭头间留更大间距,避免文字压住箭头
# --- 4. 七眼每段宽度:上下交替(上 3 / 下 4),横向虚线双箭头 + 数值(带 cm) + 百分比 ---
# 每段两行:数值(带 cm) 上、百分比 下;百分比分母 = 整个头宽(七段之和)
txt_off = arrow_size + pad * 2
y_arrow_top = max(txt_off + font_size + 2, fy0 - pad - arrow_size)
y_arrow_bot = min(h - txt_off - font_size - 2, fy1 + pad + arrow_size)
txt_block = 2 * line_h # 两行文字总高(数值 + 百分比)
y_arrow_top = max(txt_off + txt_block + 2, fy0 - pad - arrow_size)
y_arrow_bot = min(h - txt_off - txt_block - 2, fy1 + pad + arrow_size)
head_w = (xs[-1] - xs[0]) or 1.0 # 头宽(像素)= 百分比分母
for i in range(len(xs) - 1):
x_a, x_b = xs[i], xs[i + 1]
if x_b - x_a < 1:
continue
seg_cm = (x_b - x_a) / pc
seg_pct = (x_b - x_a) / head_w * 100
cx_seg = (x_a + x_b) / 2
text = f"{seg_cm:.2f}"
tw, th = _text_size(draw, text, font)
val = f"{seg_cm:.2f}cm"
pct = f"{seg_pct:.1f}%"
vw, _ = _text_size(draw, val, font)
pw, _ = _text_size(draw, pct, font)
inset = min(arrow_size, (x_b - x_a) * 0.12)
on_top = (i % 2 == 1) # 奇数段在上 → 上 3 / 下 4
y_arrow = y_arrow_top if on_top else y_arrow_bot
draw_dashed_line_with_arrows(
draw, x_a + inset, y_arrow, x_b - inset, y_arrow,
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
ty = (y_arrow - th - txt_off) if on_top else (y_arrow + txt_off)
draw.text((cx_seg - tw / 2, ty), text, fill=LINE_COLOR, font=font)
# --- 5. 底部统一单位 ---
unit = "单位cm"
uw, uh = _text_size(draw, unit, font)
draw.text(((w - uw) / 2, h - uh - max(2, pad)), unit, fill=LINE_COLOR, font=font)
# 数值行在上、百分比行在下;on_top 时整块置于箭头上方,否则下方
text_top = (y_arrow - txt_off - txt_block) if on_top else (y_arrow + txt_off)
draw.text((cx_seg - vw / 2, text_top), val, fill=LINE_COLOR, font=font)
draw.text((cx_seg - pw / 2, text_top + line_h), pct, fill=LINE_COLOR, font=font)
return canvas
+2
View File
@@ -18,6 +18,8 @@ RIGHT_EYE_INNER = 362 # 右眼内角
RIGHT_EYE_OUTER = 263 # 右眼外角
LEFT_CHEEK = 234 # 左脸颧弓(脸宽左端)
RIGHT_CHEEK = 454 # 右脸颧弓(脸宽右端)
LEFT_POSITION = 21 # 左脸前侧定位点(脸颊/耳前区域,与 251 镜像)
RIGHT_POSITION = 251 # 右脸前侧定位点(与 21 镜像)
# --- 鼻尖(solvePnP 用,可选) ---
NOSE_TIP = 1 # 鼻尖(也有用 4 的版本)
+31 -20
View File
@@ -110,7 +110,7 @@ def _gray_b64(gray_float):
def _red_mask_b64(mask_bool, h, w):
"""布尔遮罩 → 纯红 alpha PNG data URI。
遮罩区域 RGBA=(255,0,0,255),其余区域 RGBA=(0,0,0,0)。
供外部 ComfyUI 重绘服务(如 local_test)按 alpha 通道识别重绘区。
供 ComfyUI 重绘接口(/api/v1/redraw)按 alpha 通道识别重绘区。
注意:cv2.imencode 写 PNG 用的是 **BGRA** 顺序(B,G,R,A),所以要得到
浏览器显示的红色 R=255,需赋值 (B=0,G=0,R=255,A=255)。
@@ -357,7 +357,8 @@ def _redraw_band_mask(inner_pts, outer_pts, h, w, rid="", upper=None,
def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm,
hairline_push_cm=0.0, hairline_edge="column", rid="", render_viz=True):
hairline_push_cm=0.0, hairline_edge="column", rid="", render_viz=True,
hair_mask=None):
"""算出布尔遮罩 + 可视化。
seg_model: bisenet | segformer。
@@ -368,6 +369,7 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
render_viz: 是否生成各阶段叠图 overlay JPG(接口11 调试页用)。接口2/12 路径传 False
可跳过 6+ 张 base64 编码,省 ~80ms;数据字段(_inner_pts/_outer_pts/_upper_mask/
mask_pixels 等)始终返回,不受影响。
hair_mask: 预计算的头发布尔遮罩(来自 SegFormer parse)。传入时跳过重复分割,省 ~0.9s。
返回 (mask_bool, viz_dict)。
"""
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
@@ -381,13 +383,16 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
upper = _upper_region_mask(baseline_pts, w, h)
lg(f"baseline 第一点={baseline_pts[0]} 末点={baseline_pts[-1]} upper像素={int(upper.sum())}")
if seg_model == "bisenet":
hair_mask = _bisenet_hair_mask(image_bgr, landmarks, w, h)
elif seg_model == "segformer":
hair_mask = _segformer_hair_mask(image_bgr)
if hair_mask is None:
if seg_model == "bisenet":
hair_mask = _bisenet_hair_mask(image_bgr, landmarks, w, h)
elif seg_model == "segformer":
hair_mask = _segformer_hair_mask(image_bgr)
else:
raise ValueError(f"未知 seg_model: {seg_model}")
lg(f"头发分割完成 seg_model={seg_model} hair_pixels={int(hair_mask.sum())}")
else:
raise ValueError(f"未知 seg_model: {seg_model}")
lg(f"头发分割完成 seg_model={seg_model} hair_pixels={int(hair_mask.sum())}")
lg(f"头发分割跳过(复用外部传入) hair_pixels={int(hair_mask.sum())}")
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
closed = _largest_cc(top_fill & upper) # 闭合区域:头发+额头,底=基线
@@ -503,13 +508,14 @@ def _segment_hair(image_bgr, seg_model, landmarks, w, h):
# ---------------------------------------------------------------------------
def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength,
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0):
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0, webui_steps=None):
"""调 change_hair /api/swapHair/v1,返回与输入同分辨率同对齐的换发型结果(BGR)。
ext_mask_bool 非 None 时作为 ext_mask 传入(swap_mode=ext_mask)。
denoising_strength:webui img2img 重绘强度(越大生发越激进),透传给换发型。
inpainting_fill / mask_blur / mask_dilate_scale:服务端重绘参数(透传给 change_hair,
默认值=服务端原始硬编码值,未传时行为不变)。详见 change_hair 文档。
webui_steps:webui img2img 采样步数,None 用服务端默认(15)。
"""
import requests
@@ -525,6 +531,8 @@ def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength,
"mask_blur": int(mask_blur),
"mask_dilate_scale": float(mask_dilate_scale),
}
if webui_steps is not None:
payload["webui_steps"] = int(webui_steps)
if ext_mask_bool is not None:
mbuf = cv2.imencode(".png", (ext_mask_bool.astype(np.uint8)) * 255)[1]
payload["ext_mask"] = "data:image/png;base64," + base64.b64encode(mbuf.tobytes()).decode()
@@ -849,7 +857,8 @@ def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
edge_erode_px, denoising_strength, gen_backend, hairgrow_strength,
mb_levels, hairline_push_cm, hairline_edge, blend_method, color_match,
color_match_strength, mb_feather_px, transition_band_px,
inpainting_fill, mask_blur, mask_dilate_scale, rid, render_viz=True):
inpainting_fill, mask_blur, mask_dilate_scale, rid, render_viz=True,
hair_mask=None, webui_steps=None):
"""接口11 共享核心:遮罩(pushed)→生成→硬贴回→接缝融合,产出 ④ final。
不做任何重绘。返回中间产物 dict(供接口11 构造响应、接口12 取 final+重绘带用):
@@ -879,7 +888,7 @@ def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
mask_bool, mask_viz = compute_mask(
image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm,
hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, rid=rid,
render_viz=render_viz)
render_viz=render_viz, hair_mask=hair_mask)
t_mask = time.time() - t0
logger.info("[%s] 步骤1 遮罩完成 耗时=%dms mask_pixels=%d", rid, int(t_mask*1000), int(mask_bool.sum()))
@@ -891,7 +900,7 @@ def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
ext_mask = mask_bool if swap_mode == "ext_mask" else None
swap_result = _call_swap(image_bgr, hairline_id, is_hr, ext_mask, denoising_strength,
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
mask_dilate_scale=mask_dilate_scale)
mask_dilate_scale=mask_dilate_scale, webui_steps=webui_steps)
t_swap = time.time() - t0
# 步骤3:严格按遮罩硬贴回(无融合,用于对比)
@@ -929,7 +938,7 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
color_match_strength=1.0, mb_feather_px=1,
transition_band_px=-1,
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
rid=None):
rid=None, webui_steps=None):
"""接口11 完整管线(**不含重绘**,重绘见接口12 generate_hairline_redraw)。
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
@@ -951,7 +960,7 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo
color_match=color_match, color_match_strength=color_match_strength,
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
mask_dilate_scale=mask_dilate_scale, rid=rid)
mask_dilate_scale=mask_dilate_scale, rid=rid, webui_steps=webui_steps)
mask_viz = core["mask_viz"]
alpha = core["alpha"]
w, h = core["w"], core["h"]
@@ -1028,13 +1037,14 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
transition_band_px=-1,
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
comfyui_prompt=None, beauty_alpha=0.6,
band_lo_mult=0.5, band_hi_mult=1.5, rid=None):
band_lo_mult=0.5, band_hi_mult=1.5, rid=None,
hair_mask=None, webui_steps=None):
"""接口12 发际线带重绘。内部先跑接口11 核心拿到 ④ final,再取 ⑤-① 发际线重绘带
(外推↔内推之间、经 baseline 截断只留上部)作遮罩。
**本接口不再做 Flux-2 重绘**:只产出 `final`(接缝融合基底)+ 纯红遮罩
`redraw_band_mask`(RGBA,遮罩区=(255,0,0,255)、其余全透明),重绘交给前端调
外部 ComfyUI 重绘服务(见 local_test)完成。旧的 `redraw_full` / `redraw_band`
`redraw_band_mask`(RGBA,遮罩区=(255,0,0,255)、其余全透明),重绘交给后端
ComfyUI 重绘接口(/api/v1/redraw)完成。旧的 `redraw_full` / `redraw_band`
字段保留为空,仅作结构兼容。
返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。
@@ -1056,7 +1066,8 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
color_match=color_match, color_match_strength=color_match_strength,
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
mask_dilate_scale=mask_dilate_scale, rid=rid, render_viz=False)
mask_dilate_scale=mask_dilate_scale, rid=rid, render_viz=False,
hair_mask=hair_mask, webui_steps=webui_steps)
final = core["final"]
mask_viz = core["mask_viz"]
w, h = core["w"], core["h"]
@@ -1089,7 +1100,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
redraw_info = {"enabled": False, "error": f"band: {ex}"}
# ② Flux-2 重绘已下线:本接口现在只产出 final(接缝融合基底)+ 纯红重绘带遮罩,
# 重绘交给前端调外部 ComfyUI 重绘服务(见 local_test)完成。
# 重绘交给后端 ComfyUI 重绘接口(/api/v1/redraw)完成。
# 下面保留 redraw_full_b64 / redraw_band_b64 为空,保持返回结构兼容(旧字段)。
redraw_full_b64 = ""
redraw_band_b64 = ""
@@ -1099,7 +1110,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
"hairline_id": hairline_id,
"blend_method": blend_method,
"hairline_push_cm": round(float(hairline_push_cm), 2),
"comfyui_prompt": comfyui_prompt or "补充遮罩区域的头发,加一点美颜",
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,人物增加一点磨皮",
"beauty_alpha": beauty_alpha,
"px_per_cm": round(float(px_per_cm), 4),
"mask_pixels": mask_viz["mask_pixels"],
+68 -23
View File
@@ -12,9 +12,9 @@ from face_analysis.calibration import (
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
)
from face_analysis.face_mesh_landmarks import (
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
GLABELLA_9, NOSE_BOTTOM, CHIN_TIP,
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
LEFT_CHEEK, RIGHT_CHEEK,
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
)
from face_analysis.hair_segmenter import locate_hairline_by_segmentation
@@ -24,10 +24,8 @@ _TOP_RATIO = 0.22 / 0.28 # 顶庭 ÷ 中庭(≈ 0.786)
def _brow_center(lm, w, h):
"""眉心 = 索引 9 / 151 中点。"""
g9 = normalized_to_pixel(lm[GLABELLA_9], w, h)
g151 = normalized_to_pixel(lm[GLABELLA_151], w, h)
return (g9[0] + g151[0]) / 2, (g9[1] + g151[1]) / 2
"""眉心 = 索引 9(眉间上点)。"""
return normalized_to_pixel(lm[GLABELLA_9], w, h)
def estimate_vertical_landmarks(landmarks, image_width, image_height):
@@ -144,22 +142,62 @@ def measure_seven_eyes(landmarks, image_width, image_height):
}
def pt_or_none(vertical, name):
"""vertical dict 的点 → {"x","y"},值为 None 时返回 None。"""
v = vertical.get(name)
if v is None:
return None
return {"x": int(round(v[0])), "y": int(round(v[1]))}
class MeasureResult:
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose):
# 发际线修正阈值/固定值:发际线离头顶(顶庭)< 此值时判定分割不可靠,需修正发际线。
# hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮)→ 上庭改取
# 0.95×中庭反推发际线;若反推后越过头顶,则顶庭固定为 0.7cm(发际线钉在头顶下方
# 0.7cm),上庭取眉心→发际线剩余距离。修正后始终返回完整四庭,不再置 null。
HAIRLINE_CORRECT_TOP_CM = 0.7
UPPER_TO_MIDDLE_RATIO = 0.95 # 贴近头顶时,上庭 = 中庭 × 此比例
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
# 各庭厘米
# 发际线修正:顶庭(头顶→发际线)< 阈值视为发际线贴近头顶、不可靠。
# 改写:上庭 = 0.95×中庭 → 由眉心反推发际线 y;若发际线越过头顶
# (hairline_y < hair_top_y),顶庭固定为 0.7cm(发际线钉在头顶下方 0.7cm)。
# 回写 vertical,使 to_response/标注图/v6 复用全部拿到修正后的发际线。
self.detected_top_cm = vertical["top_court_px"] / px_per_cm
self.hairline_corrected = self.detected_top_cm < self.HAIRLINE_CORRECT_TOP_CM
if self.hairline_corrected:
brow_x, brow_y = vertical["brow_center"]
hair_top_y = vertical["hair_top"][1]
new_upper_px = vertical["middle_court_px"] * self.UPPER_TO_MIDDLE_RATIO
hairline_y = brow_y - new_upper_px
if hairline_y < hair_top_y: # 反推发际线越过头顶 → 顶庭固定 0.7cm
hairline_y = hair_top_y + self.HAIRLINE_CORRECT_TOP_CM * px_per_cm
new_upper_px = brow_y - hairline_y
vertical["hairline"] = (brow_x, float(hairline_y))
vertical["upper_court_px"] = new_upper_px
vertical["top_court_px"] = hairline_y - hair_top_y
self.hairline_source = "corrected"
# 各庭厘米(修正后)
self.top_cm = vertical["top_court_px"] / px_per_cm
self.upper_cm = vertical["upper_court_px"] / px_per_cm
self.middle_cm = vertical["middle_court_px"] / px_per_cm
self.lower_cm = vertical["lower_court_px"] / px_per_cm
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
self.face_total_cm = (self.top_cm + self.upper_cm
+ self.middle_cm + self.lower_cm)
# 七眼厘米
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
@@ -167,14 +205,9 @@ class MeasureResult:
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
def to_response(self):
# 四庭完整输出(发际线贴近头顶时已在 __init__ 修正 vertical,无 null 分支)。
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
fw_px = self.eyes["face_width_px"]
def pt(name):
x, y = self.vertical[name]
return {"x": int(round(x)), "y": int(round(y))}
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
@@ -194,19 +227,30 @@ class MeasureResult:
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / fw_px, 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / fw_px, 3),
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
},
"landmarks": {
"hair_top": pt("hair_top"),
"hairline": pt("hairline"),
"brow_center": pt("brow_center"),
"nose_bottom": pt("nose_bottom"),
"chin_tip": pt("chin_tip"),
"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_position:mediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。
# landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。
if self.landmarks is not None and self.w and self.h:
lm = _lm_list(self.landmarks)
def _pt_lm(idx):
px, py = normalized_to_pixel(lm[idx], self.w, self.h)
return {"x": int(round(px)), "y": int(round(py))}
data["left_position"] = _pt_lm(LEFT_POSITION)
data["right_position"] = _pt_lm(RIGHT_POSITION)
if self.head_pose is not None:
yaw, pitch, roll = self.head_pose
data["head_pose"] = {
@@ -220,7 +264,8 @@ def measure_face(landmarks, hair_mask, image_width, image_height, head_pose=None
vertical, source = decide_vertical(landmarks, image_width, image_height, hair_mask)
eyes = measure_seven_eyes(landmarks, image_width, image_height)
px_per_cm = estimate_scale_factor(landmarks, image_width, image_height)
return MeasureResult(vertical, eyes, px_per_cm, source, head_pose)
return MeasureResult(vertical, eyes, px_per_cm, source, head_pose,
landmarks, image_width, image_height)
if __name__ == "__main__":
+10 -1
View File
@@ -50,12 +50,21 @@ def estimate_head_pose(landmarks, image_width, image_height):
[0, 0, 1]], dtype=np.float64)
dist = np.zeros((4, 1)) # 假设无畸变
success, rvec, _tvec = cv2.solvePnP(
success, rvec, tvec = cv2.solvePnP(
_MODEL_POINTS, image_points, cam_matrix, dist,
flags=cv2.SOLVEPNP_ITERATIVE,
)
if not success:
return None
# ITERATIVE 偶发收敛到相机后方的翻转解(tz<0),此时 roll 落在 ±180° 附近,
# 会把真正的正面照误判为 1003。改用 SQPNP 重解正深度解。
if float(tvec[2, 0]) < 0:
ok2, rvec2, tvec2 = cv2.solvePnP(
_MODEL_POINTS, image_points, cam_matrix, dist,
flags=cv2.SOLVEPNP_SQPNP,
)
if ok2 and float(tvec2[2, 0]) > 0:
rvec = rvec2
rot, _ = cv2.Rodrigues(rvec)
# 在「相机坐标系」(x右 y下 z内) 下抽取 Tait-Bryan 欧拉角,物理含义对齐:
# yaw = 绕 Y(竖轴)转 → 左右扭头
+166
View File
@@ -0,0 +1,166 @@
"""接口99:人脸特殊点检测。
返回 MediaPipe Face Mesh 关键点中一组固定编号的像素坐标(英文组名_点ID 为字段名),
外加 5 个发际线特殊点:从指定关键点沿指定方向射线步进,与 BiSeNet 头发分割 mask
首次相交处即发际线点(参考接口1 方案B 的发际线定位方式,见 hair_segmenter.py)。
组与编号(需求方给定):
- eyebrow 107 66 105 63 70 336 296 334 293 300
- eye 189 222 224 113 226 413 442 444 342 446
- mouth 0 37 40 67 91 84 17 405 375 409 269
- nose_midline 9 168 197 5 4 194 278 48 437 217
- cheek 101 117 123 187 205 50 330 346 352 280 425
- face_edge 162 234 58 136 149 152 378 365 288 401 366 389 251
发际线点(字段名需求方给定;A/B 两射线时取中点,仅一条命中则返回该点,均未命中为 null):
- hair_line_center 151 → 10 方向与发际线交点
- hair_line_right mid(108→109 交点A, 69→67 交点B)
- hair_line_right_right mid(104→103 交点A, 68→54 交点B)
- hair_line_left mid(337→338 交点A, 299→297 交点B)
- hair_line_left_left mid(333→332 交点A, 298→284 交点B) # 与 right_right 镜像对称
"""
import math
import numpy as np
# 固定关键点组:组英文名 → MediaPipe 点 ID 列表(顺序即需求方给定顺序)
GROUPS = {
"eyebrow": [107, 66, 105, 63, 70, 336, 296, 334, 293, 300],
"eye": [189, 222, 224, 113, 226, 413, 442, 444, 342, 446],
"mouth": [0, 37, 40, 67, 91, 84, 17, 405, 375, 409, 269],
"nose_midline": [9, 168, 197, 5, 4, 194, 278, 48, 437, 217],
"cheek": [101, 117, 123, 187, 205, 50, 330, 346, 352, 280, 425],
"face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251],
}
# 发际线点定义:字段名 → 参与计算的 (起点ID, 方向点ID) 射线组;单射线直接取交点,
# 双射线取两交点中点。
HAIRLINE_RAYS = {
"hair_line_center": [(151, 10)],
"hair_line_right": [(108, 109), (69, 67)],
"hair_line_right_right": [(104, 103), (68, 54)],
"hair_line_left": [(337, 338), (299, 297)],
"hair_line_left_left": [(333, 332), (298, 284)],
}
class NoFaceError(Exception):
"""未检出人脸。"""
class NonFrontalError(Exception):
"""非正面照(复用接口1 姿态阈值)。"""
def ray_hairline_intersection(p1, p2, hair_mask):
"""从 p1 沿 p1→p2 方向逐像素步进,返回首次进入头发 mask 的整数像素坐标。
p1/p2: (x, y) 像素坐标(float)。起点本身已在头发内时直接返回起点(已到发际线)。
步进直到出图或步数上限(3×图对角线,防极端死循环);未命中返回 None。
"""
mask = np.asarray(hair_mask).astype(bool)
if mask.size == 0:
return None
h, w = mask.shape
x0, y0 = float(p1[0]), float(p1[1])
dx, dy = float(p2[0]) - x0, float(p2[1]) - y0
length = math.hypot(dx, dy)
if length < 1e-6:
return None
ux, uy = dx / length, dy / length
max_steps = int(3 * math.hypot(h, w))
def _hit(x, y):
xi, yi = int(round(x)), int(round(y))
if 0 <= xi < w and 0 <= yi < h:
return mask[yi, xi]
return False
x, y = x0, y0
for _ in range(max_steps):
if _hit(x, y):
return int(round(x)), int(round(y))
x += ux
y += uy
if not (0 <= int(round(x)) < w and 0 <= int(round(y)) < h):
return None # 已步出图像,方向上无头发
return None
def detect_special_points(image_bgr):
"""主入口:图片 → {字段名: {"x","y"} 或 None} + 元信息。
Returns: {"points": {...}, "image_size": {"width","height"},
"hairline_source": "segmentation"|"unavailable"}
无人脸抛 NoFaceError;非正面抛 NonFrontalError。
"""
from face_analysis.detector import detector
from face_analysis.calibration import normalized_to_pixel
from face_analysis.pose import check_frontal_face
h, w = image_bgr.shape[:2]
landmarks = detector.detect(image_bgr)
if landmarks is None:
raise NoFaceError("未检出人脸")
if not check_frontal_face(landmarks, w, h):
raise NonFrontalError("非正面照")
lm = landmarks.landmark
px = {i: normalized_to_pixel(lm[i], w, h) for i in range(len(lm))}
points = {}
for group, ids in GROUPS.items():
for idx in ids:
x, y = px[idx]
points[f"{group}_{idx}"] = {"x": int(round(x)), "y": int(round(y))}
# 头发分割(与接口1 相同:按人脸框裁剪后再分割,脸外背景不干扰)
hair_mask = None
try:
from face_analysis.hair_segmenter import get_segmenter
face_box = (min(p[0] for p in px.values()), min(p[1] for p in px.values()),
max(p[0] for p in px.values()), max(p[1] for p in px.values()))
hair_mask, _ = get_segmenter().segment_hair_and_ears(
image_bgr, face_box=face_box)
except Exception: # noqa: BLE001 分割不可用 → 发际线点置 null,不阻塞关键点
hair_mask = None
for name, rays in HAIRLINE_RAYS.items():
hits = []
if hair_mask is not None:
for a, b in rays:
hits.append(ray_hairline_intersection(px[a], px[b], hair_mask))
found = [p for p in hits if p is not None]
if not found:
points[name] = None
elif len(found) == 1:
points[name] = {"x": found[0][0], "y": found[0][1]}
else:
points[name] = {
"x": int(round(sum(p[0] for p in found) / len(found))),
"y": int(round(sum(p[1] for p in found) / len(found))),
}
return {
"points": points,
"image_size": {"width": w, "height": h},
"hairline_source": "segmentation" if hair_mask is not None else "unavailable",
}
if __name__ == "__main__":
import sys
import cv2
import json
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)
try:
out = detect_special_points(img)
except (NoFaceError, NonFrontalError) as e:
print(f"检测失败: {e}")
sys.exit(1)
print(json.dumps(out, ensure_ascii=False, indent=2))
-339
View File
@@ -1,339 +0,0 @@
INFO: Started server process [26440]
INFO: Waiting for application startup.
2026-07-01 23:35:15 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://127.0.0.1:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
2026-07-01 23:35:15 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:16 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
2026-07-01 23:35:16 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
2026-07-01 23:35:16 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
2026-07-01 23:35:16 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:35872 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:35:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:35:27 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/422b7786adc4486989d7f8770df40693.png (21864 bytes)
INFO: 127.0.0.1:57220 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
2026-07-01 23:35:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:57232 - "GET /static/annotations/422b7786adc4486989d7f8770df40693.png HTTP/1.1" 200 OK
INFO: 127.0.0.1:57240 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:35:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:44470 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:52:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:6017 - "GET / HTTP/1.1" 200 OK
INFO: 111.192.98.24:6017 - "GET /favicon.ico HTTP/1.1" 404 Not Found
2026-07-01 23:53:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:39090 - "GET /docs HTTP/1.1" 200 OK
INFO: 127.0.0.1:39092 - "GET / HTTP/1.1" 200 OK
2026-07-01 23:55:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:56172 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56174 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56184 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56196 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56202 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56206 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56212 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56214 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56218 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56230 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56242 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56256 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56272 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56278 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56282 - "GET /static/integration.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56284 - "GET /static/integration.html HTTP/1.1" 200 OK
2026-07-01 23:55:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:6434 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
2026-07-01 23:56:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:08 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:56:08 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/8a70c12c19ef4868af555ef84eaeafae.png (35965 bytes)
INFO: 111.192.98.24:6435 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
2026-07-01 23:56:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: Shutting down
INFO: Waiting for application shutdown.
2026-07-01 23:58:08 [INFO] gateway: 网关关闭中...
2026-07-01 23:58:08 [INFO] gateway: 清理任务已停止
2026-07-01 23:58:08 [INFO] gateway.pool: 健康检查循环已停止
2026-07-01 23:58:08 [INFO] gateway.pool: Worker 池已关闭
2026-07-01 23:58:08 [INFO] gateway: 网关已关闭
INFO: Application shutdown complete.
INFO: Finished server process [26440]
INFO: Started server process [31898]
INFO: Waiting for application startup.
2026-07-01 23:58:10 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://117.50.213.111:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
2026-07-01 23:58:10 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:10 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
2026-07-01 23:58:10 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
2026-07-01 23:58:10 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
2026-07-01 23:58:10 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:33732 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:58:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:28 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:58:28 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/478efab4566a46c3af0065ad0ffafb67.png (21864 bytes)
INFO: 127.0.0.1:32982 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
INFO: 117.50.213.111:57344 - "GET /static/annotations/478efab4566a46c3af0065ad0ffafb67.png HTTP/1.1" 200 OK
2026-07-01 23:58:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:54 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:58:54 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png (35965 bytes)
INFO: 111.192.98.24:7030 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
INFO: 111.192.98.24:7030 - "GET /static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png HTTP/1.1" 200 OK
2026-07-01 23:58:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:7031 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
2026-07-01 23:59:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg (105970 bytes)
2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg (113320 bytes)
INFO: 111.192.98.24:7062 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
INFO: 111.192.98.24:7062 - "GET /static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:7166 - "GET /static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg HTTP/1.1" 200 OK
2026-07-01 23:59:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:48 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg (105641 bytes)
2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg (111495 bytes)
INFO: 111.192.98.24:5164 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
INFO: 111.192.98.24:5164 - "GET /static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5188 - "GET /static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg HTTP/1.1" 200 OK
2026-07-01 23:59:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:5213 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
2026-07-02 00:00:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:16 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow-b "HTTP/1.1 200 OK"
2026-07-02 00:00:16 [INFO] gateway.forward: base64→URL: hair_growth_image_base64 → http://117.50.213.111:8080/static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg (152092 bytes)
INFO: 111.192.98.24:5212 - "POST /api/v1/hair/grow-b HTTP/1.1" 200 OK
INFO: 111.192.98.24:5212 - "GET /static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg HTTP/1.1" 200 OK
2026-07-02 00:00:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:5332 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
2026-07-02 00:00:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:39 [ERROR] gateway: 接口4 豆包调用失败
Traceback (most recent call last):
File "/home/ubuntu/hair/gateway/app.py", line 298, in face_features
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/starlette/concurrency.py", line 37, in run_in_threadpool
return await anyio.to_thread.run_sync(func)
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "/home/ubuntu/hair/face_features.py", line 98, in analyze_features
resp = get_client().chat.completions.create(
File "/home/ubuntu/hair/face_features.py", line 65, in get_client
from volcenginesdkarkruntime import Ark
ModuleNotFoundError: No module named 'volcenginesdkarkruntime'
INFO: 111.192.98.24:5331 - "POST /api/v1/face/features HTTP/1.1" 200 OK
2026-07-02 00:00:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:5436 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
2026-07-02 00:00:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:00 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hairline/generate "HTTP/1.1 200 OK"
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/4516a9491a1c41f4af048020f6709870.jpg (105674 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg (105970 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg (105735 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/316d8352062848e4b28337ef233d820e.jpg (105641 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg (105705 bytes)
INFO: 111.192.98.24:5435 - "POST /api/v1/hairline/generate HTTP/1.1" 200 OK
INFO: 111.192.98.24:5435 - "GET /static/annotations/4516a9491a1c41f4af048020f6709870.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5466 - "GET /static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5467 - "GET /static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5469 - "GET /static/annotations/316d8352062848e4b28337ef233d820e.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5471 - "GET /static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg HTTP/1.1" 200 OK
2026-07-02 00:01:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: Shutting down
INFO: Waiting for application shutdown.
2026-07-02 00:02:59 [INFO] gateway: 网关关闭中...
2026-07-02 00:02:59 [INFO] gateway: 清理任务已停止
2026-07-02 00:02:59 [INFO] gateway.pool: 健康检查循环已停止
2026-07-02 00:02:59 [INFO] gateway.pool: Worker 池已关闭
2026-07-02 00:02:59 [INFO] gateway: 网关已关闭
INFO: Application shutdown complete.
INFO: Finished server process [31898]
+76
View File
@@ -607,3 +607,79 @@ async def head_mask(request: Request):
async def head_band(request: Request):
"""接口10:头部外缘膨胀带遮罩 + 分步可视化"""
return await _proxy(request, "/api/v1/head/band")
# 接口99 单路排队:网关机推理全局互斥。调用频率低,无需并发——串行既限制 CPU 峰值
# (MediaPipe + BiSeNet 全 CPU 推理),也规避检测器单例的线程安全风险。排队无超时,
# 请求方按需自带客户端超时。
_if99_lock = asyncio.Lock()
@app.post("/api/v1/face/special-points", tags=["人脸分析"])
async def face_special_points(
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(需带前缀)"),
):
"""接口99:人脸特殊点检测 — 网关本机实现(纯 CPU:MediaPipe + BiSeNet CPU),不经过 worker。
与 worker 同仓库,直接 import face_analysis.special_points;权重已入 git,clone 即用。
依赖惰性导入:网关缺依赖时仅本接口报 1007,不影响其他路由。
"""
import uuid as _uuid
rid = f"gw-{_uuid.uuid4().hex[:8]}"
def _err(code, message):
return JSONResponse(status_code=200, content={
"code": code, "message": message, "request_id": rid, "data": None})
# 三选一校验
provided = [x for x in (image_file, image_url, image_base64) if x]
if len(provided) != 1:
return _err(1007, "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个")
img_bytes = None
if image_file:
img_bytes = await image_file.read()
elif image_base64:
b64 = image_base64.split(",", 1)[1] if "," in image_base64 else image_base64
try:
img_bytes = base64.b64decode(b64)
except Exception:
return _err(1008, "图片格式不支持(base64 解码失败)")
else:
try:
import httpx
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as cli:
r = await cli.get(image_url)
r.raise_for_status()
img_bytes = r.content
except Exception:
return _err(1008, "图片下载失败(URL 不可达)")
try:
import cv2
import numpy as np # noqa: F401 (detect_special_points 依赖)
from face_analysis.special_points import (
detect_special_points, NoFaceError, NonFrontalError)
except ImportError as ex:
return _err(1007, f"网关依赖未安装(安装清单见 docs/网关待改动.md §9):{ex}")
image = cv2.imdecode(np.frombuffer(img_bytes, np.uint8), cv2.IMREAD_COLOR)
if image is None:
return _err(1008, "图片格式不支持(仅 JPG / PNG)")
from fastapi.concurrency import run_in_threadpool
try:
async with _if99_lock: # 单路排队:同一时刻只跑一路推理
data = await run_in_threadpool(detect_special_points, image)
except NoFaceError:
return _err(1001, "无法识别人像")
except NonFrontalError:
return _err(1003, "角度问题,请上传正面照")
except Exception as ex: # noqa: BLE001
logger.exception("接口99 处理异常")
return _err(1007, f"处理失败:{ex}")
return JSONResponse(status_code=200, content={
"code": 0, "message": "success", "request_id": rid, "data": data})
+8 -9
View File
@@ -1,16 +1,15 @@
[Unit]
Description=Hair Worker (GPU) - 四庭七眼测量 接口1
After=network.target
Description=hair GPU worker FastAPI (0.0.0.0:8187)
After=network-online.target comfyui.service change_hair-hair.service
Wants=comfyui.service change_hair-hair.service
[Service]
Type=simple
User=xsl
WorkingDirectory=/home/xsl/hair
# 鉴权密码:优先 worker_config.json;也可在此用环境变量覆盖
# Environment=WORKER_ACCEPT_PASSWORDS=your-strong-secret
ExecStart=/home/xsl/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
Restart=always
RestartSec=3
User=ubuntu
WorkingDirectory=/home/ubuntu/hair
ExecStart=/home/ubuntu/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+3 -3
View File
@@ -170,10 +170,10 @@
},
"16": {
"inputs": {
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
"unet_name": "flux-2-klein-9b-Q4_K_M.gguf",
"weight_dtype": "fp8_e4m3fn"
},
"class_type": "UNETLoader",
"class_type": "UnetLoaderGGUF",
"_meta": {
"title": "UNet加载器"
}
@@ -410,7 +410,7 @@
},
"60": {
"inputs": {
"text": "补充遮罩区域的头发,加一点美颜"
"text": "填充遮罩区域的头发,人物增加一点磨皮"
},
"class_type": "JjkText",
"_meta": {
+67 -5
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import copy
import json
import logging
import os
import random
import time
@@ -18,7 +19,7 @@ import uuid
import httpx
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://10.60.74.221:8188").rstrip("/")
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188").rstrip("/")
_WORKFLOW_DEFAULT = os.getenv(
"ADD_HAIR_WORKFLOW",
os.path.join(os.path.dirname(os.path.dirname(__file__)), "add_hair.json"),
@@ -29,6 +30,29 @@ _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 / UnetLoaderGGUF:Flux 模型加载
_CLIP_NODE = "61" # CLIPLoader:qwen 文本编码器
_VAE_NODE = "3" # VAELoader
# Flux 模型 → 配套文本编码器映射。切换 unet 时自动同步编码器,避免维度不匹配。
def _clip_for_unet(unet_name: str) -> str | None:
"""根据 unet 文件名推断配套的文本编码器文件名;无法推断返回 None。"""
low = unet_name.lower()
if "9b" in low: # Flux.2 9B 系列
return "qwen_3_8b_fp8mixed.safetensors"
if "z-image" in low: # Z-Image-Turbo 用 4B 编码器
return "qwen_3_4b.safetensors"
if "4b" in low: # Flux.2 4B
return "qwen_3_4b.safetensors"
return None
# Flux 模型 → 配套 VAE 映射。Z-Image 用 ae.safetensors,Flux.2 系列用 flux2-vae。
def _vae_for_unet(unet_name: str) -> str | None:
"""根据 unet 文件名推断配套 VAE 文件名;无法推断返回 None(保持工作流原值)。"""
low = unet_name.lower()
if "z-image" in low:
return "ae.safetensors"
return None # Flux.2 系列 vae 在工作流里已正确配置,不覆盖
_wf_cache: dict[str, dict] = {} # path → workflow JSON
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
@@ -85,11 +109,18 @@ def _get_output_node(workflow_path: str | None = None) -> str:
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
workflow_path: str | None = None) -> bytes:
workflow_path: str | None = None, front: bool = False,
unet_name: str | None = None) -> bytes:
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。
front:True 时任务插到 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)
@@ -104,11 +135,39 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
name = (up.get("subfolder") + "/" if up.get("subfolder") else "") + up["name"]
# 2. 改工作流:节点26 输入图 + 随机 seed
try:
import io as _io
from PIL import Image as _Img
_sz = _Img.open(_io.BytesIO(rgba_png_bytes)).size
logging.getLogger("hair.worker").info(
"ComfyUI 输入尺寸 %dx%d workflow=%s", _sz[0], _sz[1], os.path.basename(path))
except Exception: # noqa: BLE001
pass
wf = copy.deepcopy(_load_workflow(path))
wf[_INPUT_NODE]["inputs"]["image"] = name
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
if prompt is not None:
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
if unet_name is not None:
node = wf.get(_UNET_NODE)
if node is not None:
# .gguf 需切换到 ComfyUI-GGUF 插件的 UnetLoaderGGUF 节点;
# .safetensors/.ckpt 保持原 UNETLoader 节点类型不变
if unet_name.lower().endswith(".gguf"):
node["class_type"] = "UnetLoaderGGUF"
else:
node["class_type"] = "UNETLoader"
node["inputs"]["unet_name"] = unet_name
# 同步切换配套文本编码器(4b→qwen_3_4b, 9b→qwen_3_8b),避免维度不匹配
clip_node = wf.get(_CLIP_NODE)
clip_name = _clip_for_unet(unet_name)
if clip_node is not None and clip_name is not None:
clip_node["inputs"]["clip_name"] = clip_name
# 同步切换 VAE(Z-Image 用 ae.safetensors,Flux.2 保持 flux2-vae)
vae_node = wf.get(_VAE_NODE)
vae_name = _vae_for_unet(unet_name)
if vae_node is not None and vae_name is not None:
vae_node["inputs"]["vae_name"] = vae_name
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
try:
@@ -125,8 +184,11 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
except Exception: # noqa: BLE001
pass
# 3. 提交
r = cli.post("/prompt", json={"prompt": wf, "client_id": client_id})
# 3. 提交(front=True 时插队到队列最前)
payload = {"prompt": wf, "client_id": client_id}
if front:
payload["front"] = True
r = cli.post("/prompt", json=payload)
r.raise_for_status()
prompt_id = r.json()["prompt_id"]
@@ -145,7 +207,7 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
outputs = entry.get("outputs")
if outputs and output_node in outputs:
break
time.sleep(1.0)
time.sleep(0.05)
if not outputs or output_node not in outputs:
raise TimeoutError(f"ComfyUI 出图超时({timeout}s) prompt_id={prompt_id}")
+7
View File
@@ -20,6 +20,13 @@ MP_TOP_ANCHORS: list[int] = [
]
N_ANCHORS = len(MP_TOP_ANCHORS) # 17
# Anatomical left/right pairs, in MediaPipe indices. The anchor list is
# NOT mirror-ordered at its ends: 127 pairs with 356, 234 with 454.
MP_TOP_ANCHOR_PAIRS = [
(127, 356), (234, 454), (162, 389), (21, 251),
(54, 284), (103, 332), (67, 297), (109, 338),
]
# 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)
+87
View File
@@ -124,6 +124,46 @@ def build_hairline_mask(parse_map: np.ndarray) -> np.ndarray:
return build_hair_mask(parse_map)
def align_hairline_for_template(
landmarks_norm: np.ndarray,
hairline_norm: np.ndarray,
valid: np.ndarray,
image_shape: tuple[int, int],
) -> np.ndarray:
"""Keep a designed template aligned with the face rather than the fringe.
Uneven bangs can make the detected hair boundary much higher on one
side, shearing an otherwise balanced template. Average the upward
extension of each anatomical anchor pair, measured in image pixels.
Keep the face anchors and the perpendicular component unchanged so
facial roll/perspective remain visible; do not level the curve in the
image. Missing ray hits retain their fallback and never influence a
valid partner. Call silhouette clamping afterwards to bound the result.
This is for template placement only, not measurement of the real hairline.
"""
h, w = image_shape
scale = np.array([w, h], dtype=np.float64)
up = face_up_vector(landmarks_norm) * scale
length = np.linalg.norm(up)
if length < 1e-9:
return hairline_norm.copy()
up /= length
anchors = landmarks_norm[C.MP_TOP_ANCHORS, :2] * scale
points = hairline_norm * scale
distances = (points - anchors) @ up
indices = {mp_idx: i for i, mp_idx in enumerate(C.MP_TOP_ANCHORS)}
for left, right in C.MP_TOP_ANCHOR_PAIRS:
i, j = indices[left], indices[right]
if not (valid[i] and valid[j]):
continue
mean_distance = (distances[i] + distances[j]) / 2.0
for k in (i, j):
points[k] += (mean_distance - distances[k]) * up
return (points / scale).astype(np.float32)
def smooth_hairline(
hairline_norm: np.ndarray,
valid: np.ndarray,
@@ -186,6 +226,53 @@ def smooth_hairline_corner_aware(
return out
def clamp_hairline_to_silhouette(
hairline_norm: np.ndarray,
parse_map: np.ndarray,
margin_px: float = 2.0,
) -> np.ndarray:
"""把发际线点的 y 钳制在 (skin∪hair) silhouette 上沿之下(不含 margin 以上)。
根因(见 issue:男性 ellipse 发际线贴到头部外面):`sample_hairline` 对射线
未命中 hair 像素的锚点会 fallback 成「锚点 + 固定 0.18 归一化偏移」,与头部实际
大小/位置无关 —— 短发/剃光头场景下这个偏移量常常把点顶到头部轮廓外面的背景里,
在有效/失效锚点交界处形成尖角,被贴图上的不透明像素蒙到就会露出戳出头部的线条。
本函数在几何检测之后追加一步「安全网」:对每个点按其 x 所在列,取 silhouette
(SegFormer skin∪hair 类,近似头部实际轮廓)上沿 y,若点比这个上沿还高(y 更
小),直接钳制到 上沿 + margin_px —— 保证曲线永远不会跑到头部轮廓外面的背景。
"""
h, w = parse_map.shape
cols_with_head, top_y = _head_top_y_per_column(parse_map, use_full_hair=True)
if cols_with_head.size == 0:
return hairline_norm
out = hairline_norm.copy()
for i in range(out.shape[0]):
x_px = float(out[i, 0]) * w
idx = int(np.searchsorted(cols_with_head, x_px))
idx = min(max(idx, 0), cols_with_head.size - 1)
sil_y = float(top_y[idx]) + margin_px
y_px = float(out[i, 1]) * h
if y_px < sil_y:
out[i, 1] = sil_y / h
return out
def sample_hairline_clamped(
landmarks_norm: np.ndarray,
parse_map: np.ndarray,
fallback_extrapolation: float = 0.18,
) -> tuple[np.ndarray, np.ndarray]:
"""策略 A(baseline + 头部轮廓钳制):与默认 `sample_hairline` 完全一致的检测,
额外用 `clamp_hairline_to_silhouette` 兜底 —— 检测失效 fallback 出的点不再可能
跑到头部外面的背景,而是贴着头部实际轮廓顶部。改动小、风险低,只在检测失效/
fallback 越界时才生效,正常长发照片的结果与 baseline 完全一致。
"""
hairline, valid = sample_hairline(landmarks_norm, parse_map, fallback_extrapolation)
hairline = clamp_hairline_to_silhouette(hairline, parse_map)
return hairline, valid
# ---------------------------------------------------------------------------
# Alternative hairline sampling strategies.
#
+80
View File
@@ -0,0 +1,80 @@
"""直接调 ComfyUI 重绘 — 替代 local_test HTTP 服务。
将 local_test/app.py 的核心逻辑(遮罩处理 + ComfyUI 调用)提取为 Python 函数,
不再需要独立 Flask 服务。使用 0716add-hair-api.json 工作流(steps=4)。
"""
from __future__ import annotations
import io
import logging
import os
import numpy as np
from PIL import Image, ImageFilter
from . import comfyui
logger = logging.getLogger("hair.worker")
_DEFAULT_PROMPT = "填充遮罩区域的头发,人物增加一点磨皮"
_REPO = os.path.dirname(os.path.dirname(__file__))
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
def _process_mask_to_rgba(image_bytes: bytes, mask_bytes: bytes) -> bytes:
"""将分开的 image + mask 处理为 ComfyUI 用的 RGBA PNG bytes。
复制 local_test/app.py 的遮罩处理逻辑:
1. 加载 image 为 RGB
2. 加载 mask 为 RGBA,取所有通道 max 值(支持红/白/alpha 遮罩)
3. resize mask 到与 image 一致
4. 高斯模糊(radius=4) 柔化边缘
5. alpha = 255 - mask(绘制区=255 → alpha=0 → 重绘区)
6. 合成 RGBA PNG
"""
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
mask_img = Image.open(io.BytesIO(mask_bytes)).convert("RGBA")
mask_arr = np.array(mask_img)
mask_data = np.max(mask_arr, axis=2) # (H, W) uint8
mask_data_img = Image.fromarray(mask_data, mode="L")
if mask_data_img.size != image.size:
mask_data_img = mask_data_img.resize(image.size, Image.LANCZOS)
mask_data_img = mask_data_img.filter(ImageFilter.GaussianBlur(radius=4))
# ComfyUI LoadImage: mask = 1.0 - (alpha/255)
# alpha=0 -> mask=1.0 (inpaint), alpha=255 -> mask=0.0 (keep)
comfyui_alpha = Image.eval(mask_data_img, lambda x: 255 - x)
r, g, b = image.split()
rgba = Image.merge("RGBA", (r, g, b, comfyui_alpha))
buf = io.BytesIO()
rgba.save(buf, format="PNG")
return buf.getvalue()
def run_redraw(image_bytes: bytes, mask_bytes: bytes,
prompt: str | None = None, timeout: float = 300.0,
front: bool = False, unet_name: str | None = None) -> bytes:
"""直接调 ComfyUI 重绘 — 替代 local_test /api/generate。
Args:
image_bytes: 人物图片字节(JPG/PNG)
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
prompt: 提示词,None 用默认 "填充遮罩区域的头发,人物增加一点磨皮"
timeout: ComfyUI 超时秒数
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
unet_name: 非 None 时切换 Flux 模型(如 flux-2-klein-9b-Q5_K_M.gguf),None 用工作流默认
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)
+257 -49
View File
@@ -14,7 +14,10 @@ from . import constants as C
from . import comfyui
from .face_landmarks import FaceLandmarker
from .face_parsing import FaceParser
from .hairline_2d import sample_hairline, smooth_hairline
from .hairline_2d import (
smooth_hairline, sample_hairline, align_hairline_for_template,
clamp_hairline_to_silhouette,
)
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
@@ -27,7 +30,8 @@ import logging
logger = logging.getLogger("hair.worker")
# 接口2 女性发型 key → change_hair hair_id(chang_*)映射:换发型+Flux-2 整帧重绘用。
# 与接口12 final 的 5 型一一对应。
# 与接口12 final 的 5 型一一对应。female 6/7(bigflower/clasicalflower)无对应 LoRA,
# 走与男性一致的原生生发(ComfyUI add_hair)管线,故不在本表。
_FEMALE_KEY_TO_CHANG = {
"ellipse": "chang_tuoyuan", # 椭圆
"flower": "chang_huaban", # 花瓣
@@ -36,31 +40,69 @@ _FEMALE_KEY_TO_CHANG = {
"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",
"wave"], # 1..7
}
_REPO = os.path.dirname(os.path.dirname(__file__))
_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture")
_BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
# 外部重绘服务(local_test,0716add-hair.json 工作流)。接口2 female 用它替代原 Flux-2 重绘。
# 重绘服务已独立到远程机器;可用 HAIR_LOCAL_REDRAW_URL 覆盖。
# gpu_worker 同内网走 10.60.74.221,外网(本地开发机)需走公网 117.50.183.232。
_LOCAL_REDRAW_URL = os.getenv("HAIR_LOCAL_REDRAW_URL", "http://10.60.74.221:8899").rstrip("/")
# 三接口(接口2女重绘 / 接口2男 / 接口3)统一的 ComfyUI 重绘 prompt。
# 关键:ComfyUI 单卡显存装不下 Flux(7.7G)+qwen CLIP(3.9G) 同驻,靠缓存 CLIP 文本条件避免重载。
# prompt 不同会使缓存失效 → 重载 CLIP 并挤出 Flux(每次 +4s)。三接口用同一字符串即可全程命中。
# 与 app.py 接口2/接口3 的默认 prompt 保持一致;可用 REDRAW_PROMPT 覆盖。
_REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发,人物增加一点磨皮")
# 接口2 女重绘整条管线(swapHair + ComfyUI)送模型前限边。真实照片常达 1257x1495:
# 全分辨率 ComfyUI 重绘要 13~21s 且激活显存把模型挤出。女性路径含 swapHair(SD WebUI ~5.3s
# 固定地板) + ComfyUI 两段串行。1024 档画质更好但部分大图会踩 12s 线,
# 默认压到 896 兜底(ComfyUI ~4s,女性总耗时 9~11s);追画质可设 REDRAW_MAX_SIDE=1024。
_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "896"))
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
"""调外部重绘服务(local_test /api/generate):传 final 图 + 纯红遮罩 PNG,
返回重绘后的 PNG bytes。失败抛异常(调用方负责 try/except 跳过)。
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0,
max_side=None, unet_name=None, prompt=None):
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
传 final 图 + 纯红遮罩 PNG,返回重绘后的 PNG bytes。
失败抛异常(调用方负责 try/except 跳过)。
max_side:送 ComfyUI 前长边压到多少像素,None 用全局默认 _REDRAW_MAX_SIDE。
unet_name:非 None 时切换 Flux 模型,None 用工作流内置默认。
prompt:None 用默认 _REDRAW_PROMPT,否则用传入的提示词。
"""
import requests
files = {
"image": ("final.jpg", image_png_bytes, "image/jpeg"),
"mask": ("mask.png", mask_png_bytes, "image/png"),
}
resp = requests.post(_LOCAL_REDRAW_URL + "/api/generate", files=files, timeout=timeout)
resp.raise_for_status()
if "image/" not in resp.headers.get("Content-Type", ""):
# 服务返回了 JSON 错误
raise RuntimeError(f"local_test 返回非图片: {resp.text[:200]}")
return resp.content
from .redraw import run_redraw
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=prompt if prompt is not None else _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 = {
@@ -69,9 +111,8 @@ _TEXTURE_DIRS = {
"low": os.path.join(_REPO, "hairline_texture_low"),
}
# ⚠️ 本 worker 是 RTX 5090(sm_120),torch 2.2.2(cu121) 只编到 sm_90,CUDA 跑算子会报
# "no kernel image"。SegFormer 默认走 CPU(~2.5s/张)。换 torch cu128 后可设 SEG_DEVICE=cuda。
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cpu")
# torch 2.7.1+cu128 已支持 RTX 5090 (sm_120),SegFormer 走 GPU(~0.05s/张)
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cuda")
_landmarker = None
_parser = None
@@ -102,10 +143,13 @@ def _gender_key(stem: str):
def get_texture_map(level: str = "middle") -> dict:
"""扫描指定档位贴图目录建 {gender: [(key, path)]},按 key 排序、按档位缓存。
"""扫描指定档位贴图目录建 {gender: [(key, path)]},按显式顺序表排序、按档位缓存。
level:middle(默认) / 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}")
@@ -119,7 +163,9 @@ def get_texture_map(level: str = "middle") -> dict:
if gender:
mapping[gender].append((key, path))
for g in mapping:
mapping[g].sort(key=lambda kp: kp[0])
order = _HAIRSTYLE_ORDER.get(g, [])
idx = {k: i for i, k in enumerate(order)}
mapping[g].sort(key=lambda kp: (idx.get(kp[0], len(idx)), kp[0]))
_texture_maps[level] = mapping
return mapping
@@ -133,13 +179,23 @@ def extract_502(image_bgr: np.ndarray):
def extract_context(image_bgr: np.ndarray):
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。"""
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。
发际线几何使用射线检测 + 人脸对应点对齐 + 头部轮廓钳制:
左右刘海高低不同时,协调对应锚点的延伸距离,避免把设计模板拉歪;
保留人脸本身的倾斜。预览白线与生发黑线/遮罩共用同一套 points。
短发/剃光头照片(如 man_test.jpg)中间锚点检测失效时,纯射线检测的固定 fallback
偏移会把点顶到头部轮廓外面的背景,产生"发际线贴到头部外面"的视觉 bug;钳制兜底后
fallback 点不会再跑出头部轮廓。
"""
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
landmarks = get_landmarker().detect(rgb)
if landmarks is None:
return None
parse_map = get_parser().parse(rgb)
hairline_2d, valid = sample_hairline(landmarks, parse_map)
hairline_2d = align_hairline_for_template(landmarks, hairline_2d, valid, parse_map.shape)
hairline_2d = clamp_hairline_to_silhouette(hairline_2d, 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)
@@ -173,10 +229,11 @@ def generate_previews(image_bgr: np.ndarray, gender: str):
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
prompt: str = None, hair_styles: list[int] | None = None,
workflow_path: str | None = None):
workflow_path: str | None = None,
unet_name: str | None = None):
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 1..4。
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..7,male: 1..7。
为 None 时生成全部(兼容旧调用)。
use_mask(默认 True):是否启用 inpaint 遮罩,用于测试对比(同接口3)。
False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。
@@ -204,9 +261,15 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
if not use_mask:
try:
h, w = image_bgr.shape[:2]
img_s, msk_s, gsc = _prep_comfy_input(image_bgr, np.zeros((h, w), np.uint8))
buf = io.BytesIO()
compose_comfy_rgba(image_bgr, np.zeros((h, w), np.uint8)).save(buf, format="PNG")
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
compose_comfy_rgba(img_s, msk_s).save(buf, format="PNG", compress_level=1)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt,
workflow_path=workflow_path, front=True,
unet_name=unet_name)
if gsc < 1.0 and shared_grown:
shared_grown = _upscale_png_to(shared_grown, w, h)
except Exception as e: # noqa: BLE001
logger.warning("接口2 生发图失败(无遮罩):%s", e)
@@ -224,9 +287,15 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
black = load_texture_rgba(_black_texture_path(white_path))
marked, mask = build_inpaint_mask(
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
m_s, msk_s, gsc = _prep_comfy_input(marked, mask)
buf = io.BytesIO()
compose_comfy_rgba(marked, mask).save(buf, format="PNG")
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, workflow_path=workflow_path)
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
grown_png = comfyui.run(buf.getvalue(), prompt=prompt,
workflow_path=workflow_path, front=True,
unet_name=unet_name)
if gsc < 1.0 and grown_png:
grown_png = _upscale_png_to(grown_png, w, h)
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
logger.warning("接口2 生发图失败 type=%s:%s", key, e)
@@ -236,25 +305,39 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
redraw_defaults: dict):
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 换发型重绘图。
redraw_defaults: dict,
redraw_max_side: int | None = None,
unet_name: str | None = None,
prompt: str | None = None):
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 生发图。
grown 图来源(新流程):对每个选中发型把 female key 映射到 change_hair 的 chang_* hair_id,
调 face_analysis.hairline_grow.generate_hairline_redraw(= 接口12 final 管线,参数用
redraw_defaults)拿到 ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG,再**后端调外部重绘服务
local_test**(0716add-hair.json 工作流)完成发际线带重绘,重绘结果作为生发图。
生发图来源按发型分两路:
- 换发型(chang_* 表内,1..5):female key → change_hair 的 chang_* hair_id,调
face_analysis.hairline_grow.generate_hairline_redraw(= 接口12 final 管线,参数用
redraw_defaults)拿到 ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG,再**后端直接调
ComfyUI**(0716add-hair-api.json 工作流)完成发际线带重绘。
- 原生生发(表外,6/7 bigflower/clasicalflower 等):无对应 change_hair LoRA,改走与男性
一致的原生生发(ComfyUI add_hair),由 _grow_native_one 完成(黑模板 + inpaint 遮罩)。
overlay 仍是发际线曲线透明层(与 generate_grow_results 完全一致)。
prompt 两条分支都生效:换发型分支传入 _call_local_redraw,原生生发分支传给
_grow_native_one。None/默认「填充遮罩区域的头发,人物增加一点磨皮」时与全局 _REDRAW_PROMPT 一致,
不破坏 CLIP 缓存命中的快路径;传不同提示词会使缓存失效并重载 CLIP/Flux
(实测换词后首次请求重绘 45s vs 缓存命中 8s),后续相同提示词恢复快路径。
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]
@@ -263,6 +346,21 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
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)
@@ -270,10 +368,16 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
grown_png = None
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
if chang_id is None:
logger.warning("接口2 换发型:female key=%s 无对应 chang_id,跳过生发图", key)
# 无对应 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:
data = generate_hairline_redraw(image_bgr, chang_id, **redraw_defaults)
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 ""
@@ -289,10 +393,23 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
mask_b64 = mask_b64.split(",", 1)[1]
final_bytes = base64.b64decode(final_b64)
mask_bytes = base64.b64decode(mask_b64)
# 后端调外部重绘服务(local_test),返回重绘后的 PNG
grown_png = _call_local_redraw(final_bytes, mask_bytes)
# 后端直接调 ComfyUI 重绘,返回重绘后的 PNG
_tr0 = _t.perf_counter()
grown_png = _call_local_redraw(final_bytes, mask_bytes,
max_side=redraw_max_side,
unet_name=unet_name,
prompt=prompt)
_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 单张失败不拖垮整请求
@@ -319,22 +436,51 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
h, w = image_bgr.shape[:2]
marked, mask = image_bgr, np.zeros((h, w), np.uint8)
buf = io.BytesIO()
compose_comfy_rgba(marked, mask).save(buf, format="PNG")
compose_comfy_rgba(marked, mask).save(buf, format="PNG", compress_level=1)
return comfyui.run(buf.getvalue(), prompt=prompt)
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
logger.warning("接口5 生发图失败:%s", e)
return None
def _grow_native_one(image_bgr: np.ndarray, ctx: dict, white_path: str,
prompt: str | None = None, unet_name: str | None = None):
"""对单个发际线做原生生发(ComfyUI add_hair),与男性 generate_grow_results 一致。
接口2 女性新发型(bigflower/clasicalflower 等)无对应 change_hair LoRA,改走此路径:
黑模板 → build_inpaint_mask → 限边(_prep_comfy_input)→ comfyui.run(front=True)。
失败返回 None,不抛异常。结果按限边前原图尺寸放大回原尺寸(仅展示对齐)。
"""
try:
h, w = image_bgr.shape[:2]
black = load_texture_rgba(_black_texture_path(white_path))
marked, mask = build_inpaint_mask(
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
m_s, msk_s, gsc = _prep_comfy_input(marked, mask)
buf = io.BytesIO()
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, front=True, unet_name=unet_name)
if gsc < 1.0 and grown_png:
grown_png = _upscale_png_to(grown_png, w, h)
return grown_png
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
logger.warning("接口2 女原生生发图失败:%s", e)
return None
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
hair_styles: list[int], use_mask: bool = True,
prompt: str | None = None):
prompt: str | None = None,
generate_grow_image: bool = True):
"""接口5:对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。
入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。
每个选中发型返回三档叠图(middle/high/low,RGBA 透明层只含发际线曲线)与一张生发图;
三档贴图同名,生发黑模板固定取自 hairline_texture_black/(middle),故生发目标固定 middle 档。
use_mask/prompt:同接口2 的生发参数。
generate_grow_image(默认 True):是否生成生发图(ComfyUI,最耗时)。False 时跳过生发,
各发型 grown_png 恒为 None,可大幅降低耗时(仅留三档发际线叠图与中心点)。
Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}((H,W,4) RGBA 透明层),grown_png}],
"best_centers":{"middle":(x,y),"high":(x,y),"low":(x,y)}};无人脸 None。
best_centers 取首个选中发型三档各自的发际线中点。
@@ -356,8 +502,9 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
tex_by_level = {lv: get_texture_map(lv)[gender] for lv in _TEXTURE_DIRS}
# use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用
# generate_grow_image=False:完全跳过生发(最耗时),grown_png 恒为 None
shared_grown = None
if not use_mask:
if generate_grow_image and not use_mask:
shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt)
def _center_of(overlay):
@@ -376,9 +523,13 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
for lv in _TEXTURE_DIRS:
white = load_texture_rgba(tex_by_level[lv][s - 1][1])
overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
# 生发:固定 middle 黑模板
grown_png = shared_grown if not use_mask else \
_grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt)
# 生发:固定 middle 黑模板(generate_grow_image=False 时跳过,恒 None)
if not generate_grow_image:
grown_png = None
elif not use_mask:
grown_png = shared_grown
else:
grown_png = _grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt)
images.append({"hairline_type": key, "order": s,
"overlays": overlays, "grown_png": grown_png})
# best_centers:首个选中发型三档(middle/high/low)发际线中点
@@ -387,18 +538,73 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
return {"images": images, "best_centers": best_centers}
# 接口3 送 ComfyUI 前限边,降低峰值显存,避免与接口2 切换时把 Flux 挤出。
# 统一 prompt 后 Flux 不再被 CLIP 挤出,接口3 可用较高分辨率。可用 GROW_B_MAX_SIDE 覆盖。
_GROW_B_MAX_SIDE = int(os.getenv("GROW_B_MAX_SIDE", "1024"))
def _downscale_max_side(img_bgr: np.ndarray, max_side: int) -> tuple[np.ndarray, float]:
"""长边超过 max_side 时等比例缩小;返回 (图, scale),scale=新/旧。"""
h, w = img_bgr.shape[:2]
m = max(h, w)
if max_side <= 0 or m <= max_side:
return img_bgr, 1.0
scale = max_side / float(m)
nw = max(1, int(round(w * scale)))
nh = max(1, int(round(h * scale)))
out = cv2.resize(img_bgr, (nw, nh), interpolation=cv2.INTER_AREA)
return out, scale
def _upscale_png_to(png_bytes: bytes, out_w: int, out_h: int) -> bytes:
"""把 Comfy 输出 PNG 双线性拉回原图尺寸(仅展示对齐,不增加推理细节)。"""
arr = np.frombuffer(png_bytes, np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
if img is None:
return png_bytes
if img.shape[1] == out_w and img.shape[0] == out_h:
return png_bytes
resized = cv2.resize(img, (out_w, out_h), interpolation=cv2.INTER_LINEAR)
ok, buf = cv2.imencode(".png", resized)
return buf.tobytes() if ok else png_bytes
def _prep_comfy_input(img_bgr: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray, float]:
"""单段 ComfyUI 生发(接口2男 / 接口3)送图前限边到 GROW_B_MAX_SIDE。
返回 (缩后图, 缩后遮罩, scale);scale<1 时调用方需把结果放大回原尺寸。"""
h, w = img_bgr.shape[:2]
if _GROW_B_MAX_SIDE <= 0 or max(h, w) <= _GROW_B_MAX_SIDE:
return img_bgr, mask, 1.0
out, scale = _downscale_max_side(img_bgr, _GROW_B_MAX_SIDE)
nh, nw = out.shape[:2]
msk = cv2.resize(mask, (nw, nh), interpolation=cv2.INTER_NEAREST)
logger.info("接口2男/接口3 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)",
w, h, nw, nh, _GROW_B_MAX_SIDE)
return out, msk, scale
def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str = None):
"""接口3:检测医生手绘发际线 → 遮罩 → 送 ComfyUI 生发(仅需划线图一张)。
检测路径只用来**建遮罩**;ComfyUI 输入图用 **marked 原图**(含医生手绘线,
工作流提示词会清除黑线再生发)。
进 Comfy 前若长边 > GROW_B_MAX_SIDE(默认 896)会先等比例缩小,降低峰值显存;
输出再拉回原图尺寸。
use_mask(默认 True):是否启用自动检测的遮罩,用于测试对比。
- True:检测手绘线 → 建遮罩 → alpha=255−mask(透明区=重绘区,节点44 画黄色参考区)。
- False:跳过检测,直接送划线图,alpha 全 255(空遮罩,节点26 mask 为空),
模型仅凭医生黑线参考生发。无需改工作流,唯一变量是遮罩。
Returns: {"grown_png": bytes 或 None, "status": "ok"|"no_face"|"no_line"}。
"""
orig_h, orig_w = marked_bgr.shape[:2]
marked_bgr, _scale = _downscale_max_side(marked_bgr, _GROW_B_MAX_SIDE)
if _scale < 1.0:
logger.info(
"接口3 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)",
orig_w, orig_h, marked_bgr.shape[1], marked_bgr.shape[0], _GROW_B_MAX_SIDE,
)
h, w = marked_bgr.shape[:2]
if use_mask:
rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB)
@@ -416,8 +622,10 @@ def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str =
mask = np.zeros((h, w), np.uint8) # 空遮罩:alpha 全 255,跳过检测
buf = io.BytesIO()
compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG") # marked 原图 + 遮罩
compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG", compress_level=1) # marked + 遮罩
grown_png = comfyui.run(buf.getvalue(), prompt=prompt)
if _scale < 1.0 and grown_png:
grown_png = _upscale_png_to(grown_png, orig_w, orig_h)
return {"grown_png": grown_png, "status": "ok"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

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