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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 16:47:04 +08:00
xslandCursor 085ad3ced0 接口1 四庭七眼标注:字体更小、数值带cm、增加百分比、线名右移居中对齐
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 23:37:59 +08:00
xslandCursor 2993a0b948 perf(接口2/3): 混合调用零模型换出且响应<12s
- 三接口统一 ComfyUI 重绘 prompt「填充遮罩区域的头发,皮肤加一点磨皮」,
  避免 CLIP 文本条件缓存失效导致的反复重载(单卡装不下 Flux+CLIP 同驻)。
- 接口2 女重绘整条管线(swapHair+ComfyUI)送模型前限边 REDRAW_MAX_SIDE(默认1024),
  overlay 预览保持全分辨率,结果放大回原尺寸。
- 接口2 男/接口3 单段推理经 _prep_comfy_input 限边 GROW_B_MAX_SIDE(默认1024)。
- 修复真实大图(1257x1495)全分辨率送模型导致 13~21s 且把 Flux 挤出显存的问题。
- comfyui.py 增加输入尺寸日志;service.py 增加女重绘分段计时(swap/blend/ComfyUI)。

实测真实图三接口任意交替: 女9~12s / 男7.8s / 接口3 6.6s,CLIP/Flux 重载 0 次。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 20:08:21 +08:00
xslandCursor 08b31a3baa fix: 测试页同时兼容 base64 与 URL 图片字段
直连 worker 返回 *_base64、经网关则改写为 *_url;统一 resolveImgSrc 后接口1/2/3/5/6 测试页都能正确显示结果图。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 15:49:46 +08:00
xsl 51e313e845 save code 2026-07-19 00:51:46 +08:00
xsl fbbcd48418 feat: 系统配置文件适配 RTX 3090
更新系统服务配置文件:
- hair-worker.service: 路径从 /home/xsl/ 改为 /home/ubuntu/,添加 comfyui/change_hair 依赖
- comfyui.service: 添加 --cache-classic --fast 标志优化性能
2026-07-18 19:18:52 +08:00
xsl 74ccab0ff8 feat: 适配 RTX 3090 (24GB) 环境优化
硬件迁移:从 RTX 5090 (32GB) 迁移到 RTX 3090 (24GB)

主要改动:
1. hairline/comfyui.py: 轮询间隔从 0.2s 降到 0.05s
2. hairline/service.py: PNG 编码 compress_level=1,节省 ~240ms
3. add_hair.json: 工作流使用 4B FP8 模型 + steps=4
4. static/test_interface3.html: 修复图片显示(添加 data:image/jpeg;base64, 前缀)

性能优化后接口3响应时间:6.6-7.3s(之前 8.66s)
2026-07-18 18:58:40 +08:00
xslandCursor 659c037270 feat: ComfyUI 改走 10.60.74.221,测试页上传图超阈值自动降采样
将 worker 默认 ComfyUI 地址改为远端 10.60.74.221:8188;前端测试页在像素超过 1536000 时等比缩小到 786432 以内。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

pytest 44 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:58:33 +08:00
Ubuntu 68fe1c1406 feat(网关): 接口4 改由网关本机直接调豆包视觉模型,不再代理到 worker
- gateway/app.py: 接口4 handler 改为直接调用 face_features 模块
- face_features.py: API Key 读取支持 gateway/config.json
- gateway/config.example.json: 增加 ark_api_key 配置项
2026-06-15 20:46:12 +08:00
160 changed files with 13015 additions and 3281 deletions
+32
View File
@@ -10,10 +10,17 @@ gateway/config.json
# worker 配置(含鉴权密码,不入 git)
worker_config.json
# ComfyUI Basic Auth 密码(不入 git
password.txt
# worker 运行期文件(PID / 日志)
worker.pid
worker.log
# 请求日志(运行时生成,不入 git)
gateway/request_log.jsonl
gateway/request_log.jsonl.1
# SegFormer 模型权重(~323MB,体积过大,不入 git,见 OFFLINE_ASSETS.md
hairline/models/face-parsing/model.safetensors
@@ -21,3 +28,28 @@ hairline/models/face-parsing/model.safetensors
static/annotations/*
!static/annotations/.gitkeep
tests/output/
# 本地临时遮罩测试页(不入 git)
test_local.py
# 运行期日志 / uvicorn 日志(不入 git
log/
uvicorn.log
uvicorn*.log
# ZCode 工具目录(不入 git
.zcode/
# 临时响应文件(不入 git
_grow*_resp.json
# 测试素材图(体积大,不入 git)
image/test/
# 批量报告输出(生成图+原图,体积大,不入 git)
static/report_hairline_v2/
static/report_hairline_v2.zip
# local_test 运行期日志 / pid(不入 git)
local_test/hair_service.log
local_test/hair_service.pid
+327
View File
@@ -0,0 +1,327 @@
{
"16": {
"class_type": "UNETLoader",
"inputs": {
"unet_name": "flux-2-klein-4b-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn_fast"
}
},
"3": {
"class_type": "VAELoader",
"inputs": {
"vae_name": "flux2-vae.safetensors"
}
},
"61": {
"class_type": "CLIPLoader",
"inputs": {
"clip_name": "qwen_3_4b.safetensors",
"type": "flux2",
"device": "cpu"
}
},
"26": {
"class_type": "LoadImage",
"inputs": {
"image": "placeholder.png"
}
},
"60": {
"class_type": "JjkText",
"inputs": {
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
}
},
"22": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": [
"61",
0
],
"text": [
"60",
0
]
}
},
"31": {
"class_type": "easy imageSize",
"inputs": {
"image": [
"26",
0
]
}
},
"33": {
"class_type": "Mask Fill Holes",
"inputs": {
"masks": [
"26",
1
]
}
},
"36": {
"class_type": "Convert Masks to Images",
"inputs": {
"masks": [
"33",
0
]
}
},
"39": {
"class_type": "ImageScale",
"inputs": {
"image": [
"36",
0
],
"upscale_method": "nearest-exact",
"width": [
"31",
0
],
"height": [
"31",
1
],
"crop": "disabled"
}
},
"37": {
"class_type": "Image To Mask",
"inputs": {
"image": [
"39",
0
],
"method": "intensity"
}
},
"32": {
"class_type": "LayerUtility: ImageScaleByAspectRatio V2",
"inputs": {
"image": [
"26",
0
],
"mask": [
"37",
0
],
"aspect_ratio": "custom",
"proportional_width": [
"31",
0
],
"proportional_height": [
"31",
1
],
"fit": "letterbox",
"method": "lanczos",
"round_to_multiple": "8",
"scale_to_side": "None",
"scale_to_length": 1024,
"background_color": "#000000"
}
},
"44": {
"class_type": "ImageAndMaskPreview",
"inputs": {
"image": [
"32",
0
],
"mask": [
"32",
1
],
"mask_opacity": 1,
"mask_color": "FFFF00",
"pass_through": true
}
},
"14": {
"class_type": "GetImageSize+",
"inputs": {
"image": [
"44",
0
]
}
},
"13": {
"class_type": "VAEEncode",
"inputs": {
"pixels": [
"44",
0
],
"vae": [
"3",
0
]
}
},
"2": {
"class_type": "ModelSamplingFlux",
"inputs": {
"model": [
"16",
0
],
"max_shift": 1.15,
"base_shift": 0.5,
"width": [
"14",
0
],
"height": [
"14",
1
]
}
},
"19": {
"class_type": "FluxGuidance",
"inputs": {
"conditioning": [
"22",
0
],
"guidance": 1
}
},
"5": {
"class_type": "ReferenceLatent",
"inputs": {
"conditioning": [
"19",
0
],
"latent": [
"13",
0
]
}
},
"7": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"width": [
"14",
0
],
"height": [
"14",
1
],
"batch_size": 1
}
},
"1": {
"class_type": "BasicScheduler",
"inputs": {
"model": [
"2",
0
],
"scheduler": "simple",
"steps": 4,
"denoise": 1
}
},
"20": {
"class_type": "BasicGuider",
"inputs": {
"model": [
"2",
0
],
"conditioning": [
"5",
0
]
}
},
"6": {
"class_type": "RandomNoise",
"inputs": {
"noise_seed": 0
}
},
"8": {
"class_type": "KSamplerSelect",
"inputs": {
"sampler_name": "euler"
}
},
"9": {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": [
"6",
0
],
"guider": [
"20",
0
],
"sampler": [
"8",
0
],
"sigmas": [
"1",
0
],
"latent_image": [
"7",
0
]
}
},
"10": {
"class_type": "VAEDecode",
"inputs": {
"samples": [
"9",
0
],
"vae": [
"3",
0
]
}
},
"62": {
"class_type": "ColorMatch",
"inputs": {
"image_ref": [
"26",
0
],
"image_target": [
"10",
0
],
"method": "mkl",
"strength": 1,
"multithread": true
}
},
"17": {
"class_type": "SaveImage",
"inputs": {
"images": [
"62",
0
],
"filename_prefix": "hair_inpaint"
}
}
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -84,7 +84,7 @@ model.safetensors https://huggingface.co/jonathandinu/face-parsing/resol
- **workerGPU 机)**`mediapipe` / `opencv-python` / `numpy<2` / `Pillow` / **`torch`+`torchvision` 的 CUDA 版**(按 GPU 的 CUDA 版本选 cu118/cu121 等)/ `transformers`(接口2 SegFormer+ FastAPI/uvicorn 全家桶。
- **网关机**:很轻,只需 FastAPI/uvicorn/httpx 等代理依赖,**不需要 torch/mediapipe**。
> 架构已拆分(见 `docs/系统架构-网关与高性能后端.md`):算法依赖只装在 worker,网关保持轻量。
> 架构已拆分(见 `docs/实现说明.md`):算法依赖只装在 worker,网关保持轻量。
---
+15 -15
View File
@@ -2,7 +2,7 @@
"1": {
"inputs": {
"scheduler": "simple",
"steps": 6,
"steps": 4,
"denoise": 1,
"model": [
"2",
@@ -63,7 +63,7 @@
},
"6": {
"inputs": {
"noise_seed": 690815303279000
"noise_seed": 217742615722421
},
"class_type": "RandomNoise",
"_meta": {
@@ -170,8 +170,8 @@
},
"16": {
"inputs": {
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn"
"unet_name": "flux-2-klein-4b-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn_fast"
},
"class_type": "UNETLoader",
"_meta": {
@@ -238,7 +238,7 @@
},
"26": {
"inputs": {
"image": "clipspace/clipspace-painted-masked-1780841075435.png [input]"
"image": "clipspace/clipspace-painted-masked-1781655058499.png [input]"
},
"class_type": "LoadImage",
"_meta": {
@@ -297,7 +297,7 @@
},
"class_type": "Mask Fill Holes",
"_meta": {
"title": "遮罩填充漏洞"
"title": "Mask Fill Holes"
}
},
"36": {
@@ -309,7 +309,7 @@
},
"class_type": "Convert Masks to Images",
"_meta": {
"title": "遮罩到图像"
"title": "Convert Masks to Images"
}
},
"37": {
@@ -322,7 +322,7 @@
},
"class_type": "Image To Mask",
"_meta": {
"title": "图像到遮罩"
"title": "Image To Mask"
}
},
"39": {
@@ -363,7 +363,7 @@
},
"class_type": "ImageAndMaskPreview",
"_meta": {
"title": "图像与遮罩预览"
"title": "ImageAndMaskPreview"
}
},
"45": {
@@ -385,12 +385,12 @@
{
"name": "A",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_nljpo_00001_.png&type=temp&subfolder=&rand=0.7003773747423834"
"url": "/api/view?filename=rgthree.compare._temp_zuixa_00119_.png&type=temp&subfolder=&rand=0.7926013627811991"
},
{
"name": "B",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_nljpo_00002_.png&type=temp&subfolder=&rand=0.10574778041280719"
"url": "/api/view?filename=rgthree.compare._temp_zuixa_00120_.png&type=temp&subfolder=&rand=0.43692946883795813"
}
]
},
@@ -405,12 +405,12 @@
},
"class_type": "Image Comparer (rgthree)",
"_meta": {
"title": "图像对比"
"title": "Image Comparer (rgthree)"
}
},
"60": {
"inputs": {
"text": "严格保留原图人物五官、面部肤色、神态、服饰、背景、原图光影与整体色调,画面除划线区域外全部细节保持原样不变;先清除画面内所有黑色标注划线,仅在原划线划定范围内生成植发术后 3 个月长发效果,新生头发生长边界刚好止于原划线位置,新发沿原划线轮廓边缘和外部原生发丝交错融合、自然无缝衔接,无生硬分界线;发色和自身原生黑发保持统一,发丝细腻写实、带有真实毛发纹理与自然光泽,发量均匀,符合植发 3 个月刚长出的轻微稀疏生长状态,毛发走向遵从头部原生头发生长规律,头发光影明暗和原图环境统一,整体写实自然,无贴片假发质感。"
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
},
"class_type": "JjkText",
"_meta": {
@@ -419,9 +419,9 @@
},
"61": {
"inputs": {
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
"clip_name": "qwen_3_4b.safetensors",
"type": "flux2",
"device": "default"
"device": "cpu"
},
"class_type": "CLIPLoader",
"_meta": {
+714
View File
@@ -0,0 +1,714 @@
{
"1": {
"inputs": {
"scheduler": "simple",
"steps": 6,
"denoise": 0.9,
"model": [
"2",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "基本调度器"
}
},
"2": {
"inputs": {
"max_shift": 1.15,
"base_shift": 0.5,
"width": [
"14",
0
],
"height": [
"14",
1
],
"model": [
"16",
0
]
},
"class_type": "ModelSamplingFlux",
"_meta": {
"title": "采样算法(Flux"
}
},
"3": {
"inputs": {
"vae_name": "flux2-vae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "加载VAE"
}
},
"5": {
"inputs": {
"conditioning": [
"19",
0
],
"latent": [
"64",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "参考Latent"
}
},
"6": {
"inputs": {
"noise_seed": 176834207514884
},
"class_type": "RandomNoise",
"_meta": {
"title": "随机噪波"
}
},
"7": {
"inputs": {
"width": [
"14",
0
],
"height": [
"14",
1
],
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": {
"title": "空Latent图像(SD3"
}
},
"8": {
"inputs": {
"sampler_name": "euler"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "K采样器选择"
}
},
"9": {
"inputs": {
"noise": [
"6",
0
],
"guider": [
"20",
0
],
"sampler": [
"8",
0
],
"sigmas": [
"1",
0
],
"latent_image": [
"64",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "自定义采样器(高级)"
}
},
"10": {
"inputs": {
"samples": [
"9",
0
],
"vae": [
"3",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE解码"
}
},
"14": {
"inputs": {
"image": [
"44",
0
]
},
"class_type": "GetImageSize+",
"_meta": {
"title": "🔧 Get Image Size"
}
},
"16": {
"inputs": {
"unet_name": "flux-2-klein-4b-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn"
},
"class_type": "UNETLoader",
"_meta": {
"title": "UNet加载器"
}
},
"19": {
"inputs": {
"guidance": 1.5,
"conditioning": [
"22",
0
]
},
"class_type": "FluxGuidance",
"_meta": {
"title": "Flux引导"
}
},
"20": {
"inputs": {
"model": [
"2",
0
],
"conditioning": [
"5",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "基本引导器"
}
},
"22": {
"inputs": {
"text": [
"60",
0
],
"clip": [
"61",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP文本编码"
}
},
"26": {
"inputs": {
"image": "clipspace/clipspace-painted-masked-1781933242779.png [input]"
},
"class_type": "LoadImage",
"_meta": {
"title": "加载图像"
}
},
"31": {
"inputs": {
"image": [
"26",
0
]
},
"class_type": "easy imageSize",
"_meta": {
"title": "图像尺寸"
}
},
"32": {
"inputs": {
"aspect_ratio": "custom",
"proportional_width": [
"31",
0
],
"proportional_height": [
"31",
1
],
"fit": "letterbox",
"method": "bicubic",
"round_to_multiple": "8",
"scale_to_side": "None",
"scale_to_length": 1024,
"background_color": "#000000",
"image": [
"26",
0
],
"mask": [
"89",
0
]
},
"class_type": "LayerUtility: ImageScaleByAspectRatio V2",
"_meta": {
"title": "图层工具:按宽高比缩放 V2"
}
},
"33": {
"inputs": {
"masks": [
"26",
1
]
},
"class_type": "Mask Fill Holes",
"_meta": {
"title": "Mask Fill Holes"
}
},
"36": {
"inputs": {
"masks": [
"33",
0
]
},
"class_type": "Convert Masks to Images",
"_meta": {
"title": "Convert Masks to Images"
}
},
"37": {
"inputs": {
"method": "intensity",
"image": [
"39",
0
]
},
"class_type": "Image To Mask",
"_meta": {
"title": "Image To Mask"
}
},
"39": {
"inputs": {
"upscale_method": "nearest-exact",
"width": [
"31",
0
],
"height": [
"31",
1
],
"crop": "disabled",
"image": [
"36",
0
]
},
"class_type": "ImageScale",
"_meta": {
"title": "缩放图像"
}
},
"44": {
"inputs": {
"mask_opacity": 1,
"mask_color": "FFFF00",
"pass_through": true,
"image": [
"32",
0
],
"mask": [
"32",
1
]
},
"class_type": "ImageAndMaskPreview",
"_meta": {
"title": "ImageAndMaskPreview"
}
},
"45": {
"inputs": {
"images": [
"44",
0
]
},
"class_type": "PreviewImage",
"_meta": {
"title": "预览图像"
}
},
"53": {
"inputs": {
"rgthree_comparer": {
"images": [
{
"name": "A",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_plnpv_00023_.png&type=temp&subfolder=&rand=0.8949967564686747"
},
{
"name": "B",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_plnpv_00024_.png&type=temp&subfolder=&rand=0.6013833933740399"
}
]
},
"image_a": [
"73",
0
],
"image_b": [
"26",
0
]
},
"class_type": "Image Comparer (rgthree)",
"_meta": {
"title": "Image Comparer (rgthree)"
}
},
"60": {
"inputs": {
"text": "补充遮罩内区域的头发,区域内填充满头发,不要保留皮肤,发际线下移填充头发。自然的头发生长方向,逼真的头发质感,自然发质。"
},
"class_type": "JjkText",
"_meta": {
"title": "Text"
}
},
"61": {
"inputs": {
"clip_name": "qwen_3_4b.safetensors",
"type": "flux2",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "加载CLIP"
}
},
"64": {
"inputs": {
"grow_mask_by": 3,
"vae": [
"3",
0
],
"images": [
"44",
0
],
"masks": [
"32",
1
]
},
"class_type": "VAEEncodeForInpaint_Frames",
"_meta": {
"title": "VAE Encode For Inpaint Frames ♾️Mixlab"
}
},
"68": {
"inputs": {
"sampler_name": "euler"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "K采样器选择"
}
},
"69": {
"inputs": {
"vae_name": "flux2-vae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "加载VAE"
}
},
"70": {
"inputs": {
"cfg": 1,
"model": [
"86",
0
],
"positive": [
"85",
0
],
"negative": [
"80",
0
]
},
"class_type": "CFGGuider",
"_meta": {
"title": "CFG引导器"
}
},
"71": {
"inputs": {
"noise_seed": 1105558556688843
},
"class_type": "RandomNoise",
"_meta": {
"title": "随机噪波"
}
},
"72": {
"inputs": {
"noise": [
"71",
0
],
"guider": [
"70",
0
],
"sampler": [
"68",
0
],
"sigmas": [
"77",
0
],
"latent_image": [
"81",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "自定义采样器(高级)"
}
},
"73": {
"inputs": {
"samples": [
"72",
0
],
"vae": [
"69",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE解码"
}
},
"75": {
"inputs": {
"filename_prefix": "ComfyUI",
"images": [
"73",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "保存图像"
}
},
"76": {
"inputs": {
"rgthree_comparer": {
"images": [
{
"name": "A",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_ssjll_00009_.png&type=temp&subfolder=&rand=0.8998219800410978"
},
{
"name": "B",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_ssjll_00010_.png&type=temp&subfolder=&rand=0.2100153657131264"
}
]
},
"image_a": [
"73",
0
],
"image_b": [
"26",
0
]
},
"class_type": "Image Comparer (rgthree)",
"_meta": {
"title": "Image Comparer (rgthree)"
}
},
"77": {
"inputs": {
"steps": 6,
"width": [
"83",
1
],
"height": [
"83",
1
]
},
"class_type": "Flux2Scheduler",
"_meta": {
"title": "Flux2调度器"
}
},
"78": {
"inputs": {
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
"type": "flux2",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "加载CLIP"
}
},
"79": {
"inputs": {
"conditioning": [
"87",
0
]
},
"class_type": "ConditioningZeroOut",
"_meta": {
"title": "条件零化"
}
},
"80": {
"inputs": {
"conditioning": [
"79",
0
],
"latent": [
"81",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "参考Latent"
}
},
"81": {
"inputs": {
"pixels": [
"10",
0
],
"vae": [
"69",
0
]
},
"class_type": "VAEEncode",
"_meta": {
"title": "VAE编码"
}
},
"82": {
"inputs": {
"width": [
"83",
0
],
"height": [
"83",
1
],
"batch_size": 1
},
"class_type": "EmptyFlux2LatentImage",
"_meta": {
"title": "空Latent图像(Flux2"
}
},
"83": {
"inputs": {
"image": [
"10",
0
]
},
"class_type": "GetImageSize",
"_meta": {
"title": "获取图像尺寸"
}
},
"85": {
"inputs": {
"conditioning": [
"87",
0
],
"latent": [
"81",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "参考Latent"
}
},
"86": {
"inputs": {
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "UNet加载器"
}
},
"87": {
"inputs": {
"text": "去掉头发接缝的黄色痕迹,头发完美融合,保持发型不变,发色不变。其他不变。",
"clip": [
"78",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP文本编码"
}
},
"89": {
"inputs": {
"left": 5,
"top": 15,
"right": 5,
"bottom": 15,
"mask": [
"37",
0
]
},
"class_type": "FeatherMask",
"_meta": {
"title": "羽化遮罩"
}
}
}
+1187 -154
View File
File diff suppressed because it is too large Load Diff
+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
+9 -28
View File
@@ -1,33 +1,14 @@
# 文档索引(按开发机器划分)
# 文档索引
系统拆分为两台机器开发:**外网网关** 和 **高性能 workerGPU**。下面标清每台机器该读哪些文档。
## 🌐 两端共享(都要读)
旷视五接口(四庭七眼测量 / C端生发 / B端生发 / 用户特征 / 发际线PNG)。系统拆成
**外网网关 + 高性能 worker(GPU)** 两台机器、一个仓库。
| 文档 | 作用 |
|------|------|
| [系统架构-网关与高性能后端.md](系统架构-网关与高性能后端.md) | 两层拆分的总设计与契约:拓扑、职责、配置、鉴权、base64→URL、错误处理。**两端的接口约定,先读。** |
| [接口文档.md](接口文档.md) | 对外 API 契约(字段/错误码)。**拆分后保持不变**,是字段命名的唯一权威。 |
| [旷视具体需求.md](旷视具体需求.md) | 原始需求 |
| [实现说明.md](实现说明.md) | **实现总览**:架构、五个接口怎么实现、base64→URL 映射、错误码、部署/环境要点。先读这份。 |
| [接口文档.md](接口文档.md) | 对外 API 契约(字段 / 错误码)。字段命名的唯一权威。 |
| [旷视具体需求.md](旷视具体需求.md) | 原始需求 |
| [../OFFLINE_ASSETS.md](../OFFLINE_ASSETS.md) | worker 离线模型权重/字体清单(内网部署)。 |
## 🖥️ 高性能 worker 机(GPU
跑完整 `app.py` + `face_analysis`,做真正的算法。
| 文档 | 作用 |
|------|------|
| [接口1-四庭七眼测量-技术实现方案.md](接口1-四庭七眼测量-技术实现方案.md) | 四庭七眼算法方案(MediaPipe / BiSeNet / 标定 / 标注图 / 误差) |
| [接口1-四庭七眼测量-开发任务书.md](接口1-四庭七眼测量-开发任务书.md) | worker 侧开发任务书(阶段一~十 + 精度验证),AI agent 执行 |
| [../OFFLINE_ASSETS.md](../OFFLINE_ASSETS.md) | 离线模型权重/字体清单(内网部署)。worker 需要这些权重 |
## 🚪 外网网关机
薄反向代理,不跑算法。
| 文档 | 作用 |
|------|------|
| [网关-开发任务书.md](网关-开发任务书.md) | 网关侧开发任务书(健康检查 / 派发 / 鉴权 / base64→URL / 部署),AI agent 执行 |
---
> 开发顺序建议:worker 先跑通(本机即可开发验证)→ worker 部署到 GPU 机 → 网关接入联调。网关在 worker 就绪前可用本地 stub worker 独立开发。
> 原先分散的「各接口技术方案 / 开发任务书 / 系统架构 / 网关任务书」已合并进 `实现说明.md`
> (细节可查 git 历史)。
+96
View File
@@ -0,0 +1,96 @@
# GPU Worker 改动说明
测试页面已新增字段,worker 侧需对应接收并使用。
## 涉及接口
| 接口 | 路径 | 新增字段 |
|------|------|----------|
| 接口 2C端生发) | `POST /api/v1/hair/grow` | `prompt` |
| 接口 3B端生发) | `POST /api/v1/hair/grow-b` | `prompt` |
## 1. 新增 `prompt` 参数
### 接口 2`/api/v1/hair/grow`
Handler 签名新增:
```python
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
```
透传链路:
```
handler → generate_grow_results(image, gender, use_mask, prompt)
→ comfyui.run(buf, prompt=prompt)
```
### 接口 3`/api/v1/hair/grow-b`
Handler 签名新增:
```python
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
```
透传链路:
```
handler → generate_grow_b(marked, use_mask, prompt)
→ comfyui.run(buf, prompt=prompt)
```
### `hairline/comfyui.py` 改动
1. 新增节点常量:
```python
_PROMPT_NODE = "60" # JjkText:提示词
```
2. `run()` 函数签名改为:
```python
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None) -> bytes:
```
3.`run()` 内部,注入图片和 seed 之后(约第81行后)加入:
```python
if prompt is not None:
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
```
### `hairline/service.py` 改动
两个服务函数各加一个 `prompt` 参数,透传给 `comfyui.run()`
- `generate_grow_results(image_bgr, gender, use_mask=True, prompt=None)` — 两处 `comfyui.run(buf.getvalue())` 都改为 `comfyui.run(buf.getvalue(), prompt=prompt)`
- `generate_grow_b(marked_bgr, use_mask=True, prompt=None)``comfyui.run(buf.getvalue())` 改为 `comfyui.run(buf.getvalue(), prompt=prompt)`
## 2. 已有字段回顾
`use_mask``prompt` 测试页面均已就绪,表单字段一览:
| 接口 | 字段 | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| 2 | `image_file` | file | — | 用户照片(三选一) |
| 2 | `image_url` | string | — | 同上 |
| 2 | `image_base64` | string | — | 同上 |
| 2 | `gender` | string | `female` | `male` / `female` |
| 2 | `use_mask` | bool | `true` | inpaint 遮罩开关 |
| 2 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
| 3 | `marked_image_file` | file | — | 划线图(三选一) |
| 3 | `marked_image_url` | string | — | 同上 |
| 3 | `marked_image_base64` | string | — | 同上 |
| 3 | `use_mask` | bool | `true` | 画发际线开关 |
| 3 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
## 3. 当前工作流提示词节点
`add_hair.json` 中节点 **60**`JjkText`)为提示词节点,当前硬编码文本:
> 补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头
`comfyui.run()` 收到 `prompt` 参数后将替换此文本。
Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

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

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

+177
View File
@@ -0,0 +1,177 @@
# 接口3 B端生发 — 实现文档
> 文档日期:2026-07-18
---
## 一、接口概述
**接口3** 是 B端(医生/操作端)生发接口。医生在用户照片上手动用马克笔画出发际线后,只需上传这一张划线图,系统自动检测划线 → 生成遮罩 → 送 ComfyUI 生发,返回「植发3个月」效果图。
**与接口2 的核心区别**
| 特性 | 接口2(C端生发) | 接口3(B端生发) |
|------|----------------|----------------|
| 输入 | 原始照片 | 划线图(含手绘线) |
| 发际线来源 | 系统按发型模板自动生成 | 医生手绘标注 |
| 发型类型 | ellipse/flower/heart/straight/wave | custom(自定义) |
| 中间步骤 | extract_context + swapHair + ComfyUI重绘 | 划线检测 + 遮罩 + ComfyUI生发 |
| 是否调 change_hair | 是(女性流程) | 否 |
| ComfyUI 工作流 | 0716add-hair-api.json(重绘) | add_hair.json(生发) |
| 典型耗时 | ~11s | ~6-8s |
---
## 二、接口定义
### 路由
```
POST /api/v1/hair/grow-b
```
### 入参
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `marked_image_file` | UploadFile | 三选一 | 划线图片文件(JPG/PNG) |
| `marked_image_url` | str | 三选一 | 划线图片 URL |
| `marked_image_base64` | str | 三选一 | 划线图片 base64 |
| `use_mask` | bool | 否(默认True) | 是否自动检测划线并建遮罩。False时跳过检测,直接送划线图 |
| `prompt` | str | 否 | ComfyUI 提示词,默认"补充遮罩区域的头发,加一点美颜" |
### 返回
```json
{
"code": 0,
"message": "success",
"data": {
"hair_growth_image_base64": "iVBORw0KGgo...(生发图 JPG base64",
"hairline_type": "custom"
}
}
```
错误码:
- `1001`: 无法识别人像 / 未检测到发际线划线
- `1007`: 处理失败
- `1008`: 图片格式不支持
---
## 三、完整调用链
```
POST /api/v1/hair/grow-b
├─ app.py hair_grow_b() [app.py:929]
│ ├─ resolve_image_bytes() → marked_raw 解析图片(file/url/base64三选一)
│ ├─ cv2.imdecode → marked_bgr 解码为 BGR
│ └─ run_in_threadpool(generate_grow_b, ...)
├─ service.py generate_grow_b(marked_bgr, use_mask, prompt) [service.py:381]
│ │
│ ├─ 步骤1:人脸检测 + 头发分割(仅 use_mask=True 时)
│ │ ├─ get_landmarker().detect(rgb) MediaPipe 478点人脸检测
│ │ │ → landmarks(无人脸返回 no_face
│ │ ├─ get_parser().parse(rgb) SegFormer 面部分割(CPU ~0.9s
│ │ │ → parse_mapint label map
│ │ │
│ ├─ 步骤2:手绘发际线检测(仅 use_mask=True 时)
│ │ ├─ detect_marker_hairline(marked_bgr, landmarks, parse_map)
│ │ │ │ [marker_detect.py:41]
│ │ │ ├─ forehead_upper_region(landmarks) 额头上部 ROI
│ │ │ ├─ head_silhouette(parse_map) 头部轮廓 ROI
│ │ │ ├─ _blackhat(gray) 黑帽变换(响应比邻域暗的细结构)
│ │ │ ├─ _snap_anchor(bh, 左鬓角21) 左锚点吸附
│ │ │ ├─ _snap_anchor(bh, 右鬓角251) 右锚点吸附
│ │ │ ├─ route_through_array(cost, 左, 右) Dijkstra最小代价路径
│ │ │ └→ path (N,2) row,col(拒识返回 None → no_line
│ │ │
│ │ ├─ path_to_curve_mask(path) 路径→曲线maskuint8 0/255
│ │ └─ mask_from_curve(curve_mask, landmarks, parse_map)
│ │ │ [mask.py]
│ │ ├─ _above_curve_region(curve_mask) 曲线以上区域
│ │ ├─ cv2.morphologyEx(闭运算) 填洞
│ │ ├─ 最大连通域
│ │ └─ 高斯羽化 → mask (uint8 0-255)
│ │
│ ├─ 步骤3:合成 RGBA PNG
│ │ ├─ compose_comfy_rgba(marked_bgr, mask) RGB=原图,alpha=255×(1-mask)
│ │ └─ PNG 编码 → rgba_png_bytes
│ │
│ └─ 步骤4ComfyUI 生发
│ └─ comfyui.run(rgba_png_bytes, prompt) [comfyui.py:87]
│ ├─ 上传图片到 ComfyUI /upload/image
│ ├─ 加载工作流 add_hair.json
│ ├─ 替换节点26输入图 + 节点6随机seed + 节点60提示词
│ ├─ POST /prompt 提交工作流
│ ├─ 轮询 /history/{prompt_id}(间隔0.2s
│ └─ GET /view 取回输出 PNG → grown_png
└─ 返回 {"grown_png": bytes, "status": "ok"}
```
---
## 四、用到的模型和外部服务
| 模型/服务 | 用途 | 位置 | 设备 |
|----------|------|------|------|
| **FaceLandmarker** (MediaPipe) | 478点人脸检测 | hairline/face_landmarks.py | CPU |
| **FaceParser** (SegFormer) | 面部分割(hair/skin/... | hairline/face_parsing.py | CPU (5090不兼容cu121) |
| **ComfyUI** (Flux-2) | 生发图生成 | hairline/comfyui.py → :8188 | GPU |
**注意**:接口3 **不调用** change_hair 服务(:8801),不需要 swapHair。这是它与接口2女性流程的关键区别。
---
## 五、核心算法:手绘发际线检测
### 5.1 为什么不用简单阈值?
手绘马克笔线条的灰度值与皮肤阴影、抬头纹等重叠,全局阈值无法区分。采用**黑帽变换 + Dijkstra最小路径**方案。
### 5.2 黑帽变换(Black Hat
```python
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
bh = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel)
```
黑帽 = 闭运算 − 原图,响应"比局部邻域暗的细结构"(即马克笔线条),对抬头纹/眉毛/发丝鲁棒。
### 5.3 Dijkstra 最小代价路径
1. **ROI 限定**:额头上部 ∩ 头部轮廓(排除背景)
2. **锚点**:左鬓角(21) / 右鬓角(251) MediaPipe 关键点
3. **代价图**`cost = (bh.max() - bh) + 1.0`ROI外设 1e6
4. **路径**`route_through_array(cost, 左锚, 右锚)` — skimage 的 Dijkstra 实现
### 5.4 拒识机制
路径平均黑帽响应 < 8.0 → 判定"未画线",返回 `no_line`
---
## 六、与接口1、接口2 的对比
| 维度 | 接口1 | 接口2 | 接口3 |
|------|-------|-------|-------|
| 功能 | 四庭七眼测量 | C端生发(5种发际线) | B端生发(手绘线) |
| 路由 | /api/v1/face/measure | /api/v1/hair/grow | /api/v1/hair/grow-b |
| 输入 | 正面照 | 正面照 | 划线图 |
| MediaPipe | ✅ | ✅ | ✅ |
| SegFormer | ✅ | ✅ | ✅ |
| change_hair | ❌ | ✅(女性) | ❌ |
| ComfyUI | ❌ | ✅(Flux-2重绘) | ✅(Flux-2生发) |
| 典型耗时 | ~2s | ~11s | ~6-8s |
| ComfyUI工作流 | — | 0716add-hair-api.json | add_hair.json |
---
## 七、测试
- **测试页面**[static/test_interface3.html](file:///home/ubuntu/hair/static/test_interface3.html)
- **测试图片**[image/girl_img/girl13.jpg](file:///home/ubuntu/hair/image/girl_img/girl13.jpg)(需手动在图上画发际线后作为划线图上传)
+7
View File
@@ -0,0 +1,7 @@
第一步 使用接口9 头发遮罩生成 的算法获取 mask
第二部 改造 /home/xsl/change_hair 换发型的工作流, 换发型的参考文档在这里 /home/xsl/change_hair/docs/换发型集成文档.md
1、原始换发型工作的遮罩用第一步算出来的遮罩
2、然后换发型得到遮罩区域发际线的图片。
3、严格按照遮罩区域把图片贴回到原图上面。
4、贴图的时候融合贴图边缘和原图的接缝,可以采用羽化算法或者渐变alpha混合的算法,目的就是边缘要和原图过渡自然。 这里通过传入各种参数可以控制选哪种算法和控制过渡细节。
最后一步返回生成特定样式的图片。
@@ -0,0 +1,115 @@
# 发际线生发遮罩算法(pushed 模式)
> 对应接口11 `/api/v1/hairline/grow`、接口12 `/api/v1/hairline/grow_v2`。
> 遮罩算法固定为 pushed;融合算法默认 multiband(多频段金字塔),接口11 可切换 seamless/two_stage/feather。
> 代码:`face_analysis/hairline_grow.py``_extract_hairline` / `_pushed_mask` / `compute_mask` / `_composite`)。
## 概述
pushed 是发际线生发的**唯一**遮罩算法。融合算法默认 multiband(多频段金字塔),接口11 暴露 `blend_method` 可切换为 seamless(泊松)/two_stage(泊松→多频段两段式)/feather(羽化),便于对比调优。它从头发分割结果中提取「头发/皮肤交界线」(发际线),以眉心为圆心逐点径向外推一段距离,与 baseline 组成闭合区域作为最终遮罩。这样遮罩顶部会覆盖现有头发下沿一小段,贴回生发结果时顶部与真头发重叠、过渡自然。
> 接口12 `/api/v1/hairline/grow_v2` 只需传 `image` + `hairline_id`,遮罩和融合全部固定,无需任何算法选择参数。
## 算法流程(5 步)
```
①-a baseline 分割线 ← 眉骨/glabella 关键点折线(含 151 中心点)
①-b 上半区 upper ← baseline 以上的区域(裁剪范围)
①-c 头发分割 hair_mask ← segformer/bisenet 的原始头发像素
①-f 头发内轮廓线 ← 取头发轮廓中朝脸一侧的那段(额头弧+两侧到下颌),有序折线
①-g 径向外推 + 成带 ← 以 151 点为圆心把内轮廓逐点向外推 push_px,内轮廓↔外推线之间的带 = 最终遮罩
```
> ①-d(填充到基线 top_fill)、①-e(闭合区域 closed)是旧 eroded/closed 模式的中间产物,pushed 模式不走这条流程,前端不展示。
### ①-a baseline 分割线
MediaPipe 人脸关键点 `[21,68,104,69,108,151,337,299,333,298,251]` 连成折线(左端 21 → 中心 151 → 右端 251),再向左右边缘水平延长。151 点(glabella/眉心)是后续径向外推的圆心。代码 `_baseline_points` / `_draw_baseline`
### ①-b 上半区 upper
baseline 折线以上的多边形区域(`_upper_region_mask`)。作为后续裁剪范围,保证遮罩不越界到下半脸。
### ①-c 头发分割 hair_mask
segformer(默认)或 bisenet 得到的头发二值掩码。
### ①-f 头发内轮廓线(核心改动)
代码 `_extract_hairline`。目标是提取「头发区域朝脸一侧的内轮廓线」:额头弧 + 左右两侧鬓角/脸颊边界,一直向下到下颌,是一条**有序折线**(不再是逐列一个 y 的数组,因为两侧近乎竖直、一个 x 对多个 y)。
1. **取头发轮廓**`hair_mask` 最大连通域,`cv2.findContours(RETR_EXTERNAL, CHAIN_APPROX_NONE)` 取稠密、保序的外轮廓点。
2. **内侧判定**:轮廓同时含「朝背景的外侧剪影」和「朝脸的内轮廓」。对每个轮廓点,朝脸中心 151 方向采样 `sample_px`(≈0.4cm)像素,落点若是**非头发**像素 → 该点朝向脸(内轮廓);否则是外侧剪影,丢弃。
3. **取最长连续内侧段**:内轮廓点在闭合轮廓上本是一段连续弧,先做 1D 环形闭运算填掉判定抖动的小缝,再取最长连续 True 段并保序。
4. **下颌截断**:丢掉 y > `chin_y`(下巴关键点 152 的 y)的点,把两侧末端截到下颌一带 → 得到「环脸」内轮廓弧。
**关键点**:不再用 baseline 做水平截断、也不再逐列取下沿;截断改为「朝脸内侧」判定 + 下颌 y 截断,因此能同时拿到额头弧和两侧竖直边界。
### ①-g 径向外推 + 闭合区域(最终遮罩)
代码 `_pushed_mask`
1. **逐点径向外推**:圆心 = 151 点 (cx, cy)。对内轮廓上每个点 (x, y),沿「从圆心指向它」的单位向量 `(ux, uy)` **向外**(远离脸中心 = 推进现有头发)外推 `push_px`,得到外推线(黄线)点 `(x + ux·push_px, y + uy·push_px)``push_px = hairline_push_cm × px_per_cm`(默认 1cm)。
2. **逐列归并**:只取外推线中落在 baseline 以上的点,逐列取最靠上的 y 作为遮罩顶界 `pushed_y[x]`;空列线性插值填补。两侧鬓角落到 baseline 以下的段落自然被排除。
3. **与 baseline 组闭合区域**:逐列从 `pushed_y[x]` 填充到 `baseline_y[x]`(仅 `pushed_y < baseline_y` 的列),`& upper` 去越界、`_largest_cc` 保留最大连通域。
最终遮罩 = **外推发际线(①-g 黄线)与 baseline 分割线(①-a)组成的闭合区域**:顶界=外推发际线(覆盖现有头发约 push_cm),底界=baseline。与旧逻辑一致,区别只是 `pushed_y` 现在来自修正后的整条内轮廓,额头弧已延伸到两侧鬓角,额头遮罩宽度不再被截短。
## 关键参数
遮罩算法(pushed)固定。融合算法接口11 通过 `blend_method` 可切换(默认 multiband),其余融合参数均可调:
| 参数 | 默认 | 说明 |
|---|---|---|
| `hairline_push_cm` | 1.0 | 内轮廓径向外推距离(厘米),= push_px / px_per_cm。`px_per_cm` 由虹膜直径标定 |
| `hairline_edge` | `column` | 兼容保留的入参;内轮廓提取(轮廓+内侧判定)不再按它分支,取值不影响结果 |
| `mb_levels` | 5 | 多频段金字塔层数(2~6,越大低频色差抹得越宽)|
| `blend_method` | `multiband` | 接缝融合:multiband(多频段金字塔) / seamless(泊松) / two_stage(泊松→多频段,大色差) / feather(羽化) / alpha_gradient。接口12 固定 multiband |
| `color_match` | `true` | 融合前 Reinhard 颜色迁移消除整体色差(multiband/feather/alpha_gradient 生效;seamless/two_stage 自带调色故跳过)|
| `color_match_strength` | 1.0 | 颜色迁移强度(0~1<1 只迁移部分,防 Reinhard 过度改色)|
| `mb_feather_px` | 1 | 多频段最细层掩码轻羽化像素(0=不羽化),消除发丝边缘锯齿 |
| `transition_band_px` | -1 | keep-region 过渡带边距(-1=自动按层数 `2**n`;>=0 用绝对像素与层数解耦)|
| `edge_erode_px` | 3 | 贴图前遮罩内缩像素(防边缘露皮/光晕)|
| `erode_cm` | 0.6(接口12 固定)| baseline 参考内缩距离,对 pushed 影响很小 |
| `redraw` | `false` | 发际线带重绘开关:开启后用 final(④融合图)在「外推线↔发际线」带重绘,swapHair/Flux-2 两路对比,结果单独展示(不替换 final)|
| `inpainting_fill` | 1 | change_hair 重绘填充:0=保留原图(治染绿) / 1=填充噪声(默认) / 2=纯色 / 3=潜变量噪声 |
| `mask_blur` | 11 | change_hair 遮罩边缘模糊像素(越大颜色越易从边缘渗透)|
| `mask_dilate_scale` | 1.0 | change_hair 遮罩膨胀核缩放(1.0=原始,<1 收缩防越界)|
| `comfyui_prompt` | `null` | redraw Flux-2 路提示词,null 用默认「补充遮罩区域的头发,加一点美颜」|
> 接口12 `/api/v1/hairline/grow_v2` 只需传 `image` + `hairline_id`,遮罩和融合全部用默认值(multiband + color_match=true),不暴露算法选择参数。
### 融合方法选择建议
- **multiband**(默认):常规首选。低频抹色差、高频保发丝。需配合 `color_match=true` 消除整体色差。
- **two_stage**:生成图与原图色差大时用。先泊松克隆统一色调,再多频段贴细节,兼顾调色与保发丝。比纯 seamless 更不易溢色。
- **seamless**:纯泊松梯度域调和,色调统一干净,但可能整体改色/边缘溢色。
- **feather / alpha_gradient**:单层 alpha 过渡,最轻量,但过渡带内色差不会被抹平,仅适合色差极小的场景。
## 发际线带重绘(redraw,接口11 可选)
`redraw=true` 时,在主流程(④接缝融合 final)之后额外跑一条重绘分支,结果单独展示(`steps.redraw_a` / `redraw_c`),**不替换** final。
**重绘区域** = ①-g 外推发际线(`outer_pts`)与 ①-f 发际线(`inner_pts`)两条折线端点相连组成的带状闭合区域(宽度 ≈ `hairline_push_cm`,只覆盖发际线交界处)。
**两路后端对比**(输入图 + 融合基底都用 final):
- **swapHair 路**`redraw_a`):final + 带遮罩调 change_hair → final 走 multiband 融合
- **Flux-2 路**`redraw_c`):final + 带遮罩调 ComfyUI`hair_repaint.json` 工作流)→ final 走 multiband 融合。Flux-2 经 reference latent + ColorMatch 双重保色,**不易染绿**
> `inpainting_fill` / `mask_blur` / `mask_dilate_scale` 透传 change_hair 服务端(仅影响 swapHair 路)。`comfyui_prompt` 仅影响 Flux-2 路。
> 两路独立容错:任一路失败只跳过该路,不影响另一路和主 final。
> ⚠️ Flux-2 路需 ComfyUI8188)在跑;swapHair 路需 change_hair8801)在跑。
## 与旧模式(eroded/closed,已移除)的区别
| | eroded/closed(已移除) | pushed(当前唯一) |
|---|---|---|
| 遮罩来源 | top_fill(头发向下填充含额头)外缘内缩 | 头发内轮廓线 径向外推成带 |
| 截断方式 | 用 baseline 组上半区 upper | 内侧判定 + 下颌 y 截断(不再用 baseline |
| 遮罩形状 | 整个额头闭合区域 | 沿内轮廓的环脸带(额头弧+两侧,压住现有头发 push_cm) |
## 调试
- 调试页:`http://<host>:8187/static/test_interface11_debug.html`(带前后端日志面板、下载日志按钮)
- 后端日志:`/home/xsl/hair/log/hairline_grow.log`(按 `[rid]` 关联一次请求),下载接口 `/api/v1/debug/hairline_log?rid=<id>&tail=500`
- 可视化步骤:①-a baseline / ①-b upper / ①-c 头发分割 / ①-f 交界线 / ①-g 外推+遮罩 / 最终遮罩 / 生成 / 贴回 / 融合
+118
View File
@@ -0,0 +1,118 @@
# 旷视五接口 — 实现说明(总)
> 把原先分散的「各接口技术方案 + 开发任务书 + 系统架构 + 网关任务书」合并成这一份**简要总览**。
> 对外 API 契约以 [`接口文档.md`](接口文档.md) 为唯一权威;原始需求见 [`旷视具体需求.md`](旷视具体需求.md);
> 离线模型清单见 [`../OFFLINE_ASSETS.md`](../OFFLINE_ASSETS.md)。
---
## 1. 架构
两台机器、一个仓库:
```
客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py)
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI)
└ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/6/7
```
- **worker**`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,**纯本地、无外网依赖**。
`/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json``accept_passwords`);
`/health` 模型就绪才返回 200。监听 **8187**`./start.sh` 控制开关,`./run_worker.sh` 热重载)。
- **网关**`gateway/`):薄反向代理,健康检查/派发/鉴权/把 worker 的 `*_base64` 落盘改成 `*_url`
唯一例外是**接口4 在网关本机直接实现**(调外网豆包,不转发 worker)。
- **图片三选一**:所有接口图片入参 `image_file`/`image_url`/`image_base64` 严格三选一(接口3 是 `marked_image_*`)。
- **响应信封**`{code, message, request_id, data}`;业务错误用 `code`HTTP 一律 200)。
### base64 → URL 映射(网关落盘改写,递归进数组、可空保留 null)
| 接口 | worker 字段(内部) | 对外字段 |
|------|--------------------|----------|
| 1 | `annotated_image_base64` | `annotated_image_url` |
| 6 | `annotated_image_base64` | `annotated_image_url` |
| 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
| 3 | `hair_growth_image_base64`(可空) | `hair_growth_image_url` |
| 5 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`(可空) | `hairline_images[].image_{middle,high,low}_url` / `grown_image_url` |
| 7 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
> **图片格式**:接口1 标注图含透明用 **PNG**;接口2/3/5 是不透明照片用 **JPG**(小很多,~9×)。
> 网关落盘按内容嗅探扩展名(PNG 头→`.png`,否则 `.jpg`)。
### 错误码
`1001` 无法识别人像 `1002` 分辨率过低 `1003` 非正面 `1004` gender 必填/非法(接口2/5/7)|
`1006` >1MB `1007` 图片参数错误(0或多个)/未预期异常 | `1008` 格式不支持。
---
## 2. 五个接口实现简述
### 接口1 四庭七眼测量 `/api/v1/face/measure`worker
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、一张透明底标注 PNG。
- **怎么实现**`face_analysis/`):MediaPipe Face Mesh 468+虹膜点 → solvePnP 姿态校验(非正面 1003) →
虹膜直径法定标(px→cm) → **眉心以下实测****眉心以上**用 BiSeNet 头发分割取真实发际线/头顶(方案B),
失败回退比例推算(方案A`hairline_source` 透出)。标注图 numpy 向量化渐变线 + 思源黑体。返回 `annotated_image_base64`
- 门槛可配:`MIN_SHORT_SIDE`/`MIN_LONG_SIDE`(默认600/800)、姿态阈值 `FRONTAL_*_THR`(默认30°)。
### 接口6 四庭七眼测量 v2 `/api/v1/face/measure-v2`worker)—— 接口1 的去顶庭变体
- **做什么**:基于接口 1,**去顶庭**:不画头顶横线、不返回顶庭数据(`four_courts` 仅上/中/下庭,`landmarks``hair_top``face_total_height_cm` 为三庭之和)。
- **与接口1 的标注差异**`create_annotated_image(variant="v6")`):①竖线范围改为发际线→下巴尖;②不画人头最左/最右端线(仅七眼 6 点 5 段,接口1 为 8 线 7 段);③左侧只标上/中/下庭。箭头/虚线/字体等与接口1 一致。
- **怎么实现**:与接口 1 共用 `_face_measure_impl(variant="v6")`;v6 时标注走变体分支、数据由 app.py 边界删顶庭字段并重算三庭比例。
- **网关改动**:新增路由 `POST /api/v1/face/measure-v2`,转发到 worker 同路径;base64→URL 改写无需改动。
### 接口2 C端生发 `/api/v1/hair/grow`worker)—— 预览 + 生发图
- **做什么**:正面照 + `gender`(必填) + `hair_style`(必填,逗号分隔多选,如 `1,2,3`) → 指定发际线类型 **N 组**:**预览图**(发际线叠在照片上) + **生发后图**(植发3个月效果)。
- **怎么实现**`hairline/`):移植 head3d——MediaPipe(Tasks) + SegFormer 分割 + 17 锚点射线检测 → 502 点 mesh,
`face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182**
`add_hair.json`(Flux-2) 出图。**关键坑**obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
返回 `results[].image_base64` + `grown_image_base64`
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave 6=bigflower 7=clasicalflowermale 1=ellipse 2=inverse_arc 3=m 4=straight 5=heart 6=Softpetal。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线;female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线。
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`worker)—— 接口2同款,add_hair2 工作流
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` 逗号分隔多选 → N 组预览+生发图)。
- **与接口 2 的唯一区别**:ComfyUI 工作流使用 `add_hair2.json`Flux-2 Klein 9b),输入/遮罩节点同为 26,
SaveImage 输出节点为 75(自动检测)。其他参数、响应结构、错误码**完全相同**。
- **网关改动**:在 `gateway/app.py` 新增路由 `POST /api/v1/hair/grow-v2`,转发到 worker 同路径即可(盲转发,
base64→URL 改写逻辑无需改动,数组内图片字段已覆盖)。详见 [`网关待改动.md`](网关待改动.md)。
### 接口3 B端生发 `/api/v1/hair/grow-b`worker)—— 马克笔发际线
- **做什么**:医生用马克笔在额头画好发际线,**只传这一张划线图** → 检测线 → 生发图。输出 `hair_growth_image_url` + `hairline_type="custom"`
- **怎么实现**:检测算法源自 headmark——**黑帽响应图 + 鬓角锚点(MediaPipe 21/251) + Dijkstra 最小路径**(scikit-image)
比全局阈值鲁棒;路径平均响应过低→拒识(1001)。检测路径建遮罩,划线图原样送 ComfyUI(提示词清除黑线)。
### 接口4 用户特征 `/api/v1/face/features`**网关本机**
- **做什么**:照片 → 几十项面部特征(脸型/眉形/肤色/三庭五眼/四季色彩季型/量感/基因风格/性别…)。`data.features` 是 JSON 字符串。
- **怎么实现**`gateway/`,逻辑参考 worker `face_features.py` / `/home/xsl/fuyan`):调**火山方舟 豆包视觉模型**
`doubao-seed-1-6-vision`(OpenAI 兼容,base64 data URI 喂图),解析 JSON + 映射 6 个英文优先字段并保留全部中文。
无人脸→1001。**唯一调外网的接口**:网关需可达 `ark.cn-beijing.volces.com`API Key 走网关配置(不入 git)。
### 接口5 发际线PNG生成 `/api/v1/hairline/generate`worker
- **做什么**:入参同接口2`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
- **怎么实现**:复用接口2 的 502 点渲染管线,三档分别用 `hairline_texture[/_high|/_low]` 同名贴图渲染叠图;生发同接口2(ComfyUI inpaint),**黑模板固定取 `hairline_texture_black/`(middle)**,每发型 1 张生发图。`best_hairline_center_point`=眉心 x × 首个选中发型 middle 档发际线 y。
---
## 3. 部署 / 环境要点
**workerGPU 机)**
- Python **3.12**(系统 3.13 无 mediapipe/torch wheel);venv 在 `./venv`,依赖 `requirements.txt`
- `numpy<2`(1.26.4)`scikit-image==0.24.0`**别升 0.25+,会顶 numpy≥2 顶崩 mediapipe**)。
- ⚠️ 本机 **RTX 5090(sm_120)**pinned `torch 2.2.2(cu121)` 只到 sm_90 → GPU 算子报 "no kernel image"
代码已自动**回退 CPU**BiSeNet/SegFormer CPU 推理可用)。要用 5090 GPU 需换 torch cu128(≥2.7)。
- 模型权重/字体见 [`../OFFLINE_ASSETS.md`](../OFFLINE_ASSETS.md)BiSeNet/SegFormer/face_landmarker.task 本地。
- 生发接口依赖本机 **ComfyUI(8188)**Flux-2,它自带支持 5090 的 torch);worker 只调其 HTTP API,不跑 Flux。
ComfyUI 开了 **HTTP Basic Auth**user `admin` + 密码);密码放 `password.txt`(不入 git) /
`worker_config.json.comfyui_password` / 环境变量 `COMFYUI_PASSWORD`URL 用 `COMFYUI_URL`
- `worker_config.json`(不入 git)`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token`
**网关机**
- 很轻:FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
- 不装 torch/mediapipe/opencv。配置 `gateway/config.json`(不入 git)`workers` 列表、`shared_password`
`ark` 的 api_key/base_url/model、`public_base_url`、超时(**生发接口慢,`request_timeout_seconds` 调大 ≥120s**)。
- 托管 `/static/annotations/`(落盘的图),定期清理。
---
> 维护:本文为简要总览;字段以 `接口文档.md` 为准。各接口更细的算法推导可查 git 历史中已合并的旧技术方案文档。
@@ -1,498 +0,0 @@
# 接口 1:四庭七眼测量 — 开发任务书(worker 侧 · AI Agent 执行版)
> **在高性能 worker(GPU 机)上开发。** 本任务书只负责 worker 侧算法逻辑。
> 配套技术方案:[`接口1-四庭七眼测量-技术实现方案.md`](接口1-四庭七眼测量-技术实现方案.md)
> 系统架构(两端共享契约,先读):[`系统架构-网关与高性能后端.md`](系统架构-网关与高性能后端.md)
> **网关侧在另一台机器开发,有独立任务书**:[`网关-开发任务书.md`](网关-开发任务书.md),本机不涉及。
> 执行者:AI coding agent。请**严格按阶段顺序**执行,每个阶段完成后运行该阶段的「验证方法」,**通过后再进入下一阶段**。
> 📌 **本机职责**worker 跑完整 `app.py` + `face_analysis`,用 GPU。与单机版的两处关键差异:
> - ① torch 用 **GPU(CUDA)** 版;
> - ② 阶段八 handler **返回 `annotated_image_base64`,不保存到 static、不拼 URL**(落盘改 URL 由网关做)。
> - 接口文档(对外契约)**完全不变**。
---
## 0. 背景与目标
把现有 `/api/v1/face/measure` 接口从 **Mock**(返回硬编码数据)替换为**真实算法实现**。
- 输入:单人正面人像图(multipart 上传 / URL / base64,三选一,≤1MB)。
- 输出:四庭(顶/上/中/下庭)、七眼(眼宽/脸宽/两眼间距)的 cm 值与占比,5 个关键点像素坐标,以及一张**仅含标注图层、透明底**的 PNG。
- 保持现有统一响应结构 `{code, message, request_id, data}` 与错误码 10011008 不变。
**核心算法策略**(见技术方案 §1.1):
- 眉心以下(中/下庭、七眼):MediaPipe Face Mesh 468 点直接实测。
- 眉心以上(上/顶庭,即发际线/头顶):**方案 B(BiSeNet 头发分割,主)** → **方案 A(比例推算,兜底)**
- 尺度换算:虹膜直径法(11.7mm)。
- 姿态校验:`cv2.solvePnP` 解算真实欧拉角。
---
## 1. 总体约束(所有阶段通用)
1. **不破坏现有接口契约**:响应外层结构、错误码、三选一图片输入规则、`ok()`/`err()` 帮助函数沿用 `app.py` 现有实现。
2. **新增逻辑全部放在 `face_analysis/` 包内**`app.py` 只做编排(读图→校验→调用→返回),保持单文件 app 的薄控制器风格。
3. **依赖锁版本**`numpy<2`(用 1.26.4)。torch 用 CPU 版。安装走 `pip.conf` 里的腾讯云镜像(torch 需用官方 CPU index)。
4. **模型权重不入 git**`face_analysis/weights/*.pth` 写进 `.gitignore`,由 §2 的下载脚本拉取。
5. **中文字体**`face_analysis/fonts/NotoSansCJKsc-Regular.otf`= 思源黑体,已预下载,见 `OFFLINE_ASSETS.md`)。
6. **每个模块都要能单独 import 且有 `if __name__ == "__main__"` 自测入口**,方便分阶段验证。
7. 代码风格、注释密度与 `app.py` 保持一致;注释用中文。
8. **不要 mock 兜底**:算法失败时返回对应错误码,**不得**回退成硬编码示例数据。
---
## 2. 阶段一:环境与依赖
**开发步骤**
1. 更新 `requirements.txt`,新增:`mediapipe==0.10.14``opencv-python==4.10.0``Pillow==11.0.0``numpy==1.26.4``torch==2.2.2``torchvision==0.17.2`
2. 在 venv 安装依赖。torch 用 CPU index
`./venv/bin/pip install torch==2.2.2 torchvision==0.17.2 --index-url https://download.pytorch.org/whl/cpu`
其余走现有 `pip.conf` 镜像。
3. 创建目录骨架:`face_analysis/{__init__.py,fonts/,weights/}``static/annotations/`
4. **权重/字体已预下载到位**(内网无需联网,见根目录 `OFFLINE_ASSETS.md` 的 sha256 清单):
- `face_analysis/weights/79999_iter.pth`BiSeNet 主权重 ~53MB
- `face_analysis/weights/resnet18-5c106cde.pth`(骨干 ~45MB
- `face_analysis/fonts/NotoSansCJKsc-Regular.otf`(中文字体 ~16MB
仍需写 `scripts/download_weights.sh`(供联网环境/生产机重建),但内网执行时跳过此步、直接用已有文件。
⚠️ **resnet18 骨干**:BiSeNet 初始化会尝试联网下载骨干,内网会失败——需把 `resnet18-5c106cde.pth` 拷到 `~/.cache/torch/hub/checkpoints/` 或改 BiSeNet 代码从 `weights/` 本地加载。
5. 更新 `.gitignore`:忽略 `face_analysis/weights/*.pth``static/annotations/*`(保留 `.gitkeep`)。
**交付物**
- 更新后的 `requirements.txt``.gitignore`
- `scripts/download_weights.sh`
- 目录骨架
**验证方法**
```bash
./venv/bin/python -c "import mediapipe, cv2, torch, numpy, PIL; \
print('numpy', numpy.__version__); print('mediapipe', mediapipe.__version__)"
```
- 必须无 import 错误;`numpy.__version__``1.26` 开头。
- `ls face_analysis/weights/79999_iter.pth` 存在且 >40MB`resnet18-5c106cde.pth` 存在。
- `ls face_analysis/fonts/NotoSansCJKsc-Regular.otf` 存在。
**完成标准**:上述命令全部通过,无报错。
---
## 3. 阶段二:MediaPipe 关键点检测封装
**开发步骤**
1. `face_analysis/face_mesh_landmarks.py`:定义所有关键点索引常量(见技术方案 §2.3):眉心 9/151、鼻翼下缘 94、下巴 152、眼角 33/133/263/362、脸颊 234/454、鼻尖 1/4、虹膜 468477、solvePnP 用的 61/291。
2. `face_analysis/detector.py`:实现 `FaceMeshDetector` 单例(技术方案 §8.2),`static_image_mode=True, max_num_faces=1, refine_landmarks=True``detect(image_bgr)` 返回 landmarks 或 None。
**交付物**`face_mesh_landmarks.py``detector.py`
**验证方法**
- 准备一张正面人像测试图 `tests/fixtures/frontal.jpg`(agent 若无素材,用一张公开 CC0 正面人像;记录来源)。
- 自测脚本:加载图 → `detector.detect()` → 断言返回非 None 且 landmark 数 ≥ 478(含虹膜)。
```bash
./venv/bin/python -m face_analysis.detector tests/fixtures/frontal.jpg
# 期望输出:detected landmarks: 478
```
**完成标准**:能稳定检测出 478 点。
---
## 4. 阶段三:姿态校验(solvePnP
**开发步骤**
1. `face_analysis/pose.py`:实现 `estimate_head_pose(landmarks, w, h)` 返回 `(yaw, pitch, roll)``check_frontal_face(...)` 返回 bool(技术方案 §9)。
2. 阈值用初始值 15°,定义为模块常量便于后续标定。
**交付物**`pose.py`
**验证方法**
- 用正面图:`check_frontal_face` 返回 True,三个角绝对值均 < 15。
- `hard_longhair.jpg` 略带角度,打印其 yaw/pitch/roll,确认角度比 frontal 大(用于观察姿态评分是否合理)。
- 仓库未提供明显侧脸图;若要测 `frontal=False` 的拒绝路径,agent 自备一张明显侧脸图存为 `tests/fixtures/profile.jpg`(公开 CC0,记录来源),否则在测试中用 mock landmarks 构造大 yaw 验证阈值逻辑。
```bash
./venv/bin/python -m face_analysis.pose tests/fixtures/frontal.jpg # frontal=True, 三角接近 0
./venv/bin/python -m face_analysis.pose tests/fixtures/hard_longhair.jpg # 打印角度,观察是否偏大
```
**完成标准**:正面图判定为 True 且三角接近 0;阈值拒绝逻辑(大 yaw→False)有测试覆盖。
---
## 5. 阶段四:尺度校准(虹膜直径法)
**开发步骤**
1. `face_analysis/calibration.py`
- `normalized_to_pixel``pixel_distance`(技术方案 §3.2)。
- `estimate_scale_factor(landmarks, w, h)` 返回 `px_per_cm`,用虹膜左右边缘点(469/471、474/476)求直径,左右取平均,除以 `AVG_IRIS_DIAMETER_CM=1.17`(技术方案 §3.3)。
- 虹膜点缺失时降级用眼宽(外→内眼角,均值 2.85cm)。
**交付物**`calibration.py`
**验证方法**
- 自测:对正面图算 `px_per_cm`,断言为正且落在合理范围(例如 1080×1920 的人像,px_per_cm 通常在 20120 之间,agent 实测后记录实际值作为回归基线)。
```bash
./venv/bin/python -m face_analysis.calibration tests/fixtures/frontal.jpg
# 期望输出:px_per_cm: <正数>
```
**完成标准**:输出正数且量级合理;故意传一张无虹膜(refine 关闭模拟)能走眼宽降级不报错。
---
## 6. 阶段五:头发分割(方案 B)+ 兜底(方案 A)
**开发步骤**
1. `face_analysis/hair_segmenter.py`
- 封装 BiSeNet face-parsing:加载 `weights/79999_iter.pth`,输入 BGR 图,输出 `hair_mask`H×W boolTrue=头发)。预处理 resize 到 512×512,推理后 resize 回原图尺寸。CPU 推理。单例加载,避免每次请求重载权重。
- `locate_hairline_by_segmentation(hair_mask, brow_center_x, h)` 返回 `(hairline_y, hair_top_y)` 或 None(技术方案 §4.0)。
2. `face_analysis/measure.py`(先做方案 A 部分):
- `estimate_vertical_landmarks(...)`(方案 A,技术方案 §4.3)作为兜底。
3.`measure.py` 里实现**决策逻辑**:先尝试方案 B,合理性校验(头顶在发际线上方、发际线在眉心上方、各庭为正)通过则用 B 并标 `hairline_source="segmentation"`,否则回退 A 标 `"estimated"`(技术方案 §4 决策流程)。
**交付物**`hair_segmenter.py``measure.py`(含纵向定位 + 决策)
**验证方法**
- 自测分割:对 `frontal.jpg` 输出 `hair_mask`,断言 `hair_mask.sum() > 0`dump 一张 mask 预览 PNG 到 `tests/output/`,目视确认头发区域正确。
- 自测定位:方案 B 返回的 `hairline_y < brow_center_y`(发际线在眉心上方,y 向下为正)、`hair_top_y < hairline_y`
- **困难样本** `hard_longhair.jpg`:长发遮挡两侧,确认要么中分缝定位合理、要么合理性校验不过自动回退方案 A(`hairline_source=="estimated"`),**两种都算通过,关键是不报错、不输出离谱坐标**。
- **降级路径**:把 mask 置空(`None`)模拟光头/分割失败,断言决策回退方案 A、`hairline_source=="estimated"`、不报错。
```bash
./venv/bin/python -m face_analysis.hair_segmenter tests/fixtures/frontal.jpg
# 期望:hair pixels: <正数>, hairline_y < brow_y, hair_top_y < hairline_y
./venv/bin/python -m face_analysis.hair_segmenter tests/fixtures/hard_longhair.jpg
# 期望:能跑通,输出分割结果或明确的回退标记
```
**完成标准**:正常头发图走分割且坐标自洽;长发/无头发图自动降级不报错。
---
## 7. 阶段六:四庭七眼测量计算
**开发步骤**
1. `measure.py` 补全:
- `measure_seven_eyes(...)`(技术方案 §5):眼宽(左右均值)、脸宽、两眼间距像素值。
- 整合主函数 `measure_face(landmarks, hair_mask, w, h)`
- 调 §4 决策得 5 个纵向点 + 各庭像素长。
- 调七眼测量。
-`estimate_scale_factor` 得 px_per_cm,全部像素 → cm。
- 算占比:四庭各段/全脸高,眼宽/脸宽、间距/脸宽。
- 返回结构化结果对象(含 cm、ratios、5 点像素坐标、hairline_source、head_pose)。
2. 结果对象提供 `to_response()` 方法,输出与现有 Mock 的 `data` 字段**完全同构**(字段名对齐 `docs/接口文档.md`)。
**交付物**:完整 `measure.py`
**验证方法**
- 对正面图跑 `measure_face`,断言:
- 四庭 ratio 之和 ≈ 1.0(±0.02)。
- 所有 cm 值为正且量级合理(全脸高度通常 18–24cm)。
- 眼宽 ratio 在 0.150.25 之间(七眼理论 ≈ 0.2)。
- 返回字段名与 `docs/接口文档.md` 定义逐一对齐(写一个字段对比断言)。
```bash
./venv/bin/python -m face_analysis.measure tests/fixtures/frontal.jpg
# 打印完整 data dict
```
**完成标准**:数值自洽、字段对齐文档。
---
## 8. 阶段七:标注图生成
**开发步骤**
1. `face_analysis/annotation.py`(技术方案 §6):
- 用打包中文字体绝对路径加载(**不静默降级**,缺字体直接抛错)。
- `draw_gradient_horizontal_line`:**numpy 向量化**实现(技术方案 §6 修订版),全程在 `np.zeros((h,w,4))` 缓冲上画,最后 `Image.fromarray`
- 四庭水平分界线(渐变消失)+ 左侧四庭 cm 数值。
- 七眼标注(上下穿插)。
- 虚线带箭头 `draw_dashed_line_with_arrows`
- 规格:线/字色 `#FFFFFF`、字体 10pt、线宽 1pt、透明底 RGBA。
2. `create_annotated_image(image_bgr, measure_result)` 返回 PIL RGBA Image。
**交付物**`annotation.py`
**验证方法**
- 对正面图生成标注 PNG,保存到 `tests/output/annotated.png`,断言:
- 模式为 `RGBA`,尺寸 == 原图尺寸。
- 存在透明像素(A==0)也存在不透明像素(A>0)。
- 中文渲染正常(人工/agent 目视 dump 图,确认"顶庭/上庭/中庭/下庭"非方块)。
- 性能:生成耗时记录,单张应 < 1s(验证 numpy 渐变线没有退化成逐像素)。
```bash
./venv/bin/python -m face_analysis.annotation tests/fixtures/frontal.jpg tests/output/annotated.png
```
**完成标准**:PNG 透明底正确、中文正常、生成快。
---
## 9. 阶段八:接入 app.py
**开发步骤**
1.`app.py`**worker 侧**)替换 `/api/v1/face/measure` 的 Mock 实现:
- 解析三选一图片输入(沿用现有 URL/base64/file 处理;URL 需下载,base64 需去前缀解码)。
- 校验:大小 ≤1MB(1006)、可解码(1008)、分辨率用**短边/长边**判断(1002,技术方案 §8.3 修订版)。**门槛做成可配置**:读环境变量 `MIN_SHORT_SIDE`(默认 600)、`MIN_LONG_SIDE`(默认 800),不要硬编码(见 §14)。
- `detector.detect` → None 则 1001。
- `check_frontal_face` → False 则 1003。
- `hair_segmenter` 取 mask(失败传 None,由 measure 内部兜底)。
- `measure_face``create_annotated_image`
- **⚠️ 拆分架构:返回 base64,不落盘不拼 URL**。`data["annotated_image_base64"] = base64(png)``return ok(data)`。落盘成 `annotated_image_url` 由网关完成(见架构文档 §9)。**worker 不写 static、不拼 hair.xiangsilian.com URL**。
2. 模型单例在模块加载时初始化(detector、segmenter),避免每请求重建;BiSeNet `.to('cuda' if available)`
3. **鉴权中间件**worker 增加校验 `X-Internal-Token` 的依赖/中间件,密码来自 worker 配置文件 `accept_passwords` 列表,不匹配返回 HTTP 401(架构文档 §7)。`/health` 不校验(供网关探测)。
4. 异常兜底:未预期异常返回 `err(1007, ...)`
**交付物**:更新后的 `app.py`
**验证方法**(本地起 worker
> 默认门槛已是 600/800`frontal.jpg` 直接放行。worker 已加鉴权,需带 `X-Internal-Token` 头(值取 worker 配置的密码;本地测试可设一个测试密码)。
```bash
./venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 &
F=http://127.0.0.1:8000/api/v1/face/measure
H="X-Internal-Token: testpass" # 与本地 worker 配置一致
# 0) 正常图 → code==0data 含 four_courts/seven_eyes/annotated_image_base64/hairline_source/head_pose
curl -s -H "$H" -X POST $F -F image_file=@tests/fixtures/frontal.jpg | python -m json.tool
# 1002/1001/1008/1006 同样带 -H "$H"
curl -s -H "$H" -X POST $F -F image_file=@tests/fixtures/lowres.png # 1002
curl -s -H "$H" -X POST $F -F image_file=@tests/fixtures/landscape.jpg # 1001
curl -s -H "$H" -X POST $F -F image_file=@tests/fixtures/corrupt.bin # 1008
head -c 1100000 /dev/urandom > /tmp/oversize.bin
curl -s -H "$H" -X POST $F -F image_file=@/tmp/oversize.bin # 1006
# 鉴权) 不带 token → HTTP 401
curl -s -o /dev/null -w "%{http_code}\n" -X POST $F -F image_file=@tests/fixtures/frontal.jpg # 401
# 1003) 侧脸:仓库无素材,用 mock 大 yaw 在单测中覆盖
```
- 正常用例 data 含 `annotated_image_base64`**不是** URL——落盘改 URL 由网关做,见网关任务书);base64 解码后是合法 PNG。
- 不带 token 返回 401`/health` 不需要 token。
- `/docs` Swagger 正常加载,该接口 schema 未破坏。
**完成标准**0/1002/1001/1008/1006 五类用例返回正确 code(有现成夹具);1003 用 mock 覆盖;正常用例 data 结构与文档一致。
---
## 10. 阶段九:测试套件与回归
**开发步骤**
1. `tests/test_face_measure.py`pytest):
- 各模块单元测试(detector/pose/calibration/segmenter/measure/annotation)。
- 接口集成测试:用 FastAPI `TestClient` 跑 §9 的错误码用例。
- **精度验证:见 §15 三层策略(合成真值 / 缩放不变性 / 可视化)**——这是误差验证的核心,必做。
- 数值回归:把 `frontal.jpg` 首次跑出的四庭/七眼 cm 值记为基线,断言后续运行偏差 < 1%(防止重构回归)。
2. `tests/fixtures/` 素材已就位(见 §14),无需再准备。
3.`docs/接口1-四庭七眼测量-技术实现方案.md` §11 待确认事项旁,补一份「实测基线数值表」。
**交付物**`tests/` 目录、`pytest.ini`(或 pyproject 配置)、基线数值表
**验证方法**
```bash
./venv/bin/python -m pytest tests/ -v
```
- 全绿。
**完成标准**`pytest` 全部通过。
---
## 11. 阶段十:worker 部署与冒烟(GPU 机 :28187
**开发步骤**
1. 在 GPU 机部署完整 `app.py` + `face_analysis` + 模型权重(见 `OFFLINE_ASSETS.md`);torch 用 CUDA 版。
2. resnet18 骨干放入 torch 缓存(`~/.cache/torch/hub/checkpoints/`),避免联网下载。
3. worker 配置文件写好 `accept_passwords`uvicorn 监听 `0.0.0.0:28187`;防火墙只放行网关 IP。
4. systemd 管理 worker 进程;确认 `/health` 模型就绪后才返回 200。
**交付物**worker 部署说明 + systemd unit + worker 配置文件示例
**验证方法**(在网关机或被放行的机器上)
```bash
TOK="X-Internal-Token: <worker配置的密码>"
# 0) 正常请求(直连 worker)→ code==0data 含 annotated_image_base64
curl -s -H "$TOK" -X POST http://<worker>:28187/api/v1/face/measure \
-F image_file=@tests/fixtures/frontal.jpg | python -m json.tool
# 鉴权) 不带 token → 401
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://<worker>:28187/api/v1/face/measure -F image_file=@tests/fixtures/frontal.jpg
# 就绪) /health → 200
curl -s http://<worker>:28187/health
nvidia-smi # 确认推理时 GPU 被占用
```
**完成标准**:worker 直连返回真实数据(base64 图)、鉴权生效、`/health` 就绪、GPU 在用。
> 端到端(经网关的 HTTPS 冒烟)见 [`网关-开发任务书.md`](网关-开发任务书.md) 的验证。
---
## 12. 总交付清单(worker 侧 Definition of Done
- [ ] `requirements.txt`torch CUDA 版)/ `.gitignore` / `scripts/download_weights.sh`
- [ ] `face_analysis/``detector.py``pose.py``calibration.py``hair_segmenter.py``measure.py``annotation.py``face_mesh_landmarks.py``fonts/``weights/`
- [ ] `app.py``/api/v1/face/measure` 真实实现(移除该接口 Mock),**返回 `annotated_image_base64`**
- [ ] worker 鉴权中间件(`X-Internal-Token`+ 配置文件 + `/health` 就绪态
- [ ] `tests/`fixtures + 单元 + 集成 + 数值回归 + §15 三层精度,`pytest` 全绿
- [ ] workerGPU 机 :28187)部署冒烟通过,GPU 在用
- [ ] worker 返回 data 含 `annotated_image_base64``hairline_source``head_pose`,业务字段与 `docs/接口文档.md` 对齐
> 网关侧的交付清单在 [`网关-开发任务书.md`](网关-开发任务书.md)(另一台机器开发),本任务书不含。
---
## 13. 风险与降级开关(提醒 agent)
1. **torch 装不上 / 太重**:若环境受限,先交付「方案 A only」版本(跳过阶段五的分割,`hairline_source` 恒为 `"estimated"`),把方案 B 标记为 TODO,但**其余阶段照常**。在交付说明里明确写出。
2. **数值不合理**(如 cm 量级离谱):优先怀疑 px_per_cm(虹膜点是否检出)和分辨率方向判断,而非盲目调比例常数。
3. **不确定字段命名**:以 `docs/接口文档.md` 为唯一权威,冲突时以文档为准并在 PR 说明里指出。
---
## 14. 测试素材清单(已就位于 `tests/fixtures/`
以下夹具**已全部创建完毕**,agent 直接使用即可,无需再拷贝/生成:
| 文件 | 尺寸(W×H) | 大小 | 来源 | 用途 |
|------|-----------|------|------|------|
| `frontal.jpg` | 682×811 | 94KB | 真实样本(原 `image/test.jpg` | **主用例**:阶段二~八全部功能验证 + 数值基线 |
| `hard_longhair.jpg` | 864×1152 | 131KB | 真实样本(原 `image/qwerqwe.jpg` | **困难样本**:分割鲁棒性、`max_num_faces=1` 只取最大脸、姿态 |
| `lowres.png` | 406×571 | 226KB | 真实样本(原 `image/image.png`,已带标注线) | **1002 拒绝用例**(短边 406 < 600);勿当干净输入 |
| `landscape.jpg` | 1000×1200 | 114KB | 程序生成(非人脸风景) | **1001 用例**:无法识别人像 |
| `corrupt.bin` | — | 2KB | 程序生成(伪 PNG 头 + 垃圾字节) | **1008 用例**:无法解码 |
| _(1006 超大图)_ | — | >1MB | **测试时动态生成,不入库** | **1006 用例**:超过 1MB |
> **1006 超大图不提交进 git**(避免仓库膨胀,内容是随机噪声无信息量)。在 `tests/conftest.py` 里用 pytest fixture 临时生成;测 1006 仅看字节数、无需合法图片:
> ```python
> @pytest.fixture
> def oversize_file(tmp_path):
> p = tmp_path / "oversize.bin"
> p.write_bytes(b"\x00" * (1_100_000)) # 1.1MB,刚过 1MB 红线
> return p
> ```
> 手动 curl 验证时临时造一个即可:`head -c 1100000 /dev/urandom > /tmp/oversize.bin`
> 仍缺:明显侧脸图(测 1003)。无合规素材,agent 用 mock landmarks 构造大 yaw 验证阈值逻辑即可(见 §4 阶段三)。
### 分辨率门槛(已放宽,可配置)
- **默认门槛下调为:短边 ≥ 600、长边 ≥ 800**(环境变量 `MIN_SHORT_SIDE=600``MIN_LONG_SIDE=800`,技术方案 §8.3 已同步)。
- 该门槛下:`frontal.jpg`(682×811)、`hard_longhair.jpg`(864×1152) 放行;`lowres.png`(406×571) 被 1002 拒绝——正好作拒绝用例,**功能测试无需再绕过校验**。
- **门槛必须做成可配置,不要硬编码**:生产可通过环境变量随时调整,无需改代码。
**待确认事项(提交给需求方,不阻塞开发)**
1. 600/800 是否合适?过低会牺牲测量精度(虹膜/关键点像素太少),过高会拒掉大量真实上传图。建议上线后按实际拒绝率/精度反馈再调。
2. `hard_longhair.jpg` 这类长发遮挡发际线的图,方案 B 大概率只能定位到中分缝;若分割结果不可靠应自动回退方案 A(`hairline_source="estimated"`)——确认这是可接受行为。
---
## 15. 精度 / 误差验证策略(三层)
> **核心认知**:管线分两层——**测量数学**(landmarks+尺度→cm)可以构造精确真值验证;**MediaPipe 检测**(图→landmarks 落点)无法合成真值,只能人工标注或间接验证。绝大多数可控 bug 在数学层,务必重点覆盖。
### Tier 1 — 合成真值,精确验证测量数学(必做,核心)
自己构造一组「已知真值」的关键点:坐标和 `px_per_cm` 都由测试设定,因此每一段的 cm/占比真值已知,算出来必须**分毫不差**(误差仅来自浮点,断言 < 1e-6)。这能精确验证 `calibration` / `measure_seven_eyes` / 方案A 推算 / 占比公式。
```python
# tests/test_geometry_truth.py
import numpy as np
class _LM: # 模拟 MediaPipe landmark.x/.y/.z
def __init__(self, x, y, z=0.0): self.x, self.y, self.z = x, y, z
def build_synthetic_landmarks(px_per_cm=50.0, W=1000, H=1000):
"""按已知 cm 几何摆放关键点,返回 (landmarks_list, ground_truth_dict)"""
cx = W / 2
def Y(cm_from_top): # cm → 归一化 y
return (cm_from_top * px_per_cm) / H
def X(px):
return px / W
# 设定真值(cm):从头顶往下
gt = {"top_court_cm": 4.0, "upper_court_cm": 5.0,
"middle_court_cm": 6.0, "lower_court_cm": 5.0,
"eye_width_cm": 3.0, "inter_eye_cm": 3.4, "face_width_cm": 14.0,
"px_per_cm": px_per_cm}
y_hairtop = 2.0
y_hairline = y_hairtop + gt["top_court_cm"]
y_brow = y_hairline + gt["upper_court_cm"]
y_nose = y_brow + gt["middle_court_cm"]
y_chin = y_nose + gt["lower_court_cm"]
lm = {i: _LM(X(cx), 0.0) for i in range(478)} # 占位
# 纵向中轴点
lm[9] = _LM(X(cx), Y(y_brow)); lm[151] = _LM(X(cx), Y(y_brow))
lm[94] = _LM(X(cx), Y(y_nose))
lm[152]= _LM(X(cx), Y(y_chin))
# 七眼横向点(按真值 px 摆位,y 任意取眉下一行)
ew = gt["eye_width_cm"] * px_per_cm
ie = gt["inter_eye_cm"] * px_per_cm
fw = gt["face_width_cm"] * px_per_cm
eye_y = Y(y_brow + 2.0)
lm[133] = _LM(X(cx - ie/2), eye_y); lm[33] = _LM(X(cx - ie/2 - ew), eye_y)
lm[362] = _LM(X(cx + ie/2), eye_y); lm[263] = _LM(X(cx + ie/2 + ew), eye_y)
lm[234] = _LM(X(cx - fw/2), eye_y); lm[454] = _LM(X(cx + fw/2), eye_y)
# 虹膜边缘点:直径 = 1.17cm * px_per_cm,使尺度可被精确反解
d = 1.17 * px_per_cm
lm[469] = _LM(X(cx - ie/2 - ew/2 - d/2), eye_y); lm[471] = _LM(X(cx - ie/2 - ew/2 + d/2), eye_y)
lm[474] = _LM(X(cx + ie/2 + ew/2 - d/2), eye_y); lm[476] = _LM(X(cx + ie/2 + ew/2 + d/2), eye_y)
return [lm[i] for i in range(478)], gt
def test_scale_factor_exact():
lm, gt = build_synthetic_landmarks(px_per_cm=50.0)
from face_analysis.calibration import estimate_scale_factor
assert abs(estimate_scale_factor(lm, 1000, 1000) - gt["px_per_cm"]) < 1e-6
def test_seven_eyes_exact():
lm, gt = build_synthetic_landmarks()
from face_analysis.measure import measure_seven_eyes
r = measure_seven_eyes(lm, 1000, 1000)
pc = gt["px_per_cm"]
assert abs(r["eye_width_px"]/pc - gt["eye_width_cm"]) < 1e-6
assert abs(r["face_width_px"]/pc - gt["face_width_cm"]) < 1e-6
assert abs(r["inter_eye_distance_px"]/pc - gt["inter_eye_cm"]) < 1e-6
# 方案A 推算、四庭占比同理,用 gt 的中/下庭做输入,断言推算的上/顶庭与 gt 关系一致
```
> 注意:方案 A 因为是「按比例推算」,它推出的上/顶庭**不会**等于任意设定的真值——Tier 1 对方案 A 只验证「推算公式按既定比例正确执行」(给定中下庭,输出符合 0.25/0.22 比例关系),而非验证它贴近真实脸。这正是方案 A 循环论证局限的体现,文档已说明。方案 B 的真值验证用合成 mask(已知头发区域上沿)走 `locate_hairline_by_segmentation`。
### Tier 2 — 缩放不变性,真实图上可运行(必做)
用真实 `frontal.jpg` 跑完整管线,再把图**等比放大 2×** 重跑。物理量应满足:
- **占比(ratio)完全不变**(±0.5%)——放大不改变比例。
- **cm 值基本不变**(±2%)——因为 px_per_cm 也随之放大,虹膜法自洽。
这用**真实 MediaPipe 输出**验证尺度处理无 bug,不需要人工真值。若放大后 cm 值漂移大,说明尺度链路有问题。
```python
def test_scale_invariance():
import cv2
img = cv2.imread("tests/fixtures/frontal.jpg")
big = cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)
r1 = run_measure(img); r2 = run_measure(big)
for k in ["top","upper","middle","lower"]:
assert abs(r1.ratio[k] - r2.ratio[k]) < 0.005 # 占比不变
assert abs(r1.cm[k] - r2.cm[k]) / r1.cm[k] < 0.02 # cm 近似不变
```
### Tier 3 — 检测落点定性评估(人工真值,抽样)
MediaPipe 落点准不准没有合成真值,只能:
1. **可视化叠加**:把 5 个纵向点 + 眼角点画回原图存 PNG,人工/agent 目视确认落点正确(眉心在眉间、下巴在下颌最低点等)。
2. **抽样人工标注**:对 2~3 张图手工标注真值关键点像素坐标存 `tests/fixtures/*_truth.json`,断言 MediaPipe 输出与标注的像素偏差 < 全脸高度的 3%。
```python
def test_landmark_overlay():
"""生成叠加图供人工核验,并断言关键点落在图像合理区域内"""
# 画点存 tests/output/frontal_landmarks.png,断言各点坐标在 [0,W]/[0,H] 且顺序自上而下
```
### 误差预期对照(写进基线表)
| 误差来源 | 验证手段 | 预期 |
|----------|----------|------|
| 测量数学(尺度/占比/七眼/脸宽) | Tier 1 合成真值 | ≈ 0< 1e-6 |
| 尺度链路一致性 | Tier 2 缩放不变性 | 占比 < 0.5%cm < 2% |
| MediaPipe 落点 | Tier 3 人工标注抽样 | < 3% 全脸高 |
| 虹膜个体差异 + 透视 | 无法消除,文档声明 | cm ±5~15%(离虹膜平面越远越大) |
| 方案 A 推算上/顶庭 | 固有局限 | 真实脸偏差可达 ±15%,故优先方案 B |
**完成标准(补充到阶段九)**Tier 1 全部断言 < 1e-6Tier 2 通过;Tier 3 叠加图人工确认 OK。
---
> **任务书版本**: v1.5 **创建日期**: 2026-06-13(v1.5:拆出网关任务书到独立文档,本书聚焦 worker 侧)| 配套技术方案 v2.0 / 系统架构 v1.0 / 网关任务书 v1.0
@@ -1,880 +0,0 @@
# 接口 1:四庭七眼测量 — 技术实现方案
> 基于 MediaPipe Face Mesh468 关键点)测量「眉心以下」+ 人脸解析分割(BiSeNet)获取「真实发际线/头顶」+ 人脸比例先验作为兜底
> 📌 **运行位置**:本文档描述的全部算法逻辑运行在 **高性能 worker(GPU 机)** 上,不在外网网关。系统已拆分为「外网网关 + worker」两层,详见 [`系统架构-网关与高性能后端.md`](系统架构-网关与高性能后端.md)。相对单机版有两处差异:
> 1. **GPU 加速**BiSeNet 改用 CUDA 推理(torch GPU 版),MediaPipe 仍 CPU。
> 2. **标注图返回 base64**worker **不落盘、不拼 URL**,把标注 PNG 以 `annotated_image_base64` 返回;落盘成 `annotated_image_url` 由网关完成(见架构文档 §9)。本文后续 §6/§8 的"保存到 static + 返回 URL"仅适用于单机版,拆分后改为返回 base64。
> 3. **资源宽裕**32G + GPU,无需单机版的 2核4G 并发限制;worker 自身并发=1 由网关保证。
---
## 1. 模型选型
### 1.1 调研结论
调研了以下人脸关键点检测模型:
| 模型 | 关键点数 | 覆盖范围 | Python 支持 | 备注 |
|------|----------|----------|-------------|------|
| **MediaPipe Face Mesh** | 468 / 478 | 额头中部 → 下巴(不含发际线以上) | ✅ `mediapipe` 包 | Google 官方,实时性能好 |
| dlib 68-point (300-W) | 68 | 眉毛 → 下巴 | ✅ `dlib` | 经典方法,无额头覆盖 |
| WFLW 98-point | 98 | 眉毛 → 下巴(额头仅 2 点) | ⚠️ 需额外模型 | 仍无头顶/发际线 |
| 3DDFA_V2 | 68+ 3D mesh | 类似 MediaPipe | ⚠️ 推理较慢 | 3D 重建更完整 |
| SPIGA | 68 | 眉毛 → 下巴 | ✅ | 实时性不如 MediaPipe |
**结论:没有任何「关键点检测模型」能直接给出「头顶」和「真实发际线」坐标**——所有关键点模型在额头以上方向都有盲区。
但「**人脸解析 / 头发分割模型**」可以直接把头发区域分割出来,从而得到**真实**的发际线与头顶位置(详见 §1.4 与 §4 方案 B)。因此本方案采用**双策略**:
- **眉心以下(中庭、下庭、七眼)**MediaPipe Face Mesh 468 点直接实测,精度高。
- **眉心以上(上庭、顶庭,即发际线与头顶)**:
- **方案 B(主)**:人脸解析分割(BiSeNet)提取真实发际线/头顶 —— 这两庭是**真实测量值**。
- **方案 A(兜底)**:当分割失败、光头、或被帽子/刘海遮挡时,退化为「人脸比例推算」。
> ⚠️ **重要**:旧版本仅用方案 A,存在「循环论证」缺陷 —— 用三庭标准比例反推发际线、再据此算占比,输出的顶庭/上庭占比几乎等于输入常数,不反映真实脸型。引入方案 B 后,顶上两庭才成为真正的测量结果。方案 A 仅作降级使用。
### 1.2 为什么用 468 点而非 478 点
478 点比 468 点多出 10 个虹膜(iris)关键点(索引 468–477),仅用于眼球追踪。四庭七眼测量不需要虹膜数据,468 点完全满足需求。使用经典 `mp.solutions.face_mesh` API,模型内置于 pip 包中,无需单独下载 `.task` 文件。
### 1.3 国内安装方式
```bash
# 使用清华镜像安装 mediapipe 及依赖
pip install mediapipe opencv-python pillow numpy -i https://pypi.tuna.tsinghua.edu.cn/simple/
```
经典 Solutions API 模型文件已打包在 wheel 包内(路径:`mediapipe/modules/face_landmark/`),安装后直接可用,无需额外下载。
> ⚠️ **版本兼容性坑**MediaPipe 0.10.x 对 numpy 2.x 支持不稳定,常出现 import 崩溃。**必须锁定 `numpy<2`(推荐 1.26.x**,详见 §10。
### 1.4 发际线 / 头顶分割模型(方案 B 依赖)
关键点模型够不到的额头以上区域,用**人脸解析(face parsing)**模型补齐。这类模型对整张脸做像素级语义分割,类别中包含 `hair`(头发):
| 模型 | 训练集 | 类别数 | 体积 | Python 支持 | 备注 |
|------|--------|--------|------|-------------|------|
| **BiSeNet (face-parsing.PyTorch)** | CelebAMask-HQ | 19(含 hair/skin/眉眼鼻嘴等) | ~50 MB | ✅ PyTorch | 最常用,CPU 可跑(~0.31s/张) |
| MODNet | 人像 matting | 前景/背景 | ~25 MB | ✅ | 只分前景,不区分头发 |
| SegFormer-b0 face-parsing | CelebAMask-HQ | 19 | ~15 MB | ✅ HuggingFace | 更轻,需 transformers |
**选型:BiSeNetface-parsing.PyTorch**,社区成熟、权重易得、19 类直接含 `hair`
拿到分割 mask 后:
- **真实发际线** = 沿面部中轴线(用 §4 的 `brow_center_x` 作为 x),从上往下扫描,**头发区域 → 皮肤区域**的第一个交界 y 坐标。
- **头顶** = 头发 mask 的**最高点**(最小 y)。
> 权重需单独下载,放入 `face_analysis/weights/`,不入 git(写进 `.gitignore`):
> - `79999_iter.pth`~53 MB)— BiSeNet 主权重。
> - `resnet18-5c106cde.pth`~45 MB)— BiSeNet 用的 resnet18 骨干。**离线/内网环境必须预放**:BiSeNet 初始化时会尝试用 `torch.utils.model_zoo` 联网下载该骨干,内网会失败。需把它放进 torch hub 缓存(`~/.cache/torch/hub/checkpoints/`)或改代码从本地路径加载。
>
> **本仓库已预先下载好上述权重 + 字体**(见根目录 `OFFLINE_ASSETS.md` 的 sha256 清单),内网机器无需联网,直接使用。
---
## 2. 关键点索引映射
MediaPipe Face Mesh 对 468 个点按固定拓扑编号,以下是四庭七眼测量所需的关键索引:
### 2.1 四庭纵向关键点
```
★ 头顶 (hair_top) ← 方案A推算,非MediaPipe直接检测
│ 顶庭 (~22%)
★ 发际线 (hairline) ← 方案A推算,非MediaPipe直接检测
│ 上庭 (~25%)
★ 眉心 (brow_center) ← 索引 9 或 151glabella,双眉间)
│ 中庭 (~28%)
★ 鼻翼下缘 (nose_bottom) ← 索引 94subnasale / 人中顶部)
│ 下庭 (~25%)
★ 下巴尖 (chin_tip) ← 索引 152menton
```
| 测量点 | MediaPipe 索引 | 说明 |
|--------|----------------|------|
| 头顶 | **无直接索引** | 由发际线 + 顶庭比例向上推算 |
| 发际线 | **无直接索引** | 由眉心 + 上庭比例向上推算 |
| 眉心 | **9****151** | glabella,双眉间中心点;两个点取中点 |
| 鼻翼下缘 | **94** | subnasale,鼻小柱底部与人中交界处 |
| 下巴尖 | **152** | menton,下颌最低点 |
### 2.2 七眼横向关键点
```
左脸 左眼外角 左眼内角 右眼内角 右眼外角 右脸
│ │ │ │ │ │
234 ←────── 33 ───── 133 ──── 两眼间距 ──── 362 ───── 263 ──────→ 454
│ │← 眼宽 →│ ← 两眼间距 → │← 眼宽 →│ │
│←──────────────── 脸宽 ──────────────────────────────→│
```
| 测量项目 | 左端索引 | 右端索引 | 说明 |
|----------|----------|----------|------|
| 左眼宽度 | 33(外眼角) | 133(内眼角) | 水平距离 |
| 右眼宽度 | 263(外眼角) | 362(内眼角) | 水平距离 |
| 两眼间距 | 133(左内眼角) | 362(右内眼角) | 内眦间距 |
| 脸宽 | 234(左颧弓) | 454(右颧弓) | 面部最宽处水平距离 |
> 注:脸宽使用 face oval 轮廓上颧弓高度对应的点。索引 234(左)和 454(右)位于 cheekbone 高度,是 face oval 路径 `...→234→127→162→21→...` 和 `...→454→356→389→251→...` 上的点。
### 2.3 参考索引速查表
| 索引 | 解剖位置 | 所属区域 |
|------|----------|----------|
| 4 | 鼻尖 (nose tip) | 鼻子 |
| 9, 151 | 眉间 / glabella | 眉心 |
| 10 | 额头顶端 (forehead top) — 不是发际线 | 额头 |
| 33 | 左眼外眼角 | 左眼 |
| 94 | 鼻翼下缘 / subnasale | 鼻子底部 |
| 133 | 左眼内眼角 | 左眼 |
| 152 | 下巴尖 / menton | 下巴 |
| 234 | 左脸颧弓处 | 面部轮廓 |
| 263 | 右眼外眼角 | 右眼 |
| 362 | 右眼内眼角 | 右眼 |
| 454 | 右脸颧弓处 | 面部轮廓 |
**Face Oval 连通路径**(面部轮廓线,用于验证脸宽点选择):
```
10→338→297→332→284→251→389→356→454→323→361→288→397→365→379→378→400→377→152→148→176→149→150→136→172→58→132→93→234→127→162→21→54→103→67→109→(回到10)
```
---
## 3. 厘米换算方案
### 3.1 转换原理
MediaPipe 输出的关键点坐标是 **归一化像素坐标**
- `x ∈ [0, 1]`,归一化于图像宽度
- `y ∈ [0, 1]`,归一化于图像高度
- `z` 为相对深度(以头部中心为零点,向镜头方向为负)
需要将归一化坐标转为像素坐标,再通过**尺度参照物**转为厘米。
### 3.2 像素坐标恢复
```python
def normalized_to_pixel(landmark, image_width, image_height):
"""归一化坐标 → 像素坐标"""
x_px = landmark.x * image_width
y_px = landmark.y * image_height
return x_px, y_px
def pixel_distance(p1, p2):
"""两点像素距离"""
return ((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2) ** 0.5
```
### 3.3 尺度校准:虹膜直径法
**原理**:人类虹膜直径高度稳定,成人平均 **11.7 mm**(标准差 ≈ 0.5 mm,约 4%),可作为天然标尺。
```python
AVG_IRIS_DIAMETER_CM = 1.17 # 11.7 mm
# MediaPipe 虹膜关键点(开启 refine_landmarks=True 后可用)
IRIS_LEFT_CENTER = 468 # 左眼虹膜中心
IRIS_RIGHT_CENTER = 473 # 右眼虹膜中心
# 虹膜边界点(取上/下或左/右两个边缘点计算直径)
IRIS_LEFT_LEFT = 469 # 左虹膜左边缘
IRIS_LEFT_RIGHT = 471 # 左虹膜右边缘
IRIS_RIGHT_LEFT = 474 # 右虹膜左边缘
IRIS_RIGHT_RIGHT = 476 # 右虹膜右边缘
def estimate_scale_factor(landmarks, image_width, image_height):
"""通过虹膜直径估算 px → cm 缩放因子
Returns:
px_per_cm: 每厘米对应多少像素
"""
# 左眼虹膜像素直径
iris_left_l = normalized_to_pixel(landmarks[IRIS_LEFT_LEFT], image_width, image_height)
iris_left_r = normalized_to_pixel(landmarks[IRIS_LEFT_RIGHT], image_width, image_height)
iris_left_diameter_px = pixel_distance(iris_left_l, iris_left_r)
# 右眼虹膜像素直径
iris_right_l = normalized_to_pixel(landmarks[IRIS_RIGHT_LEFT], image_width, image_height)
iris_right_r = normalized_to_pixel(landmarks[IRIS_RIGHT_RIGHT], image_width, image_height)
iris_right_diameter_px = pixel_distance(iris_right_l, iris_right_r)
# 取平均,减少误差
avg_iris_diameter_px = (iris_left_diameter_px + iris_right_diameter_px) / 2
px_per_cm = avg_iris_diameter_px / AVG_IRIS_DIAMETER_CM
return px_per_cm
```
> **注意**:虹膜关键点(索引 468477)需要 `FaceMesh(refine_landmarks=True)` 才会输出。如果不启用 `refine_landmarks`,可用**眼宽**(外眼角→内眼角)作为替代标尺,人类平均眼裂宽度约 **27–30 mm**,精度略低。
> ⚠️ **透视局限(务必在 API 文档/返回里注明)**:虹膜法得到的 `px_per_cm` 只在**虹膜所在的深度平面**精确。下巴、额头、头顶与虹膜不共面,2D 照片存在透视投影,因此纵向(四庭)的 cm 换算会带系统误差,离虹膜平面越远(如头顶)误差越大。返回的 cm 值应理解为**近似值**,而非全脸恒定尺度下的精确测量。比例(ratio)受透视影响小于绝对 cm 值,建议前端优先展示比例。
### 3.4 备用校准:人脸比例法
若虹膜数据不可用,也可用 460 点基础模型的脸宽比例估算:
```python
# 基于三庭五眼理想比例
# 脸宽 (234→454 px) ≈ 5 眼宽 ≈ 5 × (脸宽的 1/5)
# 已知脸宽距离的像素值,参考人脸统计平均脸宽 ~14 cm (女性) ~15 cm (男性)
# 得 px_per_cm = face_width_px / 14.5 (粗略)
```
此方法误差较大(±15%),建议优先使用虹膜法。对测量误差要求不严格的场景可接受。
---
## 4. 头顶 & 发际线定位(方案 B 主 / 方案 A 兜底)
> **决策流程**:先跑方案 B(分割)。若分割成功且发际线/头顶落在合理范围(发际线在眉心上方、头顶在发际线上方、各庭长度为正),用方案 B 结果;否则记录 `hairline_source = "estimated"` 并回退方案 A。方案 B 成功时 `hairline_source = "segmentation"`,需在返回 `data` 里透出该字段,方便前端/业务区分真实测量与估算。
### 4.0 方案 B(主):分割提取真实发际线 & 头顶
```python
def locate_hairline_by_segmentation(hair_mask, brow_center_x, image_height):
"""
输入: hair_mask (H×W bool/uint8, True=头发像素), 面部中轴线 x, 图高
输出: (hairline_y, hair_top_y) 像素坐标; 失败返回 None
"""
import numpy as np
if hair_mask is None or hair_mask.sum() == 0:
return None # 光头 / 分割失败 → 交给方案 A
cx = int(round(brow_center_x))
# 在中轴线附近取一个窄列带(±3px)求稳,避免单列噪声
band = hair_mask[:, max(0, cx - 3): cx + 4]
col = band.any(axis=1) # 每一行在该列带是否有头发
hair_rows = np.where(col)[0]
if hair_rows.size == 0:
return None
# 发际线 = 中轴线上「头发→皮肤」交界:即该列带头发像素中最靠下的连续头发块的下沿
# 简化:取中轴线列上头发区域的最大 y(向下为正)作为发际线
hairline_y = int(hair_rows.max())
# 头顶 = 整张头发 mask 的最高点(最小 y),更鲁棒地用全图而非单列
top_rows = np.where(hair_mask.any(axis=1))[0]
hair_top_y = int(top_rows.min())
# 合理性校验:头顶必须在发际线上方
if hair_top_y >= hairline_y:
return None
return hairline_y, hair_top_y
```
> 实际实现可对 mask 先做轻量形态学开运算去噪;发际线判定可改为"沿中轴线从上往下首次出现的 hair→non-hair 跳变",比单纯取 max 更贴合带刘海/碎发场景。具体阈值在拿到测试集后调。
### 4.1 方案 A(兜底)核心思路
> 仅当方案 B 不可用时启用。**注意其循环论证局限:顶上两庭为估算值,不反映真实脸型。**
MediaPipe 可以精确检测 **眉心、鼻翼下缘、下巴尖** 三个关键点(均位于面部中轴线)。利用「三庭五眼」标准比例,向上推算发际线和头顶位置。
### 4.2 比例参数
根据需求文档中的 Mock 数据反推(顶庭:上庭:中庭:下庭 = 22%:25%:28%:25%),以及经典三庭五眼理论(三庭等分),定义两套可选参数:
```
方案比例(基于Mock数据):
顶庭 : 上庭 : 中庭 : 下庭 = 0.22 : 0.25 : 0.28 : 0.25
经典三庭比例(上庭=中庭=下庭):
上庭 : 中庭 : 下庭 = 1 : 1 : 1
顶庭 ≈ 0.2 × 全脸高度(通过统计)
```
实际采用混合策略:**以实测中庭和下庭为基准,按标准比例推算上庭和顶庭**。
### 4.3 推算公式
```python
def estimate_vertical_landmarks(landmarks, image_width, image_height):
"""
输入: MediaPipe 468 landmarks + 图像尺寸
输出: 5 个关键点像素坐标 + 各段像素距离
"""
# --- 1. 提取可直接检测的关键点 ---
# 眉心 (glabella):索引 9 和 151 的中点
glabella_9 = normalized_to_pixel(landmarks[9], image_width, image_height)
glabella_151 = normalized_to_pixel(landmarks[151], image_width, image_height)
brow_center_y = (glabella_9[1] + glabella_151[1]) / 2
brow_center_x = (glabella_9[0] + glabella_151[0]) / 2
# 鼻翼下缘 (subnasale):索引 94
nose_bottom = normalized_to_pixel(landmarks[94], image_width, image_height)
# 下巴尖 (menton):索引 152
chin_tip = normalized_to_pixel(landmarks[152], image_width, image_height)
# --- 2. 计算实测段长度 (像素) ---
middle_court_px = abs(brow_center_y - nose_bottom[1]) # 眉心 → 鼻翼下缘
lower_court_px = abs(nose_bottom[1] - chin_tip[1]) # 鼻翼下缘 → 下巴尖
# --- 3. 推算上庭和顶庭 ---
# 以中庭和下庭的平均值作为基准"一等份"(减小个体差异)
one_unit_px = (middle_court_px + lower_court_px) / 2 # 一等份 ≈ 中庭/下庭的平均
# 上庭 ≈ 一等份(经典三庭等分)或根据实际中庭比例微调
upper_court_px = one_unit_px * (0.25 / 0.265) # 上庭 25% vs 中庭/下庭平均 26.5%
# 顶庭 ≈ 中庭 × (22%/28%) 或 ≈ 0.79 × one_unit_px
top_court_px = one_unit_px * (0.22 / 0.28) # 约 0.786 × one_unit_px
# --- 4. 推算头顶和发际线 Y 坐标 ---
hairline_y = brow_center_y - upper_court_px
hair_top_y = hairline_y - top_court_px
# --- 5. 计算全脸总高度 ---
face_total_height_px = hair_top_y - chin_tip[1] # 注意 Y 轴方向(向下为正)
return {
"hair_top": (brow_center_x, hair_top_y),
"hairline": (brow_center_x, hairline_y),
"brow_center": (brow_center_x, brow_center_y),
"nose_bottom": (nose_bottom[0], nose_bottom[1]),
"chin_tip": (chin_tip[0], chin_tip[1]),
# 各段像素高度
"top_court_px": top_court_px,
"upper_court_px": upper_court_px,
"middle_court_px": middle_court_px,
"lower_court_px": lower_court_px,
"face_total_height_px": face_total_height_px,
}
```
### 4.4 像素 → 厘米转换
```python
def pixels_to_cm(vertical_result, px_per_cm):
"""将像素距离转为厘米"""
return {
"top_court_cm": vertical_result["top_court_px"] / px_per_cm,
"upper_court_cm": vertical_result["upper_court_px"] / px_per_cm,
"middle_court_cm": vertical_result["middle_court_px"] / px_per_cm,
"lower_court_cm": vertical_result["lower_court_px"] / px_per_cm,
"face_total_height_cm": vertical_result["face_total_height_px"] / px_per_cm,
}
```
---
## 5. 七眼测量实现
七眼测量全部基于可直接检测的关键点(无需推算),精度较好。
```python
def measure_seven_eyes(landmarks, image_width, image_height):
"""
测量眼宽、脸宽、两眼间距(像素)
返回像素值,后续通过 px_per_cm 转为厘米
"""
# 左眼外/内角
left_outer = normalized_to_pixel(landmarks[33], image_width, image_height)
left_inner = normalized_to_pixel(landmarks[133], image_width, image_height)
# 右眼内/外角
right_inner = normalized_to_pixel(landmarks[362], image_width, image_height)
right_outer = normalized_to_pixel(landmarks[263], image_width, image_height)
# 脸宽
left_cheek = normalized_to_pixel(landmarks[234], image_width, image_height)
right_cheek = normalized_to_pixel(landmarks[454], image_width, image_height)
eye_width_px = pixel_distance(left_outer, left_inner) # 左眼宽(也可用右眼或平均)
right_eye_width_px = pixel_distance(right_inner, right_outer)
avg_eye_width_px = (eye_width_px + right_eye_width_px) / 2
inter_eye_px = pixel_distance(left_inner, right_inner) # 两眼间距
face_width_px = pixel_distance(left_cheek, right_cheek) # 脸宽
return {
"eye_width_px": avg_eye_width_px,
"face_width_px": face_width_px,
"inter_eye_distance_px": inter_eye_px,
}
```
### 占比计算
```python
# 七眼比例(眼宽/脸宽,间距/脸宽)
eye_width_ratio = eye_width_px / face_width_px
inter_eye_ratio = inter_eye_px / face_width_px
# 四庭比例(各段 / 全脸总高)
for court in ["top", "upper", "middle", "lower"]:
ratios[f"{court}_court"] = result[f"{court}_court_px"] / face_total_height_px
```
---
## 6. 标注图片生成
需求要求输出**仅包含标注图层、不含人物**的 PNG 图片,规格如下:
| 项目 | 要求 |
|------|------|
| 字体色 / 线色 | `#FFFFFF` 100% |
| 字体 | PingFangSC-Regular 10pt |
| 线宽 | 1pt |
| 四庭数值位置 | 图片**左侧** |
| 七眼间距数值 | **上下穿插**展示 |
| 横线/竖线 | 渐变消失 |
| 虚线 | 两侧带箭头 |
### 实现方案
使用 **Pillow (PIL)**`ImageDraw` 生成透明底 PNG,画布尺寸与输入原图一致。
```python
from PIL import Image, ImageDraw, ImageFont
import math
def create_annotated_image(input_image_path, vertical_result, eye_result, px_per_cm):
"""生成标注图层 PNG(透明底,仅标注)"""
# 读取原图获取尺寸
original = Image.open(input_image_path)
width, height = original.size
# 创建透明画布 (RGBA, A=0)
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(canvas)
# ⚠️ 字体:PingFangSC 是 macOS 字体,Linux 服务器没有;且 ImageFont.load_default()
# 不渲染中文(会出现方块/空白)。必须随仓库打包一个中文 TTF 并用绝对路径加载。
# 已打包 Noto Sans CJK SC= 思源黑体,同一套字体):face_analysis/fonts/NotoSansCJKsc-Regular.otf
FONT_PATH = os.path.join(os.path.dirname(__file__), "fonts", "NotoSansCJKsc-Regular.otf")
font = ImageFont.truetype(FONT_PATH, 10) # 字体缺失时直接抛错,避免静默降级成乱码
line_color = (255, 255, 255, 255) # #FFFFFF 100%
line_width = 1 # 1pt
# --- 1. 绘制四庭水平分界线(渐变消失效果) ---
courts = [
("hair_top", vertical_result["hair_top"]),
("hairline", vertical_result["hairline"]),
("brow_center", vertical_result["brow_center"]),
("nose_bottom", vertical_result["nose_bottom"]),
("chin_tip", vertical_result["chin_tip"]),
]
for name, (cx, cy) in courts:
# 绘制从中心向两侧渐变的水平线
draw_gradient_horizontal_line(draw, cx, cy, width, line_color, line_width)
# --- 2. 绘制四庭数值(左侧标注) ---
court_values = [
("顶庭", vertical_result["top_court_px"] / px_per_cm),
("上庭", vertical_result["upper_court_px"] / px_per_cm),
("中庭", vertical_result["middle_court_px"] / px_per_cm),
("下庭", vertical_result["lower_court_px"] / px_per_cm),
]
left_margin = 20
for i, (label, cm_val) in enumerate(court_values):
# 标注在对应段落中间高度
y_start = courts[i][1][1]
y_end = courts[i+1][1][1]
y_mid = (y_start + y_end) / 2
text = f"{label} {cm_val:.2f}cm"
draw.text((left_margin, y_mid), text, fill=line_color, font=font)
# --- 3. 绘制七眼标注(上下穿插) ---
# 眼宽标注在上方,间距标注在下方
# (具体位置根据实际坐标布局)
# ... (详细绘制逻辑见完整实现)
# --- 4. 绘制虚线箭头 ---
# 在分界点位置绘制水平虚线,两端带箭头
return canvas
```
### 渐变线实现
> ⚠️ **性能**:逐像素 `draw.point` 在大图上极慢(每条线几百次 Python 调用,多条线 × 高分辨率图肉眼可感卡顿)。用 numpy 向量化生成一行渐变像素后整行写入,快几个数量级:
```python
import numpy as np
def draw_gradient_horizontal_line(canvas: Image.Image, cx, cy, color, half_length=None):
"""以 (cx, cy) 为中心,向两侧绘制渐变消失的水平线(numpy 向量化)"""
arr = np.asarray(canvas) # RGBA, H×W×4
h, w = arr.shape[:2]
cy = int(round(cy)); cx = int(round(cx))
if not (0 <= cy < h):
return
half = half_length or (w // 3)
xs = np.arange(w)
dist = np.abs(xs - cx)
alpha = np.clip(1.0 - dist / half, 0.0, 1.0) * color[3] # 线性衰减,超出 half 为 0
mask = alpha > 0
row = arr[cy]
row[mask, 0], row[mask, 1], row[mask, 2] = color[0], color[1], color[2]
# 与已有 alpha 取较大值,避免覆盖其它线条
row[mask, 3] = np.maximum(row[mask, 3], alpha[mask].astype(np.uint8))
# 注意:需用可写数组(np.array(canvas) 复制),处理完用 Image.fromarray 写回画布
```
> 实现时建议全程在一个 `np.zeros((h, w, 4), uint8)` 缓冲区上画线,最后 `Image.fromarray` 一次性转回,再用 `ImageDraw` 画文字/箭头。
### 虚线带箭头
```python
def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color, dash_len=6, gap_len=4):
"""两点间画虚线,两端带箭头"""
total_len = ((x2 - x1)**2 + (y2 - y1)**2) ** 0.5
if total_len == 0:
return
dx = (x2 - x1) / total_len
dy = (y2 - y1) / total_len
# 画虚线
pos = 0
while pos < total_len:
seg_end = min(pos + dash_len, total_len)
draw.line([
(x1 + dx * pos, y1 + dy * pos),
(x1 + dx * seg_end, y1 + dy * seg_end)
], fill=color, width=1)
pos += dash_len + gap_len
# 两端箭头 (等腰三角形)
arrow_size = 6
# 左端箭头...
# 右端箭头...
```
> 标注图片的具体视觉样式建议在实现后根据实际效果微调,特别是虚线箭头的方向和位置。
---
## 7. 整体处理流程
```
输入图片
┌─────────────────────────────────────┐
│ 1. 预处理 │
│ - 校验格式 (JPG/PNG) │
│ - 校验分辨率 (短边≥600 长边≥800, 可配置)
│ - 校验文件大小 (≤ 1MB) │
│ - 校验人脸数量 (仅单人) │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 2. MediaPipe 推理 │
│ - FaceMesh(static_image_mode=True,
│ max_num_faces=1,
│ refine_landmarks=True) │
│ - 输出: 468+10 关键点 │
│ - 无人脸 → 1001 │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 3. 姿态校验 (solvePnP) │
│ - 解算 yaw/pitch/roll │
│ - 超阈值 → 1003 (非正面照) │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 4. 关键点提取 + 发际线/头顶定位 │
│ - 横向: 眼宽/脸宽/两眼间距(实测) │
│ - 中/下庭: 眉心/鼻翼/下巴 (实测) │
│ - 上/顶庭: 方案B分割(主)→A推算(兜底)│
│ 记录 hairline_source │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 5. 尺度校准 │
│ - 虹膜直径法: px_per_cm 估算 │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 6. 计算与生成 │
│ - 像素 → 厘米 │
│ - 计算占比 │
│ - 生成标注图层 PNG │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 7. 输出 │
│ - annotated_image_url (标注PNG) │
│ - face_total_height_cm │
│ - four_courts (含cm & ratios) │
│ - seven_eyes (含cm & ratios) │
│ - landmarks (5个点原图像素坐标) │
│ - hairline_source ("segmentation"│
│ / "estimated") │
│ - head_pose (yaw/pitch/roll) │
└─────────────────────────────────────┘
```
---
## 8. 关键代码骨架
### 8.1 目录结构建议
```
hair/
├── app.py # 现有 FastAPI 应用
├── face_analysis/
│ ├── __init__.py
│ ├── detector.py # MediaPipe Face Mesh 封装
│ ├── hair_segmenter.py # 方案 BBiSeNet 头发分割封装
│ ├── pose.py # solvePnP 头部姿态估计 + 正面校验
│ ├── measure.py # 四庭七眼测量逻辑(整合方案 B/A)
│ ├── calibration.py # px→cm 尺度校准(虹膜法)
│ ├── annotation.py # 标注图片生成(numpy 渐变线 + 中文字体)
│ ├── face_mesh_landmarks.py # 关键点索引常量
│ ├── fonts/
│ │ └── NotoSansCJKsc-Regular.otf # 打包的中文字体(= 思源黑体)
│ └── weights/ # 模型权重(不入 git,部署脚本拉取)
│ ├── 79999_iter.pth # BiSeNet face-parsing 权重 ~53MB
│ └── resnet18-5c106cde.pth # BiSeNet 骨干权重 ~45MB(离线必需,见下)
├── static/
│ └── annotations/ # 生成的标注 PNG 存放目录
├── .gitignore # 忽略 face_analysis/weights/*.pth
└── requirements.txt
```
### 8.2 MediaPipe 封装 (`detector.py`)
```python
import mediapipe as mp
import cv2
import numpy as np
mp_face_mesh = mp.solutions.face_mesh
class FaceMeshDetector:
"""MediaPipe Face Mesh 封装,单例模式"""
def __init__(self):
self.face_mesh = mp_face_mesh.FaceMesh(
static_image_mode=True,
max_num_faces=1, # 仅检测单人
refine_landmarks=True, # 启用虹膜 + 唇部精细关键点
min_detection_confidence=0.5,
)
def detect(self, image: np.ndarray) -> list | None:
"""
检测人脸关键点
Args:
image: BGR numpy array (OpenCV 格式)
Returns:
landmarks: NormalizedLandmarkList,或 None
"""
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
results = self.face_mesh.process(rgb)
if results.multi_face_landmarks:
return results.multi_face_landmarks[0] # 第一个人脸
return None
def close(self):
self.face_mesh.close()
# 全局单例
detector = FaceMeshDetector()
```
### 8.3 FastAPI 集成
```python
# 在 app.py 中集成
from face_analysis.measure import measure_face
from face_analysis.annotation import create_annotated_image
import cv2
import numpy as np
from io import BytesIO
@app.post("/api/v1/face/measure")
async def face_measure(image_file: UploadFile = File(...)):
# 1. 读取图片
contents = await image_file.read()
# 2. 校验
if len(contents) > 1_000_000:
return err(1006, "文件超出 1 MB 限制")
nparr = np.frombuffer(contents, np.uint8)
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if image is None:
return err(1008, "图片格式不支持")
h, w = image.shape[:2]
# ⚠️ 竖拍人像通常 w=1080, h=1920;不要把 w/h 写反导致竖图被全部拒绝。
# 用「短边/长边」判断,方向无关,竖拍横拍都兼容。
# 门槛可配置(环境变量),默认放宽到 600/800 以适配真实用户上传图。
min_short = int(os.getenv("MIN_SHORT_SIDE", "600"))
min_long = int(os.getenv("MIN_LONG_SIDE", "800"))
short_side, long_side = min(w, h), max(w, h)
if short_side < min_short or long_side < min_long:
return err(1002, "人像分辨率过低")
# 3. 人脸检测
landmarks = detector.detect(image)
if landmarks is None:
return err(1001, "无法识别人像")
# 4. 测量计算
result = measure_face(landmarks, w, h)
# 5. 生成标注图
annotated = create_annotated_image(image, result)
buf = BytesIO()
annotated.save(buf, format="PNG")
# 6. 拆分架构下:返回 base64,由网关落盘改写成 annotated_image_url(见架构文档 §9
import base64
data = result.to_response()
data["annotated_image_base64"] = base64.b64encode(buf.getvalue()).decode()
return ok(data)
# —— 单机版(非拆分)才在此保存到 static/ 并返回 annotated_image_url ——
```
---
## 9. 误差分析与局限
| 误差来源 | 影响范围 | 估算误差 | 缓解措施 |
|----------|----------|----------|----------|
| 头顶/发际线推算 | 顶庭、上庭 cm 值 | ±15% | 基于实测中庭下庭比例自适应 |
| 虹膜直径个体差异 | 所有 cm 值 | ±5% | 左右眼平均;未来可接性别/年龄修正 |
| 非正面照 | 所有横向测量 | ±20% | 前置校验偏航角(yaw),过大则返回 1003 |
| 相机畸变 | 边缘区域坐标 | ±3% | 假设普通手机拍照,畸变可控 |
| 人脸比例个体差异 | 推算的发际线/头顶 | ±10% | 无完美解决方案,方案 A 的自然局限 |
**前置姿态校验**(检测是否为正面照):
> 旧版本靠「双眼 y 差 + 鼻尖偏移」的经验阈值(0.03/0.08),不可解释、难调。**改用 `cv2.solvePnP` 解算真实头部欧拉角(yaw/pitch/roll,单位:度)**,阈值就能写成业务可读的"yaw>15° 拒绝",并把角度返回给前端做拍照引导。
```python
import cv2
import numpy as np
# 通用 3D 头部模型(单位 mm,近似),与下方 MediaPipe 索引一一对应
_MODEL_POINTS = np.array([
(0.0, 0.0, 0.0), # 鼻尖 -> 1(或 4
(0.0, -63.6, -12.5), # 下巴 -> 152
(-43.3, 32.7, -26.0), # 左眼外角 -> 33
(43.3, 32.7, -26.0), # 右眼外角 -> 263
(-28.9, -28.9, -24.1), # 左嘴角 -> 61
(28.9, -28.9, -24.1), # 右嘴角 -> 291
], dtype=np.float64)
_PNP_IDX = [1, 152, 33, 263, 61, 291]
def estimate_head_pose(landmarks, image_width, image_height):
"""返回 (yaw, pitch, roll) 角度。solvePnP 失败返回 None。"""
image_points = np.array([
(landmarks[i].x * image_width, landmarks[i].y * image_height)
for i in _PNP_IDX
], dtype=np.float64)
focal = image_width # 近似焦距
cam_matrix = np.array([[focal, 0, image_width / 2],
[0, focal, image_height / 2],
[0, 0, 1]], dtype=np.float64)
dist = np.zeros((4, 1)) # 假设无畸变
ok, rvec, tvec = cv2.solvePnP(_MODEL_POINTS, image_points, cam_matrix, dist,
flags=cv2.SOLVEPNP_ITERATIVE)
if not ok:
return None
rot, _ = cv2.Rodrigues(rvec)
sy = (rot[0, 0] ** 2 + rot[1, 0] ** 2) ** 0.5
pitch = np.degrees(np.arctan2(-rot[2, 0], sy))
yaw = np.degrees(np.arctan2(rot[1, 0], rot[0, 0]))
roll = np.degrees(np.arctan2(rot[2, 1], rot[2, 2]))
return yaw, pitch, roll
def check_frontal_face(landmarks, image_width, image_height,
yaw_thr=15, pitch_thr=15, roll_thr=15):
"""正面照判定:yaw/pitch/roll 均在阈值内才算正面。阈值待测试集标定。"""
pose = estimate_head_pose(landmarks, image_width, image_height)
if pose is None:
return True # 解算失败时不拦截,交由后续逻辑
yaw, pitch, roll = pose
return abs(yaw) <= yaw_thr and abs(pitch) <= pitch_thr and abs(roll) <= roll_thr
```
> 上面 `_MODEL_POINTS` 是常用近似头模,索引/坐标可在测试阶段微调。阈值 15° 为初始值,按 §11 收集的测试数据标定。
---
## 10. 依赖与版本
```
# requirements.txt 新增
mediapipe==0.10.14 # 经典 Solutions API(模型内置,无需额外下载)
opencv-python==4.10.0 # 图片读取、处理、solvePnP 姿态估计
Pillow==11.0.0 # 标注图生成(PNG 透明图层)
numpy==1.26.4 # ⚠️ 必须 <2,否则 mediapipe 0.10.x import 崩溃
# 方案 B:头发分割(BiSeNet face-parsing
# 拆分架构:worker 有 GPU → 用 CUDA 版 torch(按 worker 的 CUDA 版本选 whl
torch==2.2.2 # GPU(CUDA)版,例如 cu121--index-url https://download.pytorch.org/whl/cu121
torchvision==0.17.2
```
> ⚠️ **numpy 锁版本**mediapipe 0.10.x 对 numpy 2.x 支持不稳定,务必锁 `numpy<2`(已验证 1.26.4 可用)。先用此组合跑通,再考虑升级。
>
> ⚠️ **torch GPU/CPU**:拆分架构下 worker 有独立 GPU,用 **CUDA 版 torch**(按 worker 实际 CUDA 版本选对应 whl index,如 cu118/cu121),BiSeNet 推理走 GPU。单机/无 GPU 环境回退 CPU 版(`--index-url .../whl/cpu`~200MB)。BiSeNet 加载时把 `.to('cuda' if torch.cuda.is_available() else 'cpu')`。
> MediaPipe 0.10.x 的经典 Solutions API (`mp.solutions.face_mesh`) 仍稳定可用。如需迁移到 Tasks API,后续可平滑升级。
---
## 11. 待确认事项
1. **标注图片设计稿**:需求文档提到需要设计稿确认,当前 UI 规范(字体/颜色/线宽)按文档实现,后续可能需要根据设计师反馈微调
2. **男女比例差异**:是否需要在 cm 换算中区分性别(男女脸宽均值不同)?当前使用虹膜直径法天然与性别无关
3. **顶庭占比**:22% 为 Mock 数据值,实际部署后是否根据用户反馈调整比例参数
4. **非正面照角度阈值**:具体多少度算「角度过大」?建议前期收集测试数据后定阈值
---
## 12. 实测基线数值表(frontal.jpg682×811
worker 侧首次实现后的实测值,作为数值回归基线(`tests/test_pipeline.py`)。
| 量 | 方案 A(兜底,mask=None) | 方案 B(分割,主) |
|----|--------------------------|--------------------|
| hairline_source | estimated | segmentation |
| 顶庭 cm | 5.06 | 6.51 |
| 上庭 cm | 6.07 | 4.08 |
| 中庭 cm | 7.23(实测) | 7.23(实测) |
| 下庭 cm | 5.64(实测) | 5.64(实测) |
| 全脸高 cm | 24.01 | 23.47 |
| 眼宽 cm | 2.52 | 2.52 |
| 脸宽 cm | 12.49 | 12.49 |
| 两眼间距 cm | 3.24 | 3.24 |
| px_per_cm(虹膜法) | 15.06 | 15.06 |
| head_pose (yaw/pitch/roll) | 15.73 / 20.93 / 3.40 | 同左 |
> 说明:中/下庭、七眼、px_per_cm、姿态在两方案下一致(均为实测);顶/上庭方案 A 为
> 比例推算、方案 B 为分割实测,二者差异正体现"方案 B 取真实发际线/头顶"的价值。
> 数值回归测试固定走方案 A(确定性、与 torch 无关),容差 1%。
### 运行环境实测要点(worker = RTX 5090
- **GPU 架构兼容性**:本 worker 为 **RTX 5090compute capability 12.0 / Blackwell, sm_120**
锁定的 `torch==2.2.2+cu121` 仅编译到 **sm_90**,在 5090 上执行 CUDA 算子会报
`CUDA error: no kernel image is available``hair_segmenter._select_device()` 已做
一次小算子探测,失败自动回退 **CPU**BiSeNet CPU 推理约 0.31s/张,方案 B 正常可用)。
- **要真正用上 5090 GPU**:需换装支持 sm_120 的构建(**torch cu128,≥2.7**,配套
torchvision),代码无需改动(`_select_device` 会自动选 CUDA)。可设 `FORCE_CPU=1` 强制 CPU。
---
> **文档版本**: v2.0
> **创建日期**: 2026-06-13v2.0 修订:修复循环论证/分辨率/字体/numpy 等问题,引入方案 B 分割 + solvePnP 姿态)
> **依赖模型**: MediaPipe Face Mesh (468 landmarks) + BiSeNet face-parsing (头发分割)
> **测量策略**: 眉心以下实测关键点 + 方案 B 分割取真实发际线/头顶(方案 A 比例推算兜底)
+182
View File
@@ -0,0 +1,182 @@
# 接口11 运行记录 — `image/hair_test.jpg`
> 实测时间:2026-07-15
> 调用:`POST http://127.0.0.1:8187/api/v1/hairline/grow`
> 鉴权:`X-Internal-Token: dev-shared-secret-2026`
> 输入图:`image/hair_test.jpg`1257×1495
> `hairline_id``chang_zhixian`(直线);其余全部走接口默认值
> 业务结果:`code=0``rid=bc7205a4`
> 产物目录:`docs/iface11_hair_test_run/`
---
## 1. 本次调用用到的全部默认参数
未在 Form 里显式传的参数均取 `app.py` / `generate_hairline_grow` 默认值;下表即本次实际生效值。
| 参数 | 本次值 | 说明 |
|------|--------|------|
| `hairline_id` | `chang_zhixian` | **必填**。发际线类型 = change_hair 的 `hair_id`(直线) |
| `gen_backend` | `swaphair` | 生成后端:换发型 LoRA |
| `hairgrow_strength` | `0.75` | 仅 `hairgrow` 后端用;本次未走该路径 |
| `is_hr` | `false` | 高清关闭(576×768 档,非 1152×1536 |
| `seg_model` | `segformer` | 头发分割模型 |
| `erode_cm` | `0.6` | baseline 参考内缩(cm);pushed 下影响很小 |
| `hairline_push_cm` | `1.0` | 发际线内轮廓径向外推距离(cm) |
| `hairline_edge` | `column` | 兼容入参;当前内轮廓提取不再按它分支 |
| `swap_mode` | `ext_mask` | 把 pushed 遮罩作为 `ext_mask` 传给 swapHair |
| `edge_erode_px` | `3` | 贴图前遮罩内缩像素 |
| `denoising_strength` | `0.6` | 换发型 webui 重绘强度 |
| `mb_levels` | `5` | 多频段金字塔层数 |
| `blend_method` | `multiband` | 接缝融合:多频段金字塔 |
| `color_match` | `true` | 融合前 Reinhard 颜色迁移 |
| `color_match_strength` | `1.0` | 颜色迁移强度(全迁移) |
| `mb_feather_px` | `1` | 多频段最细层掩码轻羽化 |
| `transition_band_px` | `-1` | keep-region 过渡带:自动按层数 `2**n` |
| `redraw` | `false` | 发际线带重绘关闭 |
| `inpainting_fill` | `1` | change_hair 填充噪声 |
| `mask_blur` | `11` | change_hair 遮罩边缘模糊像素 |
| `mask_dilate_scale` | `1.0` | change_hair 遮罩膨胀缩放 |
| `comfyui_prompt` | `null` | 仅 `redraw`+Flux-2 路用;本次未用 |
| `mask_type` | `pushed`(固定) | 代码写死,不可选 |
图片入参:仅传了 `image_file`(三选一中的文件上传)。
---
## 2. 返回元数据(无 base64
| 字段 | 值 |
|------|-----|
| `px_per_cm` | 47.5311(虹膜直径标定) |
| `erode_px` | 29(≈ 0.6cm × px_per_cm |
| `hair_pixels` | 186798 |
| `closed_pixels` | 191712 |
| `mask_pixels` | 140299 |
| `image_size` | 1257 × 1495 |
| `timings_ms.mask` | 1462 |
| `timings_ms.swap` | 5596 |
| `timings_ms.blend` | 220 |
| `redraw.enabled` | false |
| 总耗时(curl | ≈ 7.4 s |
完整精简 JSON`docs/iface11_hair_test_run/response_meta.json`
完整原始响应(含 base64):`docs/iface11_hair_test_run/response.json`
---
## 3. 管线分步说明与产物
管线:① pushed 遮罩 → ② swapHair 生成 → ③ 硬贴回 → ④ multiband 融合。
各步图保存在 `docs/iface11_hair_test_run/steps/`
### ①-a 发际线分割线(baseline
- **做什么**:MediaPipe 关键点连成眉骨折线(中心为 151 眉心),并向左右边缘水平延长。
- **图**[`steps/baseline_overlay.jpg`](iface11_hair_test_run/steps/baseline_overlay.jpg)
- **含义**:黄线 = baseline;151 中心点为后续径向外推圆心。
### ①-b 分割线上半区(upper
- **做什么**:baseline 折线以上的多边形区域,作为后续裁剪范围。
- **图**[`steps/upper_overlay.jpg`](iface11_hair_test_run/steps/upper_overlay.jpg)
- **含义**:青 = 上半区。
### ①-c 头发分割(hair_seg
- **做什么**:SegFormer 得到头发二值掩码。
- **图**[`steps/hair_seg_overlay.jpg`](iface11_hair_test_run/steps/hair_seg_overlay.jpg)
- **含义**:绿 = 原始头发像素(本次 `hair_pixels=186798`)。
### ①-d / ①-e(旧 eroded/closed 中间步)
- pushed 模式**不走**这两步;返回字段为空字符串。
- `top_fill_overlay` / `closed_overlay`:本次无图。
### ①-f 头发内轮廓线(hairline
- **做什么**:取头发朝脸一侧的内轮廓(额头弧 + 两侧到下颌),有序折线。
- **图**[`steps/hairline_overlay.jpg`](iface11_hair_test_run/steps/hairline_overlay.jpg)
- **含义**:绿 = 内轮廓;黄 = baseline。
### ①-g 外推发际线(pushed
- **做什么**:以眉心 151 为圆心,内轮廓逐点向外推 `hairline_push_cm=1.0`(≈ 47.5 px),与 baseline 组闭合区域。
- **图**[`steps/pushed_overlay.jpg`](iface11_hair_test_run/steps/pushed_overlay.jpg)
- **含义**:青 = 外推线;红 = 外推遮罩区域。
### ① 最终遮罩
- **叠加图**[`steps/mask_overlay.jpg`](iface11_hair_test_run/steps/mask_overlay.jpg) — 红 = 遮罩区(贴回/生成区)
- **纯遮罩**[`steps/mask.png`](iface11_hair_test_run/steps/mask.png) — 白 = 生成/贴回区
- 本次 `mask_pixels=140299`;贴图前再内缩 `edge_erode_px=3`
### ② 生成全帧(swap_raw
- **做什么**`gen_backend=swaphair` + `swap_mode=ext_mask`,把遮罩交给 change_hair`:8801`),LoRA=`chang_zhixian``denoising_strength=0.6`
- **图**[`steps/swap_raw.jpg`](iface11_hair_test_run/steps/swap_raw.jpg)
- **含义**:生成结果已与原图同分辨率对齐;耗时约 5.6 s。
### ③ 严格按遮罩贴回(hard_paste
- **做什么**:遮罩内用生成图,遮罩外保持原图,无融合。
- **图**[`steps/hard_paste.jpg`](iface11_hair_test_run/steps/hard_paste.jpg)
- **含义**:用于对比接缝融合前后差异。
### ④ 融合权重 alpha + 最终结果
- **做法**`blend_method=multiband``mb_levels=5``color_match=true`(强度 1.0),`mb_feather_px=1`
- **alpha**[`steps/alpha.png`](iface11_hair_test_run/steps/alpha.png) — 白 = 更多采用生成图
- **最终输出**[`steps/final.jpg`](iface11_hair_test_run/steps/final.jpg)(副本:[`final.jpg`](iface11_hair_test_run/final.jpg)
- **输入对照**[`steps/input.jpg`](iface11_hair_test_run/steps/input.jpg)
### ⑤ 发际线带重绘(本次关闭)
`redraw=false`,故 `redraw_band_overlay` / `redraw_a` / `redraw_c` 均为空。
---
## 4. 最终输出
**主结果文件**[`docs/iface11_hair_test_run/final.jpg`](iface11_hair_test_run/final.jpg)
含义:同一人、同一发型观感下,按直线发际线类型(`chang_zhixian`)压低发际线后的合成图;遮罩外像素保持原图不动。
---
## 5. 复现命令
```bash
curl -sS -X POST "http://127.0.0.1:8187/api/v1/hairline/grow" \
-H "X-Internal-Token: dev-shared-secret-2026" \
-F "image_file=@image/hair_test.jpg" \
-F "hairline_id=chang_zhixian" \
-o docs/iface11_hair_test_run/response.json
```
(其余参数全部省略即可走默认值。)
---
## 6. 产物清单
```
docs/接口11_hair_test运行记录.md ← 本文档
docs/iface11_hair_test_run/
final.jpg ← 最终结果
response.json ← 完整 API 响应(含 base64
response_meta.json ← 去掉大图的元数据
steps/
input.jpg
baseline_overlay.jpg
upper_overlay.jpg
hair_seg_overlay.jpg
hairline_overlay.jpg
pushed_overlay.jpg
mask_overlay.jpg
mask.png
swap_raw.jpg
hard_paste.jpg
alpha.png
final.jpg
```
@@ -1,363 +0,0 @@
# 接口 2:C 端生发 — 技术实现方案(发际线预览 + 生发图)
> **当前状态(2026-06-15):两步均已实现。** ① 发际线曲线叠加预览图(§1~§9);
> ② 生发后图片(§10ComfyUI/Flux)。`results[]` 每项同时返回 `image_base64`(预览) 与
> `grown_image_base64`(生发图)。下文 §0「本期只做预览」为初版历史描述,以本说明与 §10 为准。
> 在 **高性能 worker(GPU 机)** 上实现,与接口 1 同机。对外接口经网关代理(见 [`系统架构-网关与高性能后端.md`](系统架构-网关与高性能后端.md))。
> 发际线检测算法移植自 **head3d** 项目(已实现 502 点 mesh + UV 贴图方案)。
---
## 0. 本期范围(第一步)
接口 2 输入用户正面照 + **性别**,按性别对应的发际线类型贴图,**逐张把发际线曲线渲染到照片上**,输出多张「叠加了建议发际线的预览图」,按固定顺序返回。
- 第一步「渲染遮罩/预览图」**已实现**:`results[].image_base64` = 「原照片 + 发际线曲线叠加图」。
第二步「文生图生发」**也已实现**(见 §10),`results[].grown_image_base64` = 植发3个月效果图。
(以下 §0 文字为初版"只做预览"的历史背景,现已扩展为预览 + 生发两步。)
- 排序 `order` 本期不计算,按贴图顺序 `1..N`
---
## 1. 与现接口文档的差异(接口 2 需同步更新 `接口文档.md`)
| 项 | 现状 | 本期改为 |
|----|------|----------|
| 输入参数 | `beauty_enabled` | **新增必填 `gender``male`/`female`**`beauty_enabled` 保留但本期不生效 |
| 输出 `results[]` 数量 | Mock 2 个 | = 该性别的贴图数量(**female 5 张 / male 4 张** |
| `results[].image_url` | 生发后图片 | **本期 = 发际线曲线叠加在原照片上的预览图** |
| `results[].hairline_type` | 中文(花瓣形…) | **英文 key**`flower`/`wave`/`heart`/`ellipse`/`straight`/`m`/`inverse_arc` |
| `results[].order` | 排序 | 本期固定 `1..N`(不排序) |
| 错误码 1004(性别判断异常) | 待确认 | `gender` 改为必填入参 → **不再自动判别性别**1004 仅在 `gender` 非法值时使用(或弃用) |
> ⚠️ 这是接口 2 的**有意契约变更**(加入参 + 改输出语义),需在 `接口文档.md` 接口 2 章节同步。其余 4 个接口契约不变。
### gender → 贴图集合
`hairline_texture/` 目录下贴图(512×512 RGBA,白色发际线曲线在顶部 UV 条带):
| gender | 贴图文件 | hairline_type (key) |
|--------|----------|---------------------|
| female | `girl_ellipse.png` | `ellipse` |
| female | `girl_flower.png` | `flower` |
| female | `girl_heart.png` | `heart` |
| female | `girl_straight.png` | `straight` |
| female | `girl_wave.png` | `wave` |
| male | `man_ellipse.png` | `ellipse` |
| male | `man_m.png` | `m` |
| male | `man_straight.png` | `straight` |
| male | `man_ inverse_arc.png` | `inverse_arc` |
> 注意 `man_ inverse_arc.png` 文件名里有个空格,代码里按 `gender + '_' + key` 生成文件名时需保留/清洗一致。建议**启动时扫描目录**建立 `{gender: [(key, path)]}` 映射,而不是硬编码文件名,并把文件名规范化(去空格)。
---
## 2. 已从 head3d 复制到本项目的文件
全部放在 `hairline/` 包下(已复制,agent 直接用):
```
hairline/
├── __init__.py
├── constants.py # 17 锚点、UV 偏移、分割类别、矢状-arc 常量、HF 模型 id
├── obj_io.py # OBJ 读写
├── face_landmarks.py # MediaPipe Tasks FaceLandmarker 封装(用 face_landmarker.task
├── face_parsing.py # SegFormer 人脸分割封装
├── hairline_2d.py # 射线检测发际线 2D + 平滑 + 回退
├── lift_3d.py # 2D→3D 矢状-arc 提升 + 中间行 + assemble 502 点
├── extract_hairline.py # 主管线(image → 502 点),可复用 run()
├── _index_map_data.py # 468→OBJ indexMapbuild_extended_obj 用,本期渲染不需要)
├── _mediapipe_subprocess.py# WSL 下子进程跑 MediaPipe 的兜底(可选)
├── models/
│ ├── face_landmarker.task # MediaPipe 模型(3.7MB,已复制)
│ └── face-parsing/ # SegFormer 权重(离线,已下载,见 OFFLINE_ASSETS.md
│ ├── config.json
│ ├── preprocessor_config.json
│ └── model.safetensors
├── mesh/
│ ├── face_ext.obj # 502 点扩展 mesh + UV + 三角面(渲染器读这个)
│ └── face.obj # 原始 468 点 mesh(参考/重生成用)
└── reference/
├── texture0.png # head3d 原 5 弧线贴图(核对 UV 用)
└── uv_template.png # UV 布局参考
```
发际线类型贴图在仓库根目录 `hairline_texture/`(用户提供,9 张)。
### 2.1 移植后需要修改的集成点
1. **`face_landmarks.py``DEFAULT_MODEL_PATH`**:原逻辑是 `dirname(dirname(__file__))/models/...`head3d 里模块在 `python/` 子目录)。现在模块在 `hairline/` 根,该路径会指向 `hair/models/`,而模型在 `hairline/models/`。**改为** `os.path.join(os.path.dirname(__file__), "models", "face_landmarker.task")`
2. **`face_parsing.py` 离线加载**`C.HF_FACE_PARSER_MODEL` 当前是 HF 在线 id `"jonathandinu/face-parsing"`。内网/离线改为本地目录:把 `constants.py``HF_FACE_PARSER_MODEL` 指向 `hairline/models/face-parsing` 的绝对路径(`from_pretrained` 支持本地目录);或设 `HF_HUB_OFFLINE=1`
3. **相对导入**:模块用 `from . import constants`,已加 `hairline/__init__.py`,作为包导入即可(`from hairline.extract_hairline import run`)。
4. **GPU**`FaceParser(device="cuda")`worker 有 GPU。
---
## 3. 算法管线(整体)
```
输入: 用户正面照 + gender
[A] head3d 管线(复用 hairline.extract_hairline 的步骤)
- MediaPipe 468 点(face_landmarker.task
- SegFormer 人脸分割 → parse_map
- 17 锚点射线检测发际线 → 17 个 2D 点 → 平滑
- 矢状-arc 提升 → 502 点(归一化 x,y,z)
│ 失败处理:无人脸→1001
[B] 投影到图像像素
- 502 点的 (x,y) × (W,H) → 502 个 2D 图像坐标
- 读 face_ext.objUV(502) + 扩展三角面(涉及顶点 ≥468 的 64 个三角形)
[C] 逐张贴图渲染(新写的服务端渲染器,本方案核心)
for 每个该性别的发际线贴图 t:
- 对每个扩展三角形:src=UV→贴图像素, dst=投影 2D 坐标 → cv2 仿射 warp
- 累积成一张 RGBA 曲线层(贴图 alpha 控制曲线/透明)
- 把曲线层 alpha 合成到原照片上 → 预览图
[D] 输出
results[] = N 个 {image(预览图), hairline_type(key), order=1..N}
worker 侧每张图以 base64 返回(见 §6)
```
---
## 4. 渲染器(新代码,本期重点)★
head3d 把贴图渲染到照片是**浏览器 Three.js** 做的(`/preview` ortho overlay),**没有服务端实现**。本期新写一个 **OpenCV 逐三角形仿射 warp** 渲染器,无需 OpenGL 离屏上下文,确定性好、部署简单。
### 4.1 原理
face_ext.obj 的 502 顶点里:
- `[0..467]` MediaPipe 点,其中 17 个 `MP_TOP_ANCHORS` 是发际线 ribbon 的**下边沿**
- `[468..484]` 中间行、`[485..501]` 发际线行,是 ribbon 的中、上两行。
这 34 个新点 + 17 个锚点之间连成 64 个三角形(ribbon),它们的 UV 落在贴图**顶部条带**V_raw≈0.67..0.94,正是发际线曲线所在)。所以只要把**这 64 个三角形**按 UV→图像坐标 warp,就能把贴图里的发际线曲线贴到照片的额头/发际线区域。
### 4.2 步骤
```python
# 伪代码
def render_hairline_overlay(photo_bgr, points502_norm, ext_faces, uv502, texture_rgba):
H, W = photo_bgr.shape[:2]
# 502 点投影到图像像素
img_xy = points502_norm[:, :2] * [W, H] # (502, 2)
TW, TH = texture_rgba.shape[1], texture_rgba.shape[0] # 512, 512
overlay = np.zeros((H, W, 4), np.float32) # 累积曲线层 RGBA
for (i, j, k) in ext_faces: # 仅扩展三角形(顶点含 ≥468
dst = img_xy[[i, j, k]].astype(np.float32) # 图像坐标
# UV → 贴图像素。注意 flipY:贴图 y = (1 - v_raw) * TH
src = np.array([[uv502[v][0]*TW, (1-uv502[v][1])*TH] for v in (i,j,k)], np.float32)
M = cv2.getAffineTransform(src, dst)
warped = cv2.warpAffine(texture_rgba, M, (W, H), flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=(0,0,0,0))
# 三角形掩码,避免覆盖整张 warp 结果
tri_mask = np.zeros((H, W), np.uint8)
cv2.fillConvexPoly(tri_mask, dst.astype(np.int32), 255)
sel = tri_mask > 0
overlay[sel] = warped[sel] # 逐三角形写入(相邻共享边,覆盖等价)
# alpha 合成到原照片
a = overlay[:, :, 3:4] / 255.0
out = photo_bgr.astype(np.float32)
out = out * (1 - a) + overlay[:, :, :3][..., ::-1] * a # RGBA→BGR 注意通道序
return out.astype(np.uint8)
```
### 4.3 注意点
- **通道序**:贴图是 RGBA,照片 OpenCV 是 BGR,合成时注意 R/B 调换。
- **flipY**face_ext.obj 的 UV 是 V_rawV=1 对应贴图顶部),转贴图像素 y 要 `(1 - v)`,与 head3d Three.js `texture.flipY=true` 一致。
- **只 warp 扩展三角形**:从 face_ext.obj 筛出顶点索引含 ≥468 的面(约 64 个)。不要 warp 整脸。
- **抗锯齿/接缝**:逐三角形 `fillConvexPoly` 掩码可能在共享边留 1px 缝。可对 `tri_mask` 略膨胀,或最后对 overlay alpha 做轻微羽化。先跑通看效果再优化。
- **裁剪到额头**:曲线层只在 ribbon 区域有内容(贴图其余透明),天然不会画到脸下半部。
---
## 5. 依赖
worker 已有(接口 1):`opencv-python``numpy``Pillow`、torch(CUDA)。接口 2 **新增**
```
mediapipe>=0.10 # Tasks Vision FaceLandmarker(注意与接口1的 solutions API 可共存)
transformers>=4.40 # SegFormer 人脸分割
# torch/torchvision 已由接口1引入(worker CUDA 版)
```
> ⚠️ **两套人脸分割模型**:接口 1 用 BiSeNet`79999_iter.pth`),接口 2 用 head3d 的 SegFormer`jonathandinu/face-parsing`)。两者并存,显存/内存够(worker 32G+GPU)。后续可评估是否统一为一个分割模型,本期先各用各的,**不强行合并**。
>
> ⚠️ **MediaPipe API 差异**:接口 1 用 `mp.solutions.face_mesh`468 点 + 虹膜 refine),接口 2 用 `mp.tasks.vision.FaceLandmarker`(读 `.task` 文件)。同一个 mediapipe 包都支持,但版本需兼容两者(建议先用一个版本把两接口都跑通)。
---
## 6. worker 集成(接口 2 handler
`app.py` 替换 `/api/v1/hair/grow` 的 Mock
```
1. 解析图片(三选一)+ 读 gender(必填,male/female;非法→1004 或 1008 参数错误)
2. 校验(大小/解码/分辨率,同接口1)
3. 跑 hairline.extract_hairline 的步骤拿 502 点(无人脸→1001)
4. 按 gender 取贴图集合(启动时扫描 hairline_texture/ 建映射)
5. for 每张贴图: render_hairline_overlay → PNG
6. results[] = [{image_base64, hairline_type, order}], 逐张 base64
7. return ok({"results": results})
```
- **拆分架构**worker 返回 `results[].image_base64`,**不落盘不拼 URL**。网关把每个 `image_base64` 落盘改写成 `image_url`(架构文档 §9 的映射表需支持**数组里的图片字段** `results[].image`)。
- 模型单例:`FaceLandmarker``FaceParser` 在模块加载时初始化一次,避免每请求重建。face_ext.obj 的 UV/faces 也只解析一次缓存。
---
## 7. 离线资产(内网部署)
接口 2 新增需要随项目带入内网的模型(已下载,登记到 `OFFLINE_ASSETS.md`):
- `hairline/models/face_landmarker.task`MediaPipe~3.7MB
- `hairline/models/face-parsing/`SegFormerconfig + preprocessor + model.safetensors
> SegFormer 加载方式改本地路径后,内网无需联网(见 §2.1)。
---
## 8. 开发步骤与验证(agent 执行)
| 阶段 | 内容 | 验证 |
|------|------|------|
| **M0 跑通管线** | 修好集成点(§2.1),用一张人像跑 `hairline.extract_hairline.run()` 得 502 点 JSON | 502 点、valid_hairline 有 true |
| **M1 解析 mesh** | 读 face_ext.obj 拿 UV + 扩展三角面(顶点≥468 的面),缓存 | 打印扩展面数(~64)、502 个 UV |
| **M2 渲染器** | 实现 `render_hairline_overlay`,对 1 张贴图渲染 | 输出预览图,**目视**:发际线曲线贴在额头正确位置、跟随脸 |
| **M3 全量 + 性别** | 扫描 `hairline_texture/` 建 gender→贴图映射,按性别渲染 N 张 | female 出 5 张、male 出 4 张,hairline_type 对 |
| **M4 接 app.py** | handler + gender 必填 + base64 返回 | curl 验证 results 数量/字段;无人脸→1001;缺 gender→报错 |
| **M5 网关映射** | 网关支持 `results[].image_base64``image_url`(网关任务书侧) | 端到端经网关返回 image_url,公网可访问 |
**M2 是关键里程碑**:渲染器对齐效果好不好,决定整个接口可用性,先用几张测试人像目视确认贴合。
---
## 9. 风险与待办
1. **新贴图 UV 是否与 texture0 完全一致**:本方案假设 9 张贴图沿用 head3d 的顶部条带 UV 布局(已肉眼确认曲线在顶部)。M2 渲染若位置偏移,核对贴图内容所在的 V 区间与 `UV_MIDDLE_DV/UV_HAIRLINE_DV`
2. **接缝/锯齿**:逐三角形 warp 的共享边接缝,M2 跑通后按 §4.3 优化。
3. **歪头/非正面**head3d 矢状-arc 假设近正脸,大角度发际线贴合差。可复用接口 1 的 solvePnP 做前置姿态校验(可选)。
4. **秃头/高发际线/刘海**:SegFormer 找不到头发时射线回退几何外推,曲线可能偏高;valid_hairline 标记可用于提示。
5. **排序**:本期 order=1..N。后续排序需定义依据(脸型/额型匹配度)。
6. **真正的生发(文生图)**:**已实现**(§10)——把划线图 + 遮罩送 ComfyUI(add_hair.json, Flux-2)
出生发图,`results[].grown_image_base64` 返回。后续可优化:发际线目标位置下移以增强"植发填充"效果、
同步 N 张较慢可改异步。
---
## 10. 第二步:生发图生成(ComfyUI + Flux inpaint)★ 新增
> 在「发际线预览」基础上,**新增真实生发后图片**:把发际线划线 + 遮罩送入本机
> ComfyUIFlux-2 Klein 9b,端口 **8182**)跑 `add_hair.json` 工作流,得到「植发 3
> 个月」效果图。**worker 不跑 Flux**,只做图像准备 + 调 ComfyUI HTTP API + 取回结果。
### 10.1 需求与决策(已与需求方确认)
| 项 | 决策 |
|----|------|
| 生成数量 | **一次请求生成该性别全部 N 种**female 5 / male 4),与预览一一对应 |
| 遮罩区域 | **头发区域 新发际线以下** —— SegFormer 头部(头发)区域,下边界拓到新发际线曲线 |
| 返回方式 | **同步阻塞**到 ComfyUI 出图再返回(N 张串行,单请求耗时可达数分钟,网关需调大超时) |
| ComfyUI 接入 | 标准 HTTP API`/upload/image` + `/prompt` + `/history` + `/view`),8182 无鉴权,自定义节点已装齐,`noise_seed` 每次随机 |
### 10.2 `add_hair.json` 工作流解读
Flux-2 Klein 9b 的参考式局部重绘(denoise=1 + ReferenceLatent):
- **节点 26 `LoadImage`** 是唯一外部输入,同时给出 **图像**[0])和 **遮罩**[1],从 PNG 的
alpha 通道取,ComfyUI 约定 `mask = 1 alpha`,即 **alpha 透明处 = 要重绘的区域**)。
- 提示词(节点 60):保留原图一切,**先清除画面内所有黑色标注划线**,仅在划线范围内生成
「植发 3 个月」头发,发际线边界刚好止于划线处,与原生发自然衔接。
- 节点 32/37/39/44 做遮罩填洞、缩放到 1024、ImageAndMaskPreview 组装 → VAEEncode 参考。
- 节点 10 VAEDecode → 节点 62 ColorMatch(与原图调色一致)→ 节点 17 SaveImage = 生发图。
> **关键**:worker 要做的就是**程序化复现「手绘 painted-masked」的输入**——给节点 26 一张
> RGBA:**RGB = 画了黑色发际线划线的照片,alpha = 遮罩(重绘区透明)**。工作流其余不动。
### 10.3 遮罩算法(参考 `/home/xsl/headmark`,用黑贴图简化)
headmark(发际线蒙板工具)的 5 步法:① MediaPipe 取**额头上半区域** → ② **整个头部分割**
③ 两者**交集** = ROI → ④ 在 ROI 内**找发际线**(手绘划线)→ ⑤ 发际线 + 头型**围成闭合区域**填充 = 遮罩。
> **我们的简化**:发际线不是手绘、需要检测的;而是用 `hairline_texture_black/` 的黑曲线
> **程序化渲染**出来——位置已知,**省掉 headmark 第 4 步的检测**,直接拿渲染出的曲线当边界。
```
已有:502 点、SegFormer parse_map
build_inpaint_mask(photo, parse_map, landmarks, hairline_texture_black/t):
[1] upper_region = MediaPipe 额头边界关键点
[21,68,104,69,108,151,337,299,333,298,251] 连线,向上+两侧补到图像边缘,填充
= headmark step1:发际线以上的"上部区域")
[2] head_mask = SegFormer 头部轮廓(hair skin 其余面部类,排除 bg/neck/cloth
= headmark step2headmark 用 head-segmentation 包/Selfie,本项目复用已加载的 SegFormer)
[3] roi = upper_region ∩ head_mask = headmark step3
[4] 划线图 marked + curve_mask = render(photo, 502点, 黑贴图 t) # 烧黑线 + 得到曲线像素
[5] mask = roi 中"在发际线曲线以上(更小 y)"的部分 → 闭运算去洞 + 取最大连通域填充 + 轻羽化
= headmark step5:发际线曲线 + ROI 上边界围成的闭合区域)
return marked(划线图), mask
```
for 每种发际线贴图 t(该性别全部):
- `marked, mask = build_inpaint_mask(...)`
- `comfy_input = RGBA(rgb=marked, alpha=255*(1mask))` # **透明=重绘区**,对齐 ComfyUI `mask=1alpha`
- `grown = comfyui_run(comfy_input)`(§10.4
- `results[t] = { preview(白线预览,已有), grown(生发图,新增) }`
- `hairline_texture_black/`:与 `hairline_texture/` 同 9 张曲线,但**黑色**,烧划线 + 当遮罩下边界。
- **头部分割来源**:先复用已加载的 SegFormer(零新增依赖);若头型轮廓不够干净,可改用
headmark 同款 `head-segmentation` 包(子进程,避免与 MediaPipe GPU 冲突)。
- 遮罩边界精度 **M5 必须 dump 可视化核验**(划线图 / ROI / 最终 mask 三张叠图)。
### 10.4 ComfyUI 客户端(`hairline/comfyui.py`,新增)
```
COMFYUI_URL = env COMFYUI_URL (默认 http://127.0.0.1:8182)
WORKFLOW = add_hair.json(启动时加载一次)
run(comfy_input_png_bytes):
1. POST /upload/image (multipart) → {name, subfolder, type:"input"}
2. wf = deepcopy(WORKFLOW); wf["26"]["inputs"]["image"] = name
wf["6"]["inputs"]["noise_seed"] = 随机
3. POST /prompt {prompt: wf, client_id} → prompt_id
4. 轮询 GET /history/{prompt_id} 直到完成(带超时)
5. node "17".images[0] → GET /view?filename&subfolder&type=output → PNG bytes
返回 PNG bytes
```
### 10.5 接口契约变更(同步更新 `接口文档.md`)
`/api/v1/hair/grow``results[]` 每项**新增生发图字段**worker 返回 base64,网关落盘改 URL):
| 字段 | 说明 |
|------|------|
| `image_base64` | (已有)发际线**预览图**(白线叠加) |
| `grown_image_base64` | (新增)**生发后图片**ComfyUI 出图) |
| `hairline_type` / `order` | 同前 |
> ⚠️ **同步 + N 张 Flux** → 单请求很慢。网关/前端超时要放大;worker 自身并发=1。
> 失败处理:某张 ComfyUI 失败时该项 `grown_image_base64` 置空并标记,不整请求失败(待定,实现期确认)。
### 10.6 开发步骤(M5+
| 阶段 | 内容 | 验证 |
|------|------|------|
| **M5 遮罩** | `build_inpaint_mask` + 黑线渲染 + 合成 RGBA | 目视:划线图正确、遮罩=头发∪发际线以下,透明区对 |
| **M6 ComfyUI 客户端** | `comfyui.py` 跑通一张(8182 起服务后) | 上传→prompt→取回 PNG,得到生发图 |
| **M7 接 service/app** | generate 时每种附带 grownhandler 返回新字段 | curlresults 含 grown_image_base64(合法 PNG |
| **M8 文档/测试** | 更新接口文档;mock ComfyUI 的单测 + 真机冒烟 | pytest 绿;真机端到端出生发图 |
### 10.7 新增风险
1. **耗时**:同步 N 张 Flux,单请求数分钟级;需评估是否后续改异步/队列。
2. **ComfyUI 依赖外部进程**:8182 未起/模型未加载/节点缺失 → 该接口失败;worker `/health` 不体现 ComfyUI 状态(可加可选探测)。
3. **遮罩精度**:直接决定生发位置与自然度;M5 必须可视化核验,必要时拿手绘样本标定。
4. **GPU 共享**ComfyUI 与接口1/2 的 CPU 推理同机;显存/算力调度需观察(ComfyUI 自带 torch,支持 5090)。
---
> **文档版本**: v1.1 **创建日期**: 2026-06-14v1.1:新增 §10 生发图生成 ComfyUI 管线)
> 算法来源: head3d502 点 mesh + UV+ Flux-2 Klein 9bComfyUI add_hair.json)| 运行位置: worker(GPU) + 本机 ComfyUI(8182)
> **产出**: ① 发际线曲线叠加预览图 ② 生发后图片(植发 3 个月效果)
@@ -1,91 +0,0 @@
# 接口 3:B 端生发 — 技术实现方案(马克笔发际线检测 + 生发)
> 在 **高性能 workerGPU 机)** 实现,与接口 1/2 同机。对外经网关代理。
> B 端:医生在患者额头**用马克笔画出规划的发际线**,拍照上传。系统**检测这条手绘线**,
> 据此生成生发图。检测算法移植自 `/home/xsl/headmark` 的调研结论(黑帽 + Dijkstra)。
---
## 0. 契约(对齐 `接口文档.md` 接口3,不变)
`POST /api/v1/hair/grow-b`
| 输入 | 说明 |
|------|------|
| `marked_image_*` | 已用马克笔标注发际线的图,三选一,必填。**只需这一张**(划线图即用户照片+手绘线,不需要原图) |
| 输出 data | 决策 |
|-----------|------|
| `hair_growth_image_url` | **生发后图片**ComfyUIworker 返回 `hair_growth_image_base64` |
| `hairline_type` | 固定 **`"custom"`**(手绘定制) |
> 落盘改 URL 由网关做(架构同接口1/2)。
---
## 1. 马克笔发际线检测(核心,源自 headmark 调研)
headmark `docs/detection_research.md` 结论:全局灰度阈值不可用(笔迹平均灰度反而高于阈值、
与皮肤阴影分布重叠);推荐 **黑帽响应图 + 端点锚定 Dijkstra 最小路径**,实测误差 ≤0.5px(GT锚点)。
本项目用 **MediaPipe 锚点**(非 GT)实测平均 3.2px、中位 0px —— 对生成遮罩足够(线会膨胀成带)。
```
detect_marker_hairline(marked_bgr, landmarks, parse_map):
[1] ROI = forehead_upper_region(landmarks) ∩ head_silhouette(parse_map) # 复用接口2 mask.py
[2] 黑帽响应 bh = MORPH_BLACKHAT(gray, ksize=max(15,int(w*0.025)|1))ROI 外置 0
[3] 锚点 = MediaPipe 21(左鬓角)/251(右鬓角),各自小窗口(≈w*3%)内吸附到 bh 最大处
[4] 代价 cost = bh.max()-bh+1ROI 外设 1e6
path = skimage.graph.route_through_array(cost, 左锚, 右锚, fully_connected, geometric)
[5] 拒识:path 平均 bh 响应 < 阈值(可调) → None(上层返回 1001 "未检测到发际线划线"
return path # (N,2) row,col
```
- 依赖:**`scikit-image==0.24.0`**。⚠️ 0.25+ 强依赖 numpy≥2,会顶掉 mediapipe 的 numpy<2 →
mediapipe/SegFormer 全崩。**必须锁 0.24.x**。
- 复用接口2`forehead_upper_region` / `head_silhouette``hairline/mask.py`)、SegFormer / MediaPipe 单例。
## 2. 遮罩(检测路径只用来建遮罩;ComfyUI 输入图 = 划线图原样)
- **遮罩**path → 画成 curve_mask → 复用接口2 `mask_from_curve`ROI ∩ 曲线以上 → 闭合)
得到"发际线以上闭合区域"。
- **ComfyUI 输入图**:直接用 **marked 划线图原样**(已含医生手绘线;`add_hair.json` 节点26
本来就是"画了线的照片",提示词会清除黑线再生发)。**不需要原图、不重画线**。
- 合成 RGBARGB=marked 划线图,alpha=255mask(透明=重绘区)。复用 `compose_comfy_rgba`
## 3. 生发(复用接口2 ComfyUI 客户端)
`hairline/comfyui.run(rgba_png)` → 跑 `add_hair.json`(Flux-2)→ 生发图 PNG。同步。
## 4. worker handler`/api/v1/hair/grow-b`
```
1. marked 三选一取图(复用 resolve_image_bytes+ 校验(大小/解码/分辨率)。只需这一张。
2. landmarks(MediaPipe)+parse(SegFormer) → detect_marker_hairline
- 无人脸 → 1001;未检测到画线 → 1001 "未检测到发际线划线"
3. 遮罩(mask_from_curve) + marked原样 → RGBA → comfyui.run → 生发图
4. return ok({ hair_growth_image_base64: 生发图, hairline_type: "custom" })
异常 → 1007;重活 run_in_threadpool。
```
## 5. 开发步骤
| 阶段 | 内容 | 验证 |
|------|------|------|
| **M1 检测** | `hairline/marker_detect.py`(黑帽+锚点+Dijkstra+拒识) | headmark test_image:检测线贴合真值;无线图被拒识 |
| **M2 遮罩** | path→遮罩(复用 mask_from_curve) + marked原样 RGBA 合成 | 目视:遮罩贴合发际线 |
| **M3 接 app** | grow-b 真实实现(仅 marked) + 输出字段 + 1001 | curlgrown 合法PNG/type=custom;无线→1001 |
| **M4 测试** | 检测/mask 单测 + mock-ComfyUI 集成 + 真机冒烟 | pytest 绿;真机出生发图 |
## 6. 风险
1. **锚点偏差/路径端点偏移**MediaPipe 21/251 吸附后仍可能在鬓角端有偏移(实测 max~42px,少数点)。
膨胀成带 + 遮罩闭合可吸收;必要时改进吸附窗口或端点截断。
2. **没画线/画线极浅**:靠拒识阈值(路径平均黑帽响应)兜底,阈值需在更多真实图上标定。
3. **医生手绘线毛刺/杂线**ComfyUI 输入图用 marked 原样(含手绘线),提示词会清除黑线;
检测出的干净 path 只用于建遮罩。若手绘过乱影响生成,可改为在 marked 上重画干净检测线(备选)。
4. **抬头纹/眉毛/发丝干扰**:黑帽 + ROI + Dijkstra 平滑已大幅抑制(调研验证抬头纹零干扰),极端情况可在代价图抑制头发区域。
---
> **文档版本**: v1.0 **创建日期**: 2026-06-15 检测来源: headmark(黑帽+Dijkstra)|
> 生发: 复用接口2 ComfyUI(add_hair.json) 运行位置: worker(GPU) + 本机 ComfyUI(8182)
@@ -1,219 +0,0 @@
# 接口 4:用户特征 — 网关侧实现方案
> **结论先行**:接口 4 不碰任何本地 GPU/模型,只是「调一次外网豆包视觉模型 + 解析 JSON」。
> 因此**在网关本地实现**(不转发给 worker)最合理:网关本就是对外那台、天然有公网出口;
> worker 由此保持纯内网/离线。本文档供网关开发照做。
>
> 算法来源 `/home/xsl/fuyan`FaceArk.py);worker 侧已有一版可跑通的实现
> `face_features.py`(用 volcengine SDK),可直接作为「逻辑参考」。
---
## 0. 与现状的差异
- 现网关 `gateway/app.py``/api/v1/face/features` 是**盲转发**给 worker
```python
@app.post("/api/v1/face/features", tags=["人脸分析"])
async def face_features(request: Request):
return await _proxy(request, "/api/v1/face/features")
```
- 改为**网关本地处理**(解析图片 → 调豆包 → 返回),**不再转发**。
- 其余 4 个接口(1/2/3/5)仍然盲转发给 worker,不变。
---
## 1. 对外契约(不变,`接口文档.md` 接口4
- `POST /api/v1/face/features`
- 输入:图片三选一(`image_file` / `image_url` / `image_base64`),无其它参数。
- 输出:标准信封 `{code, message, request_id, data}`,其中 `data.features` 是一个
**JSON 字符串**(不是对象),内含几十项面部特征。无人脸返回 **1001**。
---
## 2. 依赖与配置
### 2.1 依赖
**不需要 volcengine SDK**。豆包/方舟是 **OpenAI 兼容** 接口,网关用现成的 `httpx` 直接调即可,保持轻量。
(若想省事,也可 `pip install "volcengine-python-sdk[ark]"` 直接照搬 worker 的 `face_features.py`,二选一。)
### 2.2 配置(加到 `gateway/config.json`,含密钥不入 git
```json
{
"ark": {
"api_key": "14fc0280-fc65-462d-ac2d-50178c0212e3",
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
"model": "doubao-seed-1-6-vision-250815",
"timeout_seconds": 60
}
}
```
- `config.example.json` 里放占位(`"api_key": "your-volcengine-ark-api-key"`)。
- 也可用环境变量覆盖(`ARK_API_KEY` 等),按网关现有风格来。
- ⚠️ 网关机需能访问 `ark.cn-beijing.volces.com`(已实测可达,401=要鉴权即连通)。
---
## 3. 豆包调用(httpx 版)
### 3.1 请求
`POST {base_url}/chat/completions`,头 `Authorization: Bearer {api_key}`,体:
```json
{
"model": "doubao-seed-1-6-vision-250815",
"messages": [{
"role": "user",
"content": [
{ "type": "image_url", "image_url": { "url": "<图片URL 或 base64 data URI>" } },
{ "type": "text", "text": "<下面 §3.3 的 PROMPT>" }
]
}]
}
```
返回里取 `resp["choices"][0]["message"]["content"]`(是个带 ```json 包裹的字符串)。
### 3.2 图片怎么喂
- 传了 `image_url` → **直接把这个 URL 塞进去**(豆包自己去拉,最省)。
- 传了 `image_file` / `image_base64` → 转成 **base64 data URI**
`data:image/jpeg;base64,XXXX`PNG 头 `\x89PNG` 用 `image/png`,否则 `image/jpeg`)。
(已实测豆包接受 data URI,无需先把图落到公网。)
### 3.3 PROMPT(移植自 fuyan,去掉身高体重前缀,**原样使用**)
```
分析一下图片告诉我以下特征,只要答案,格式为json字符串,图片是否有人脸(有人/没人) 三庭五眼特征(答案要有三庭五眼四个字,9个字以内) 面部年龄(给出区间年龄)鼻长(鼻长适中/长鼻/短鼻) 脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 嘴型 眼袋(答案要有眼袋两个个字) 眼型 鼻型 眼皮(双眼皮/单眼皮) 法令纹(有法令纹/无法令纹) 人中(人中适中/人中长/人中短) 眉形 瞳色(答案要有瞳色两个字) 脖长(脖长适中/脖子短/脖子长) 肤色(粉一白/粉二白/粉三白/黄一白/黄二白/黄黑皮)直得分 曲得分 直曲总分(直得分-曲得分) 大量感得分 小量感得分 量感总分(大量感得分-小量感得) 面部立体度(总分十分)瞳距(毫米)对比度(对比度较强/对比度适中/对比度较弱)鼻子立体度(立体度高/立体度适中/立体度低)色相(中间表示0,最大值分别是-5和5,负数表示偏冷,正数表示偏暖)亮度(中间表示0,最大值分别是-5和5;负数表示暗沉,正数表示白皙)色度(中间表示0,最大值分别是-5和5;负数表示饱和度低,正数表示鲜艳)面部颜色对比度(10分制)四季色彩季型(净春型/暖春型/浅春型/浅夏型/冷夏型/柔夏型/柔秋型/暖秋型/深秋型/净冬型/冷冬型/深冬型)基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)量感类型(大量感/中量感/小量感)轮廓类型(轮廓偏曲/轮廓适中/轮廓偏直)动静类型(静态型/动态型)性别(男/女)
```
---
## 4. 解析 + 字段映射
1. **去包裹解析**`content` 去掉首尾 ```json ``` 后 `json.loads`,得到 doubao 的中文字段 dict。
2. **英文优先字段**(与中文并存,方便客户端直接取):
```python
_KEY_MAP = {
"脸型": "face_shape", "眉形": "eyebrow_shape", "面部年龄": "facial_age",
"动静类型": "dynamic_static_type", "性别": "gender", "基因风格": "gene_style",
}
for zh, en in _KEY_MAP.items():
if zh in data and en not in data:
data[en] = data[zh]
```
3. **无人脸判定**`data["图片是否有人脸"]` 含「没人」/「没有」→ 视为无人脸 → 返回 **1001**。
4. `data.features` = `json.dumps(data, ensure_ascii=False)`**字符串**)。
> 字段几十项:脸型/眉形/眼型/鼻型/眼袋/法令纹/人中/瞳色/脖长/肤色、三庭五眼、四季色彩季型、
> 量感/轮廓类型、各项得分、瞳距、对比度、基因风格、动静类型、性别…… 字段不固定、可增删。
---
## 5. 路由实现(替换盲转发)
`gateway/app.py`
```python
@app.post("/api/v1/face/features", tags=["人脸分析"])
async def face_features(request: Request):
from gateway.face_features import handle_features # 新增模块
return await handle_features(request) # 网关本地处理,不再 _proxy
```
`gateway/face_features.py`(新增,伪代码):
```python
import base64, json, uuid, httpx
from fastapi import Request
from fastapi.responses import JSONResponse
from gateway.config import get_config
PROMPT = "...(§3.3 原样)..."
_KEY_MAP = {...} # §4
def _env(code, msg):
return JSONResponse(status_code=200, content={
"code": code, "message": msg, "request_id": f"gw-{uuid.uuid4().hex[:8]}", "data": None})
async def handle_features(request: Request) -> JSONResponse:
form = await request.form()
f = form.get("image_file"); url = form.get("image_url"); b64 = form.get("image_base64")
provided = [x for x in (f, url, b64) if x]
if len(provided) != 1:
return _env(1007, "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个")
# 图片 → URL 或 data URI
if url:
image_ref = url
else:
raw = (await f.read()) if f is not None else _decode_b64(b64) # base64 去 data:前缀再 decode
if len(raw) > 1_000_000:
return _env(1006, "文件超出 1 MB 限制")
fmt = "png" if raw[:8] == b"\x89PNG\r\n\x1a\n" else "jpeg"
image_ref = f"data:image/{fmt};base64," + base64.b64encode(raw).decode()
cfg = get_config()["ark"]
try:
async with httpx.AsyncClient(timeout=cfg.get("timeout_seconds", 60)) as cli:
r = await cli.post(
f'{cfg["base_url"]}/chat/completions',
headers={"Authorization": f'Bearer {cfg["api_key"]}'},
json={"model": cfg["model"], "messages": [{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": image_ref}},
{"type": "text", "text": PROMPT}]}]})
r.raise_for_status()
text = r.json()["choices"][0]["message"]["content"]
data = _parse_json(text) # 去 ```json 包裹
for zh, en in _KEY_MAP.items():
if zh in data and en not in data: data[en] = data[zh]
if _no_face(data):
return _env(1001, "无法识别人像")
return JSONResponse(status_code=200, content={
"code": 0, "message": "success", "request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": {"features": json.dumps(data, ensure_ascii=False)}})
except Exception as ex:
return _env(1007, f"处理失败:{ex}")
```
> `_parse_json``s.strip()`,若以 ``` 开头去掉反引号和开头的 `json`,再 `json.loads`。
> `_no_face``"没人" in str(data.get("图片是否有人脸",""))`。
> `_decode_b64`:有 `data:` 前缀就 `split(",",1)[1]` 再 `base64.b64decode`。
> ⚠️ 网关其它接口是盲转发(不读 body);接口4 这里**读了 form**,没问题(不再转发,body 自己消费)。
---
## 6. 错误码
| 码 | 触发 |
|----|------|
| 1007 | 图片参数 0 个或多个;或调用/解析异常兜底 |
| 1006 | 文件 > 1MBfile/base64 路径) |
| 1001 | 豆包判定「没人」 |
| 0 | 正常,`data.features` 为 JSON 字符串 |
---
## 7. worker 侧回收(迁移后做)
接口4 改由网关处理、不再转发后,worker 的 `/api/v1/face/features` 就成了死代码。建议:
- worker `app.py` 把接口4 **回退为 Mock**(或保留真实实现做直连兜底,二选一);
- worker `requirements.txt` 去掉 `volcengine-python-sdk[ark]`worker 保持无外网依赖);
- `worker_config.json` 去掉 `ark_api_key`(密钥只放网关配置)。
- worker 仓库现有 `face_features.py` 可作为网关实现的逻辑参考,迁移完成后 worker 侧可删。
> 这步不阻塞网关实现;先把网关跑通,worker 回收随后做。
---
## 8. 自测
```bash
# 经网关(对外,客户端不带 token)
curl -s -X POST https://hair.xiangsilian.com/api/v1/face/features \
-F image_file=@<一张人像> | python -m json.tool
# 期望:code 0data.features 是 JSON 字符串,parse 后含 face_shape/gender/基因风格 等几十项
curl -s -X POST https://hair.xiangsilian.com/api/v1/face/features \
-F image_file=@<风景图> ; # 期望 code 1001(豆包判无人脸;注意合成图偶有波动)
```
> 实测参考(worker 直跑 frontal):返回 42 字段,`鹅蛋脸 / 平眉 / 18-25岁 / 静态型 / 女 / 少年型`。
---
> **文档版本**: v1.0 **创建日期**: 2026-06-15 模型: 火山方舟 doubao-seed-1-6-vision
> 运行位置: **网关**(本地 httpx 调用,不经 worker)| 逻辑参考: worker `face_features.py` / `/home/xsl/fuyan`
+191 -38
View File
@@ -15,6 +15,7 @@
| 接口 | 方法 | 路径 |
|------|------|------|
| 1 四庭七眼测量 | POST | `/api/v1/face/measure` |
| 6 四庭七眼测量 v2 | POST | `/api/v1/face/measure-v2` |
| 2 C 端生发 | POST | `/api/v1/hair/grow` |
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
| 4 用户特征 | POST | `/api/v1/face/features` |
@@ -80,11 +81,12 @@
| 1001 | 无法识别人像 | 图片中未检测到人脸 |
| 1002 | 人像分辨率过低 | 低于最低分辨率要求 |
| 1003 | 角度问题,非正面照 | 非正面 / 角度过大 |
| 1004 | 性别标签判断异常 | 男女标签无法判定 **【待确认】** 是否作为错误 |
| 1004 | gender 必填/非法 | 接口2/5 的 `gender` 缺失或非 `male`/`female` |
| 1005 | 检测到多张人脸 | 默认仅支持单人,检测到 2 人或以上时返回 |
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
| 1008 | 图片格式不支持 | 非 JPG / PNG |
| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token``/api/*` 路径鉴权) |
---
@@ -107,6 +109,8 @@
| four_courts | object | 四庭数据,见下表 |
| seven_eyes | object | 七眼数据,见下表 |
| landmarks | object | 关键分界点坐标(头顶 / 发际线 / 眉心 / 鼻翼下缘 / 下巴尖),原图像素坐标 |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
`four_courts`(四庭,自上而下):
@@ -126,18 +130,26 @@
| face_width_cm | number | 脸宽(cm |
| inter_eye_distance_cm | number | 两眼间距(cm |
| ratios | object | 七眼各段占脸宽的比例 |
| eye1 | number \| null | 从左到右第 1 段宽度(cm):人头最左 → 左脸颊(左耳外侧段)。该侧耳朵不可见时为 null |
| eye2 | number | 从左到右第 2 段宽度(cm):左脸颊 → 左眼外角 |
| eye3 | number | 从左到右第 3 段宽度(cm):左眼外角 → 左眼内角(左眼宽度) |
| eye4 | number | 从左到右第 4 段宽度(cm):左眼内角 → 右眼内角(两眼间距) |
| eye5 | number | 从左到右第 5 段宽度(cm):右眼内角 → 右眼外角(右眼宽度) |
| eye6 | number | 从左到右第 6 段宽度(cm):右眼外角 → 右脸颊 |
| eye7 | number \| null | 从左到右第 7 段宽度(cm):右脸颊 → 人头最右(右耳外侧段)。该侧耳朵不可见时为 null |
> `eye1`~`eye7` 为从左到右共 7 段宽度,与标注图竖线一一对应。最左/最右端线取自耳朵分割外缘;某侧耳朵被头发或侧脸遮挡(不可见)时该侧端线省略,对应 `eye1` 或 `eye7` 为 `null`(键始终保留),实际有效段为 5 或 6 段。`eye3`/`eye5` 为左右眼宽、`eye4` 为两眼间距,与 `eye_width_cm` / `inter_eye_distance_cm` 语义一致。
### 标注图片(UI)规范
| 项目 | 要求 |
|------|------|
| 字体及线颜色 | `#FFFFFF` 100% |
| 数值排布 | 四庭数值统一在图片**左侧**呈现;七眼间距**上下穿插**展示 |
| 字体 | PingFangSC-Regular,字号 10pt |
| 线 | 横线、竖线、虚线均为 1pt |
| 线样式 | 横线、竖线渐变消失;虚线两侧呈现箭头 |
> **【待确认】** 标注图片需提供设计稿后才能最终确定样式。
| 颜色 | 字体及所有线/箭头 `#FFFFFF` 100%,透明底 |
| 尺寸 | 字号/线宽/虚线/箭头按图片**短边自适应缩放**(非固定 pt) |
| 横线 | 5 条分界线(头顶/发际线/眉心/鼻翼下缘/下巴尖),两端渐变消失并略超出最外侧竖线;线名在线**右上方** |
| 线 | 人头最左 + 七眼 6 点 + 人头最右(最外两条取自头发分割轮廓),两端渐变消失并略超出头顶/下巴 |
| 数值排布 | 四庭数值(名 + 数值两行,**不带 cm**)统一在图片**左侧**呈现;七眼段宽**上下穿插**展示;底部统一标「单位cm」 |
| 线样式 | 段宽/庭高用**虚线 + 实心三角双箭头**标示(箭头尖端落在虚线两端) |
### 响应示例(当前 Mock 返回值)
@@ -160,7 +172,9 @@
"eye_width_cm": 3.44,
"face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44,
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 }
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 },
"eye1": 3.44, "eye2": 3.44, "eye3": 3.44, "eye4": 3.44,
"eye5": 3.44, "eye6": 3.44, "eye7": 3.44
},
"landmarks": {
"hair_top": { "x": 540, "y": 120 },
@@ -175,12 +189,72 @@
---
## 接口 6:四庭七眼测量 v2 接口
**说明**:基于[接口 1](#接口-1四庭七眼测量标注接口)的变体。与接口 1 的差异:
- **去顶庭**:不画头顶横线、不返回顶庭数据。`four_courts` 仅含上/中/下庭,`landmarks``hair_top``face_total_height_cm` 为三庭之和(不含顶庭)。
- **竖线范围**:纵向竖线从**发际线**画到**下巴尖**(接口 1 为头顶→下巴尖)。
- **不画人头最左/最右端线**:仅画七眼 6 点(左脸颊/左眼外角/左眼内角/右眼内角/右眼外角/右脸颊)共 5 段标尺,不取头发轮廓的头部端线(接口 1 会多出最左/最右 2 条头部端线、共 7 段)。
- 其余(实心三角箭头、虚线样式、字体、单位cm、七眼数据)与接口 1 一致。
**请求**`POST /api/v1/face/measure-v2`
### 输入
与接口 1 完全相同。图片参数见「通用约定 → 图片传参字段」(`image_file` / `image_url` / `image_base64` 三选一)。本接口无其他专属参数。
### 输出(data
| 字段 | 类型 | 说明 |
|------|------|------|
| annotated_image_url | string | 标注图层 PNG URL(透明底,仅标注线/文字,不含人物) |
| face_total_height_cm | number | 面部总高度(cm)= 上庭 + 中庭 + 下庭(**不含顶庭**) |
| four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio**无顶庭** |
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + **eye2~eye6** 共 5 段宽度) |
| landmarks | object | 四个关键点像素坐标(发际线/眉心/鼻翼下缘/下巴尖) |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
> 接口6 是**三庭五眼**`four_courts`/`landmarks` 不含顶庭与头顶点(无 `top_court_cm`/`hair_top`);`seven_eyes` 只含 **eye2~eye6**(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段),**无 eye1/eye7**(耳外段需头发轮廓端线,仅接口1 有)。
### 响应示例
```json
{
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"annotated_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
"face_total_height_cm": 10.32,
"four_courts": {
"upper_court_cm": 3.44, "middle_court_cm": 3.44, "lower_court_cm": 3.44,
"ratios": { "upper_court": 0.333, "middle_court": 0.333, "lower_court": 0.333 }
},
"seven_eyes": {
"eye_width_cm": 3.44, "face_width_cm": 24.08, "inter_eye_distance_cm": 3.44,
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 },
"eye2": 3.0, "eye3": 3.44, "eye4": 3.44, "eye5": 3.44, "eye6": 3.0
},
"landmarks": {
"hairline": { "x": 540, "y": 430 },
"brow_center": { "x": 540, "y": 740 },
"nose_bottom": { "x": 540, "y": 1050 },
"chin_tip": { "x": 540, "y": 1360 }
}
}
}
```
---
## 接口 2C 端生发接口
**说明**:输入用户正面照 + 性别,按性别对应的发际线类型,逐张把建议发际线渲染到照片上,输出多张方案
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染发际线曲线透明 PNG + 生发图
> **每个方案返回两张图**`image_url`=「原照片 + 发际线曲线叠加的**预览图**」;`grown_image_url`=
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」。两者均已实现,见 [`接口2-C端生发-技术实现方案.md`](接口2-C端生发-技术实现方案.md)。
> **每个方案返回两张图**`image_url`=「发际线曲线**透明 PNG**(仅白色曲线,透明底,需叠加原图显示)」;`grown_image_url`=
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」(完整人像照片)。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。
**请求**`POST /api/v1/hair/grow`
@@ -191,22 +265,27 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflowermale1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。越界/非法返回 `1007` |
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
### 输出(data
`results`:发际线方案数组,**数量 = 该性别的发际线类型数**`female` 5 个 / `male` 4 个)。每个元素:
`results`:发际线方案数组,**数量 = 所选发型数**。每个元素:
| 字段 | 类型 | 说明 |
|------|------|------|
| image_url | string | 方案**预览图** URL(发际线曲线叠加图 |
| grown_image_url | string | **生发后图片** URLComfyUI/Flux「植发 3 个月」效果图) |
| hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`female),`ellipse`/`m`/`straight`/`inverse_arc`male |
| image_url | string | 发际线曲线**透明 PNG** URL仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示 |
| grown_image_url | string | **生发后图片** URLComfyUI/Flux「植发 3 个月」效果图,完整人像照片 |
| hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`male |
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
> ⚠️ 生发图由本机 ComfyUIFlux-2,端口 8182)生成,**一次请求生成全部 N 张、同步返回**
> 单请求耗时可达数分钟,调用方超时需放大。worker 侧返回 `image_base64` / `grown_image_base64`
> ⚠️ 生发图由本机 ComfyUIFlux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**
> worker 侧返回 `image_base64` / `grown_image_base64`
> 网关落盘后改写为上表的 `image_url` / `grown_image_url`。
>
> 💡 `image_url` 为透明底 PNG,前端需用绝对定位叠加到原图上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface2.html)的 `.img-stack` 叠加结构)。
### 响应示例
@@ -224,7 +303,7 @@
}
```
> 识别失败时返回通用错误码(1001 / 1002 / 1003 等)。`gender` 缺失或非法值返回参数错误(1008;本接口已改为必填入参,不再自动判别性别1004 不再使用)
> 识别失败时返回通用错误码(1001 / 1002 / 1003 等)。`gender` 缺失或非法值返回 **1004**;本接口已改为必填入参,不再自动判别性别。
---
@@ -243,6 +322,8 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| marked_image_* | file / string | 是 | 已用马克笔标注发际线的图片,三选一 |
| use_mask | bool | 否 | 是否画发际线,默认 `true``false` 时跳过划线检测、直接送划线图,模型仅凭手绘黑线生发,供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
### 输出(data
@@ -281,11 +362,7 @@
### 输出(data
`data.features` 是一个 **JSON 字符串**(不是对象,客户端 `JSON.parse()` 后用)。内含**几十项**特征
脸型/眉形/眼型/鼻型/眼袋/法令纹/人中/瞳色/脖长/肤色、三庭五眼、四季色彩季型、量感/轮廓类型、
直曲量感得分、面部立体度、瞳距、对比度、基因风格、动静类型、性别……字段不固定、可随时增删。
其中**英文优先字段**(与 doubao 中文字段并存,方便客户端直接取):
`data.features` 是一个 **JSON 字符串**(不是对象,客户端 `JSON.parse()` 后用)**仅含以下 6 个英文字段**
| 字段 | 说明 |
|------|------|
@@ -307,18 +384,18 @@
"message": "success",
"request_id": "mock-request-id",
"data": {
"features": "{\"图片是否有人脸\":\"有人\",\"脸型\":\"鹅蛋脸\",\"眉形\":\"平眉\",\"面部年龄\":\"18-25岁\",\"四季色彩季型\":\"冷夏型\",\"基因风格\":\"少年型\",\"性别\":\"女\",\"face_shape\":\"鹅蛋脸\",\"eyebrow_shape\":\"平眉\",\"facial_age\":\"18-25岁\",\"dynamic_static_type\":\"静态型\",\"gender\":\"女\",\"gene_style\":\"少年型\"}"
"features": "{\"face_shape\":\"鹅蛋脸\",\"eyebrow_shape\":\"平眉\",\"facial_age\":\"18-25岁\",\"dynamic_static_type\":\"静态型\",\"gender\":\"女\",\"gene_style\":\"少年型\"}"
}
}
```
> `features` 为字符串形式的 JSON字段后续可随时增删,不固定。
> `features` 为字符串形式的 JSON,固定上述 6 个字段
---
## 接口 5:发际线 PNG 生成接口
**说明**输入用户照片,返回 N 张用户发际线的 PNG 图片,并返回「最合适发际线」的面部中间点坐标。
**说明**入参同接口2(先选性别、再多选发型)。对每个选中发型返回 `middle` / `high` / `low` **三档**发际线叠图与**生发图**,并返回「最合适发际线」的面部中间点坐标。
**请求**`POST /api/v1/hairline/generate`
@@ -328,21 +405,53 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定返回的发际线集合(female 5 / male 4 |
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 7 / male 6)。缺失/非法返回 `1004` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflowermale1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。缺失/越界/非法返回 `1007` |
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,可大幅降低耗时 |
> ⚠️ 三档叠图分别用 `hairline_texture` / `hairline_texture_high` / `hairline_texture_low` 三套同名贴图;**生发黑模板固定取自 `hairline_texture_black/`middle 档)**,即生发目标固定压到 middle 档,每个发型仅 1 张生发图。
### 输出(data
| 字段 | 类型 | 说明 |
|------|------|------|
| hairline_images | object[] | N 张发际线叠加图(发际线曲线叠在用户照片上,同接口2预览),**数量 = 该性别发际线数**,本期按贴图顺序,元素见下表 |
| best_hairline_center_point | object | 最佳(order=1发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
| hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 |
| best_hairline_center_point | object \| null | **首个选中发型**的 **middle 档**发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
| high_hairline_center_point | object \| null | 同上,**high 档**发际线中点(发际线偏高) |
| low_hairline_center_point | object \| null | 同上,**low 档**发际线中点(发际线偏低) |
| face_measure | object \| null | **复用接口1**的四庭七眼测量**数值**(不含标注图)。独立流程,测量失败(无人脸/非正面/分割失败)时为 `null`,不影响发际线主结果。字段结构见下表 |
`hairline_images` 元素:
| 字段 | 类型 | 说明 |
|------|------|------|
| image_url | string | 发际线叠加图 URLworker 返回 `image_base64`,网关落盘后改写为 url |
| order | int | 排序序号(本期固定 `1..N`,暂不计算合适度 |
| hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`male |
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示 |
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null` |
| order | int | 发型序号(= 传入的 hair_style 值) |
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。
>
> 💡 三档 `image_*_url` 为透明底 PNG,前端需用绝对定位叠加到原图上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface5.html)的 `.img-stack` 叠加结构)。`grown_image_url` 是完整人像照片,直接显示即可。
`face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段):
| 字段 | 类型 | 说明 |
|------|------|------|
| face_total_height_cm | number | 全脸总高度(cm= 四庭之和 |
| four_courts | object | 四庭数据(顶/上/中/下庭 cm + 占比 ratios),结构同接口1 |
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + eye1~eye7 从左到右 7 段宽度),结构同接口1 |
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算) |
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
### 响应示例(当前 Mock 返回值)
@@ -353,14 +462,57 @@
"request_id": "mock-request-id",
"data": {
"hairline_images": [
{ "image_url": "https://hair.xiangsilian.com/static/sample.jpg", "order": 1 },
{ "image_url": "https://hair.xiangsilian.com/static/sample.jpg", "order": 2 }
{
"hairline_type": "ellipse",
"image_middle_url": "https://hair.xiangsilian.com/static/annotations/mid1.png",
"image_high_url": "https://hair.xiangsilian.com/static/annotations/high1.png",
"image_low_url": "https://hair.xiangsilian.com/static/annotations/low1.png",
"grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown1.png",
"order": 1
},
{
"hairline_type": "heart",
"image_middle_url": "https://hair.xiangsilian.com/static/annotations/mid3.png",
"image_high_url": "https://hair.xiangsilian.com/static/annotations/high3.png",
"image_low_url": "https://hair.xiangsilian.com/static/annotations/low3.png",
"grown_image_base64": null,
"order": 3
}
],
"best_hairline_center_point": { "x": 540, "y": 430 }
"best_hairline_center_point": { "x": 540, "y": 430 },
"high_hairline_center_point": { "x": 540, "y": 380 },
"low_hairline_center_point": { "x": 540, "y": 480 },
"face_measure": {
"face_total_height_cm": 26.76,
"four_courts": {
"top_court_cm": 5.77, "upper_court_cm": 5.93,
"middle_court_cm": 7.62, "lower_court_cm": 7.44,
"ratios": { "top_court": 0.216, "upper_court": 0.222,
"middle_court": 0.285, "lower_court": 0.278 }
},
"seven_eyes": {
"eye_width_cm": 2.76, "face_width_cm": 15.08,
"inter_eye_distance_cm": 3.9,
"ratios": { "eye_width": 0.183, "inter_eye_distance": 0.259 },
"eye1": null, "eye2": 3.0, "eye3": 2.76, "eye4": 3.9,
"eye5": 2.76, "eye6": 3.0, "eye7": null
},
"landmarks": {
"hair_top": { "x": 504, "y": 103 },
"hairline": { "x": 504, "y": 228 },
"brow_center": { "x": 504, "y": 357 },
"nose_bottom": { "x": 505, "y": 522 },
"chin_tip": { "x": 506, "y": 683 }
},
"hairline_source": "segmentation",
"head_pose": { "yaw": -1.39, "pitch": 2.49, "roll": -0.06 }
}
}
}
```
> 说明:生发失败的元素中,网关不改写 `null` 值,故字段名保持为 `grown_image_base64: null`(有值时才改写为 `grown_image_url`),与接口2生发失败项一致。
---
## 汇总:输入输出一览
@@ -368,10 +520,11 @@
| 接口 | 输入 | 主要输出 |
|------|------|----------|
| 1 四庭七眼测量 | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 |
| 2 C 端生发 | 用户照片 | 生发后图片 + 多张发际线(带类型与排序) |
| 6 四庭七眼测量 v2 | 用户照片 | 同接口1,复刻实现 |
| 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单/多张) |
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
| 4 用户特征 | 用户照片 | N 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格 |
| 5 发际线 PNG | 用户照片 | N 张发际线 PNG + 最合适发际线面部中间点坐标 |
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
---
@@ -1,265 +0,0 @@
# 系统架构:外网网关 + 高性能后端(worker)
> 本文档描述「旷视五接口」的部署架构拆分。**接口文档(对外契约)完全不变**——客户端看到的 URL、请求/响应结构、错误码 1001–1008 全部保持原样。本文只改变内部如何处理这些请求。
---
## 1. 背景与目标
外网服务器(`hair.xiangsilian.com`)性能不足以跑 MediaPipe + BiSeNet + 标注图生成等重逻辑。因此拆分为两层:
- **外网网关(gateway)**:保持 HTTPS 对外接口不变,**自身不跑算法**,只做反向代理、健康检查、负载分发、鉴权、标注图托管。资源占用极小,可继续跑在现有外网机。
- **高性能后端(worker**:独立机器,**GPU + 32G 内存**,跑真正的算法逻辑。通过 `http://hair.xiangsilian.com:28187` 这类 `host:port` 暴露。
**核心诉求**
1. 对外接口与文档零变化。
2. 网关可配置**多个 worker URL**,周期探测可用性,只把请求发给健康的 worker。
3. **每个 worker 并发 = 1**(一次处理一个请求);多 worker 即可并发,**总并发 = 健康 worker 数**。
---
## 2. 拓扑总览
```
HTTPS (对外,接口文档不变)
┌────────┐ :443 ┌───────────────────────────┐
│ 客户端 │ ───────▶ │ 外网网关 gateway │
└────────┘ │ hair.xiangsilian.com │
│ - 反向代理 5 个接口 │
│ - 健康检查 worker 池 │
│ - 空闲 worker 派发(并发=worker数)│
│ - 共享密码鉴权 │
│ - 标注图 base64→落盘→URL │
│ - 无可用后端→1007 │
└───────┬───────────┬─────────┘
HTTP + 密码头 │ │
┌───────────────────────┘ └─────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ worker #1 │ ...(可配置多个)... │ worker #N │
│ :28187 GPU/32G │ │ :xxxxx GPU/32G │
│ 跑完整 app.py │ │ 跑完整 app.py │
│ + face_analysis │ │ + face_analysis │
│ 并发=1 │ │ 并发=1 │
└──────────────────┘ └──────────────────┘
```
---
## 3. 职责划分
| 能力 | 网关 gateway | worker |
|------|:---:|:---:|
| 对外 HTTPS、接口契约 | ✅ | ✗ |
| 反向代理 5 个接口 | ✅ | ✗ |
| 算法(MediaPipe/BiSeNet/测量/标注图) | ✗ | ✅ |
| 入参校验(大小/格式/分辨率/人脸) | ✗(透传) | ✅ |
| worker 健康检查 + 池管理 | ✅ | 提供 `/health` |
| 负载分发(挑空闲 worker) | ✅ | ✗ |
| 鉴权(共享密码) | 发送密码 | 校验密码 |
| 标注 PNG 落盘 + 对外 URL | ✅ | 返回 base64 |
| `/static/*` 静态托管 | ✅ | ✗ |
| GPU | 不需要 | ✅ |
> **原则**:所有业务逻辑只在 worker 实现一份(worker 跑的就是完整 `app.py` + `face_analysis`)。网关是无状态的薄层,除了"健康池 + worker 忙闲状态"外不持有业务状态。
---
## 4. 网关配置文件
后端 URL 列表、共享密码、各项参数集中在一个配置文件,运维手动维护(密码定期轮换)。
`gateway/config.json`(示例):
```json
{
"workers": [
"http://hair.xiangsilian.com:28187",
"http://10.0.0.12:28187"
],
"shared_password": "REPLACE_ME_ROTATE_PERIODICALLY",
"accept_passwords": ["REPLACE_ME_ROTATE_PERIODICALLY"],
"health_check": {
"path": "/health",
"interval_seconds": 8,
"timeout_seconds": 3,
"unhealthy_threshold": 2,
"healthy_threshold": 1
},
"dispatch": {
"per_worker_concurrency": 1,
"queue_wait_seconds": 30,
"request_timeout_seconds": 60,
"retry_on_failure": true,
"max_retries": 1
}
}
```
字段说明:
- `workers`:worker 基址列表,手动增删。**增加一个就多一路并发**。
- `shared_password`:网关调用 worker 时发送的密码。
- `accept_passwords`:worker 端用(见 §7)——允许的密码列表,**轮换期可同时放新旧两个**,实现不停机改密码。网关侧也可放在 worker 的独立配置里。
- `health_check`:探测周期、超时、连续失败几次判定下线、连续成功几次判定上线。
- `dispatch.per_worker_concurrency`**固定为 1**(当前约束)。
- `dispatch.queue_wait_seconds`:所有 worker 都忙时请求最多排队多久,超时返回 1007。
- `request_timeout_seconds`:单次转发到 worker 的超时。
- `retry_on_failure` / `max_retries`:worker 转发失败时是否换一个 worker 重试。
> 配置变更后网关需 reload(可做成监听文件变更热加载,或重启网关进程)。
---
## 5. 健康检查
网关后台**周期轮询**每个 worker 的 `/health`(已存在于 `app.py`,排除在 OpenAPI 之外):
- 间隔 `interval_seconds`,每次超时 `timeout_seconds`
- 连续失败 `unhealthy_threshold` 次 → 标记**下线**,停止派发。
- 重新连续成功 `healthy_threshold` 次 → 标记**上线**,恢复派发。
- 健康池 = 当前在线的 worker 集合。
**主动检查 + 被动剔除**双保险:除周期探测外,转发请求时若 worker 连接失败/超时,立即把它标记为不健康并(按配置)换一个 worker 重试。
> worker 的 `/health` 建议返回 **模型就绪状态**:只有 MediaPipe / BiSeNet 权重都加载完成才返回 200,否则返回 503——避免请求被派发到尚未热好的 worker。
---
## 6. 负载分发与并发
**每个 worker 并发 = 1**,所以分发逻辑很简单:
1. 网关维护每个健康 worker 的**忙/闲**状态。
2. 新请求到来:从健康池里选**一个空闲 worker**,标记为忙,转发;收到响应(或失败)后标记为闲。
3. 若当前**无空闲 worker**(全忙):请求进入**队列等待**,直到有 worker 空闲或等待超过 `queue_wait_seconds`(超时返回 1007)。
4. 若**健康池为空**(无可用后端):直接返回 1007。
- **总并发能力 = 健康 worker 数量**。加机器即扩并发。
- 选空闲 worker 的策略:任意(如先到先得 / 轮转空闲),并发=1 下无需最少连接数算法。
---
## 7. 鉴权(共享密码)
`:28187` 在公网可直接访问,必须鉴权防止他人直接打 worker。
- **网关 → worker**:每个转发请求带密码头,如 `X-Internal-Token: <shared_password>`
- **worker 校验**worker 侧配置文件持有 `accept_passwords` 列表,收到请求校验 `X-Internal-Token` 是否在列表内;不匹配返回 HTTP 401,**不进入业务逻辑**。
- **轮换**:运维改密码时,先把新密码加进 worker 的 `accept_passwords`(此时新旧都接受)→ 再把网关 `shared_password` 切到新值 → 确认无旧密码流量后从 worker 移除旧密码。全程不停机。
- 密码**仅存配置文件**,不写日志、不进 git(配置文件加入 `.gitignore`,仓库只放 `config.example.json`)。
> 建议叠加防火墙:worker 防火墙只放行网关来源 IP(纵深防御)。密码是应用层兜底。
---
## 8. 单次请求处理流程
```
客户端 ──(HTTPS, multipart/json, 接口文档原样)──▶ 网关
├─ 1. 选一个空闲健康 worker(无则排队/1007
├─ 2. 原样转发请求体 + 加 X-Internal-Token 头
│ ──(HTTP)──▶ worker
│ ├─ 校验密码(失败→401,网关视为该 worker 异常)
│ ├─ 跑完整业务(校验/MediaPipe/BiSeNet/测量/标注图)
│ └─ 返回标准信封;图片字段以 base64 形式(见 §9)
├─ 3. 收到 worker 响应,标记该 worker 空闲
├─ 4. 若响应含 base64 图片 → 落盘到网关 /static/annotations/{uuid}.png
│ → 把字段改写成对外 URL(见 §9)
└─ 5. 把最终(符合接口文档的)响应返回客户端
```
- 网关**不解析也不校验**业务入参,原样透传(worker 负责全部校验与错误码)。worker 返回的 1001–1008 由网关**透传**给客户端。
- 网关只在「无后端/排队超时」时**自行**返回 1007。
---
## 9. 标注图处理(base64 → 落盘 → URL 改写)★关键
接口文档里多个接口返回图片 URL(接口1 标注图、接口3 标记图、接口5 发际线图)。拆分后:
1. **worker 不落盘、不拼 URL**,而是把生成的 PNG 以 **base64** 放进响应的约定字段返回给网关。
- 约定:worker 用 `*_base64` 字段承载图片,例如接口1 返回 `annotated_image_base64` 而非 `annotated_image_url`
2. **网关收到后**:对每个 `*_base64` 字段——
- 解码 → 保存到网关本地 `static/annotations/{uuid}.png`
- 删除该 base64 字段,新增对应的 `*_url` 字段,值为 `https://hair.xiangsilian.com/static/annotations/{uuid}.png`
3. 客户端最终看到的字段名/URL **与接口文档完全一致**(如 `annotated_image_url`)。
> **完整字段映射表见 [`网关-开发任务书.md`](网关-开发任务书.md) §6**(接口1/2/3/5 全部 `*_base64`→`*_url`,以 `接口文档.md` 为准)。
> ⚠️ 两个易漏点:
> 1. **数组里的图片字段**:接口2 `results[].image_base64`/`results[].grown_image_base64`、接口5
> `hairline_images[].image_base64` 在数组元素内——改写逻辑要**递归进数组**(建议「凡 key 以 `_base64`
> 结尾就改写」的通用递归,自动覆盖嵌套与未来新增字段)。
> 2. **可空字段**:接口2/3 的生发图(ComfyUI 未起/失败时)`*_base64` 为 **null** → 保留 null,不落盘。
>
> 代价:内部 HTTP 多传一份 base64(图片放大约 1.33×)。worker↔网关若跨网络,单图 ~100KB–1MB 量级,可接受。
> ⚠️ 生发接口(2/3)经 ComfyUI 同步出图较慢(接口2 ~18s、接口3 ~6s),网关转发**超时要调大(≥120s)**。
>
> 静态文件清理:网关 `static/annotations/` 会持续增长,需加定期清理(按时间或容量,定时任务),与拆分前同样的问题,由网关侧负责。
---
## 10. 错误处理
| 场景 | 返回 | 由谁 |
|------|------|------|
| 业务错误(无人脸/分辨率/非正面/超大/格式…) | 10011008(原样) | worker 产生,网关透传 |
| **无可用 worker / 全忙排队超时** | **1007 系统错误** | 网关 |
| worker 转发失败(连接/超时/5xx/401) | 按配置换 worker 重试;重试耗尽 → 1007 | 网关 |
- 复用 **1007**(系统错误)表示「基础设施层不可用」,**不新增错误码**,接口文档不动。
- 局限:调用方无法从错误码区分"算法失败"与"后端全挂"(都是 1007)。如需区分,可在 `message` 文案上体现(如"后端服务暂不可用,请稍后重试"),但 `code` 保持 1007。
---
## 11. worker 侧相对单机方案的变化
worker 跑的就是「接口1 技术实现方案」里描述的完整逻辑,但有几处因拆分/硬件而变:
1. **GPU 加速**worker 有独立 GPUBiSeNet 改用 **CUDA** 推理(torch GPU 版),比 CPU 快很多;MediaPipe 仍 CPUPython solutions API 仅 CPU)。
2. **不落盘、返回 base64**:标注图相关接口的 handler 改为返回 `*_base64`,不再保存到本地 `/static`、不拼 URL(改由网关做,见 §9)。
3. **`/health` 反映就绪**:模型加载完才返回 200。
4. **鉴权中间件**worker 增加一个校验 `X-Internal-Token` 的中间件/依赖(见 §7)。
5. **资源宽裕**:32G 内存 + GPU,无需单机方案里 2核4G 的并发限制与降级开关;但 worker 自身仍是**并发=1**(由网关保证,不向 worker 发并发请求;worker 可不做内部并发控制,但建议 uvicorn 单 worker 进程以省显存)。
> 单机方案文档(接口1 技术实现方案)描述的「方案A/B、虹膜标定、标注图、误差验证」全部不变,只是运行位置从外网机挪到 GPU worker,并启用 GPU。
---
## 12. 部署
### 网关(外网机,hair.xiangsilian.com
- 新增 gateway 应用(轻量 FastAPI/asgi 代理)。
- nginx 把 443 → 网关进程;网关再转发到 worker 池。
- 托管 `/static/*`(标注图落盘目录)。
- 配置文件 `gateway/config.json`(含 worker 列表 + 密码)。
- systemd 管理网关进程。
### workerGPU 机,:28187
- 部署完整 `app.py` + `face_analysis` + 模型权重(见 `OFFLINE_ASSETS.md`)。
- torch 用 **GPU 版**CUDA),其余依赖同单机方案。
- uvicorn 监听 `0.0.0.0:28187`(仅经防火墙放行网关)。
- 配置文件持有 `accept_passwords`
- systemd 管理 worker 进程;`/health` 供网关探测。
---
## 13. 安全注意
- `:28187` **公网可达 + HTTP 明文**:密码头会明文走公网。**强烈建议**给 worker 也套一层 TLSworker 前置 nginx 终止 HTTPS,或网关↔worker 走内网/VPN/IP 白名单),否则密码可被中间人嗅探。
- 若短期内只能 HTTP,务必靠**防火墙 IP 白名单**把 worker 限制为只接受网关来源,密码作为应用层兜底。
- 密码不入 git、不写日志。
- 网关对转发的请求体大小设上限(如 ≤2MB),防止被超大 body 拖垮。
---
## 14. 待确认 / 后续
1. **worker↔网关传输是否加 TLS**:当前 §13 标为强烈建议。若运维能给 worker 配 HTTPS 或限定内网,安全性更好。请确认部署条件。
2. **worker host:port 形态**`hair.xiangsilian.com:28187` 是端口转发到 GPU 机,还是 GPU 机直接持有该域名?影响防火墙与 TLS 方案。
3. **多 worker 的物理分布**:是否都在同一内网?若跨公网,base64 图片传输与密码明文风险都需重新评估。
4. **静态图清理策略**:网关 `static/annotations/` 的保留时长 / 清理触发条件。
---
> **文档版本**: v1.0 **创建日期**: 2026-06-14 配套:接口1 技术方案 v2.0 / 开发任务书 / OFFLINE_ASSETS.md
> **关键约束**: 接口文档不变;每 worker 并发=1;无后端→1007;标注图 base64→网关落盘→URL
-217
View File
@@ -1,217 +0,0 @@
# 外网网关 — 开发任务书(AI Agent 执行版)
> 在 **外网机(`hair.xiangsilian.com`** 上开发。本任务书自包含,只负责**网关**这一层。
> 配套:[`系统架构-网关与高性能后端.md`](系统架构-网关与高性能后端.md)(两端共享的契约,**先读**)。
> worker(GPU 机)侧算法由另一份任务书负责:`接口1-四庭七眼测量-开发任务书.md`,本机不涉及。
> 执行者:AI coding agent。**严格按阶段顺序**执行,每阶段跑完「验证方法」通过后再进入下一阶段。
---
## 0. 网关是什么 / 不是什么
- **是**:一个无状态的**薄反向代理**。对外保持 HTTPS 接口与接口文档**完全不变**;对内把请求转发给高性能 worker 池,做健康检查、空闲派发、鉴权、把 worker 返回的 base64 标注图落盘成对外 URL。
- **不是**:不跑任何算法(无 MediaPipe / torch / BiSeNet);不解析业务入参;不持有业务状态。所有业务逻辑和错误码(1001–1008)都来自 worker,网关原样透传。
**网关唯一自行产生的错误**:无可用 worker / 全忙排队超时 → `code: 1007`(复用系统错误,不新增错误码)。
---
## 1. 总体约束
1. **对外契约零变化**:客户端看到的 URL、请求方式(multipart/form-data,三选一图片)、响应结构 `{code,message,request_id,data}`、字段名、错误码,全部与 `docs/接口文档.md` 一致。客户端**无感知**拆分。
2. **代理全部 5 个接口**`/api/v1/face/measure``/hair/grow``/hair/grow-b``/face/features``/hairline/generate`。worker 跑完整 app,网关统一代理。
3. **技术栈**Python + FastAPI + `httpx`(异步转发)+ uvicorn。**不引入** torch/mediapipe/opencv 等重依赖,网关保持轻量。
4. **无状态**:除"健康池 + worker 忙闲状态"这点运行时状态外,不持久化业务数据。
5. 密码、worker 列表等走**配置文件**,不硬编码、不入 git。
---
## 2. 配置文件
`gateway/config.json`(运维维护,**入 `.gitignore`**;仓库只放 `gateway/config.example.json`):
```json
{
"workers": [
"http://hair.xiangsilian.com:28187",
"http://10.0.0.12:28187"
],
"shared_password": "REPLACE_ME_ROTATE_PERIODICALLY",
"public_base_url": "https://hair.xiangsilian.com",
"static_dir": "static/annotations",
"health_check": {
"path": "/health",
"interval_seconds": 8,
"timeout_seconds": 3,
"unhealthy_threshold": 2,
"healthy_threshold": 1
},
"dispatch": {
"per_worker_concurrency": 1,
"queue_wait_seconds": 30,
"request_timeout_seconds": 60,
"retry_on_failure": true,
"max_retries": 1
}
}
```
字段含义见架构文档 §4。要点:`workers` 手动增删(**加一个就多一路并发**);`per_worker_concurrency` 固定 1`shared_password` 网关调用 worker 时通过 `X-Internal-Token` 头发送。
---
## 3. 阶段一:骨架 + 配置加载
**开发步骤**
1. 新建目录:
```
gateway/
├── app.py # FastAPI 应用 + 5 接口代理路由
├── config.py # 读取/校验 config.json
├── config.example.json
├── pool.py # 健康池 + worker 忙闲状态 + 派发
├── forward.py # httpx 转发 + base64→URL 改写
└── __init__.py
static/annotations/ # 标注图落盘目录(.gitkeep
```
2. `config.py`:加载 `config.json`,缺字段给默认值,启动时校验 `workers` 非空、密码非占位值(占位值打 WARNING)。
3. `app.py`:建 FastAPI 应用,挂载 `/static`,加 `/gateway-health`(网关自身健康,区别于 worker 的 `/health`)。
4. 更新根 `.gitignore`:忽略 `gateway/config.json`、`static/annotations/*`(保留 `.gitkeep`)。
**验证**
```bash
./venv/bin/uvicorn gateway.app:app --host 127.0.0.1 --port 8080 &
curl -s http://127.0.0.1:8080/gateway-health # 200
```
启动日志打印解析出的 worker 列表与派发参数。
**完成标准**:网关起得来,配置正确加载,无 worker 时也不崩。
---
## 4. 阶段二:健康检查 + 空闲派发(并发=worker 数)
**开发步骤**
1. `pool.py`
- 后台 asyncio 任务,每 `interval_seconds` 给每个 worker 发 `GET {worker}/health`(带 `X-Internal-Token``timeout_seconds` 超时)。
- 连续失败 `unhealthy_threshold` 次 → 下线;重新连续成功 `healthy_threshold` 次 → 上线。维护在线集合。
- 每个 worker 一个 `asyncio.Lock`(或容量=1 的信号量)表示忙闲(`per_worker_concurrency=1`)。
2. 派发 `acquire_worker()`:从在线池里挑一个**空闲** worker 占用;全忙则 `await` 等待,最多 `queue_wait_seconds`,超时抛 `NoWorkerAvailable`;在线池为空也抛该异常。用完 `release`。
3. 路由层捕获 `NoWorkerAvailable` → 返回 `{code:1007, message:"后端服务暂不可用,请稍后重试", ...}`。
**验证**
- 配 2 个**假 worker**(本地起两个返回 `/health` 200 + 简单 echo 的 stub):并发打 3 个请求 → 前 2 个并行、第 3 个排队后成功。
- 停掉 1 个 stub → `interval×unhealthy_threshold` 内自动下线,请求只走存活的。
- 全停 → 请求返回 `code==1007`。
```bash
# 可用 python 起两个 stub:每个监听不同端口,/health 返回200,业务接口 sleep 1s 再回
```
**完成标准**:健康检查上下线正确;并发=在线 worker 数;全忙排队、池空→1007。
---
## 5. 阶段三:鉴权头 + 转发 + 标注图改写
**开发步骤**
1. `forward.py`:用 `httpx.AsyncClient` 把客户端请求**原样转发**到选中的 worker 对应路径:
- 透传 method、multipart/form body、查询参数;附加 `X-Internal-Token: <shared_password>` 头。
- `request_timeout_seconds` 超时;连接失败/超时/5xx/401 视为该 worker 异常 → 标记不健康,按 `max_retries` 换 worker 重试;耗尽 → 1007。
- worker 返回的 10011008 业务响应**原样透传**给客户端(不要改 code)。
2. **base64 → URL 改写**:拿到 worker 的 JSON 响应后,对约定的图片字段:
- worker 用 `*_base64` 承载 PNG(如接口1 `annotated_image_base64`)。
- 网关:解码 → 存 `static/annotations/{uuid}.png` → **删除 `*_base64`,新增 `*_url`** = `{public_base_url}/static/annotations/{uuid}.png`。
- **推荐通用实现**:递归遍历 `data`,凡 key 以 `_base64` 结尾 → 落盘 → 改成同前缀 `_url`
**包括数组元素内部的字段**(接口2/5 的图片字段在数组里)。这样新增字段自动覆盖、无需逐一硬编码。
- `*_base64` 值可能为 **null**(如接口2/3 的生发图,ComfyUI 未起/失败时)→ 该项**保留 null、不落盘、不生成 url**。
**完整字段映射表**worker 内部 `*_base64` → 对外 `*_url`,以 `docs/接口文档.md` 为准):
| 接口 | 路径 | worker 字段(内部) | 对外字段 | 位置 |
|------|------|--------------------|----------|------|
| 1 | `/api/v1/face/measure` | `annotated_image_base64` | `annotated_image_url` | data 顶层 |
| 2 | `/api/v1/hair/grow` | `results[].image_base64` | `results[].image_url` | **数组元素** |
| 2 | 〃 | `results[].grown_image_base64` | `results[].grown_image_url` | **数组元素**(可空) |
| 3 | `/api/v1/hair/grow-b` | `hair_growth_image_base64` | `hair_growth_image_url` | data 顶层(可空) |
| 4 | `/api/v1/face/features` | —(无图片字段,原样透传)| — | — |
| 5 | `/api/v1/hairline/generate` | `hairline_images[].image_base64` | `hairline_images[].image_url` | **数组元素** |
> 接口2/5 **新增了必填 `gender` 入参**、接口3 用 `marked_image_*`+`original_image_*`——这些都是
> **请求 multipart 参数**,网关原样透传即可,无需改造(网关对入参透明)。
3. 5 个接口路由统一走「选 worker → 转发 → 改写 → 返回」一条链路。
**验证(端到端,最终冒烟)**
```bash
# 经网关(对外 HTTPS;客户端不需要 token——token 是网关→worker 内部的)
curl -s -X POST https://hair.xiangsilian.com/api/v1/face/measure \
-F image_file=@<一张人像> | python -m json.tool
# 期望:与接口文档完全一致——data 含 annotated_image_url(不是 base64
curl -sI https://hair.xiangsilian.com/static/annotations/<uuid>.png # 200
```
- 对外响应字段/URL 与 `docs/接口文档.md` **逐一一致**。
- 响应里**不应**出现 `*_base64`(已被网关消化)。
- 直接打 worker 不带 token → 401worker 侧行为);经网关正常。
**完成标准**:5 接口代理通;base64 落盘改 URL 正确;对外契约零变化;失败重试与 1007 生效。
---
## 6. 阶段四:静态托管 + 清理
**开发步骤**
1. 网关 `app.mount("/static", StaticFiles(directory="static"), ...)` 托管落盘的标注图。
2. 加**定期清理**`static/annotations/` 会持续增长,按时间(如保留 N 天)或容量清理。可用进程内定时任务或外部 cron(任选,记录方案)。
**验证**
- 生成的图能经 `https://hair.xiangsilian.com/static/annotations/<uuid>.png` 访问(200)。
- 清理任务按规则删除过期文件,不误删近期文件。
**完成标准**:静态图可公网访问;清理任务可控。
---
## 7. 阶段五:部署
**开发步骤**
1. nginx443 → 网关进程(uvicorn)。沿用现有 `nginx/hair.conf` 风格。
2. systemd 管理网关进程(可继续用 `hair.service`,或新建 `hair-gateway.service`)。
3. `config.json` 就位(worker 列表 + 密码);`static/annotations/` 可写。
**验证**
```bash
sudo systemctl restart hair-gateway && sudo systemctl status hair-gateway
# 端到端冒烟(同阶段三);journalctl 无 ERROR
```
**完成标准**:线上经 HTTPS 走通全链路,标注图可访问,日志无异常。
---
## 8. 交付清单(网关侧 DoD)
- [ ] `gateway/``app.py`、`config.py`、`pool.py`、`forward.py`、`config.example.json`
- [ ] 代理 5 个接口,对外契约/字段/URL 与接口文档**完全一致**
- [ ] worker 池健康检查(上下线)+ 空闲派发(并发=在线 worker 数)+ 全忙排队/无后端→1007
- [ ] 共享密码鉴权(`X-Internal-Token`,配置文件,可轮换)
- [ ] base64 → 落盘 `static/annotations/` → 改写 `*_url`,响应无 `*_base64` 残留
- [ ] 静态托管 + 定期清理
- [ ] 端到端 HTTPS 冒烟通过,`annotated_image_url` 公网可访问
- [ ] `config.json` 入 `.gitignore`,仓库只留 `config.example.json`
---
## 9. 风险与注意
1. **联调依赖 worker**:阶段四之前可用**本地 stub worker**(返回 `/health` 200 + 假的 base64 图)独立开发;worker 真机就绪后再换真实地址端到端联调。
2. **接口文档是字段唯一权威**:图片字段映射表、对外字段名都以 `docs/接口文档.md` 为准,冲突时以文档为准并在 PR 说明指出。
3. **安全(先跑通后处理,已知项)**:`:28187` 当前 HTTP 明文 + 公网可达,密码明文传输;后续建议加 TLS / 内网 / IP 白名单(见架构文档 §13/§14)。本阶段不阻塞。
4. **接口实现进度****接口 1/2/3/4/5 worker 均已真实实现**。接口 4(用户特征)返回 `features`(JSON 字符串)、
**无图片字段,网关原样透传**;但接口4 worker 会调外网豆包视觉模型(`ark.cn-beijing.volces.com`)——
网关本身不受影响,但要知道该接口耗时含一次远程大模型调用(数秒)。
5. **生发图耗时**:接口 2(一次 N 张 Flux~18s)、接口 3~6s)经 ComfyUI 同步出图,**`request_timeout_seconds` 要调大**(建议 ≥120s),否则网关会先超时换 worker 重试。
---
> **文档版本**: v1.0 **创建日期**: 2026-06-14 配套:系统架构 v1.0
> **关键约束**: 接口文档不变;不跑算法;每 worker 并发=1;无后端→1007base64→落盘→URL
+146
View File
@@ -0,0 +1,146 @@
# 网关侧改动清单(worker 近期变更引发)
> 给网关开发:以下是 worker/契约近期变化里**与网关有关**的点。标 ✅ 的我已在本仓库 `gateway/`
> 改好(你 review/拉取即可);标 🔲 的是**建议你确认或改**。功能必需只有第 1 条。
---
## 1. ✅【功能必需】base64→URL 落盘扩展名按内容嗅探(支持 JPG)
**背景**:接口 **2/3/5 的返回图改成了 JPG**(体积约小 9×),**接口 1 标注图仍是 PNG**(含透明)。
网关把 `*_base64` 落盘时若**硬编码 `.png`**JPG 会被存成 `.png`(内容是 JPG、扩展名错)。
**改动**`gateway/forward.py``rewrite_base64_to_url`,已改):
```python
# 原:filename = f"{uuid.uuid4().hex}.png"
ext = "png" if img_bytes[:8] == b"\x89PNG\r\n\x1a\n" else "jpg" # 按内容嗅探
filename = f"{uuid.uuid4().hex}.{ext}"
```
> 这样接口1 存 `.png`、接口2/3/5 存 `.jpg`,对外 URL 后缀也就正确。**若你的网关是独立部署/独立代码,按上面这两行改一下即可。**
---
## 2. 🔲【建议】生发接口超时调大
接口 **2(一次 N 张 Flux~18s/ 3~6s** 经 ComfyUI 同步出图较慢。
`gateway/config.json``dispatch.request_timeout_seconds` 建议 **≥ 120**,否则网关会先超时换 worker 重试。
---
## 3. 🔲【确认】base64→URL 通用改写仍覆盖这些场景
- **数组里的图片字段**:接口2 `results[].image_base64` / `results[].grown_image_base64`
接口5 `hairline_images[].image_base64` 在数组元素内——改写要**递归进数组**(你现有的递归实现已覆盖)。
- **可空字段**:接口2/3 的生发图(ComfyUI 没起/失败时)`*_base64`**null** → 保留 null、不落盘。
---
## 4. 🔲【可选·仅影响 /docs】OpenAPI 表单声明
纯文档展示,不影响转发功能(网关是盲转发)。若想让网关 `/docs` 准确:
- 接口2 `/hair/grow`、接口5 `/hairline/generate` 入参**新增必填 `gender`**male/female)。
- 接口3 `/hair/grow-b` 入参**只剩 `marked_image_*`**(已去掉 `original_image_*`)。
- `gateway/app.py` 里的 `_*_FORMS` 字典当前未被路由引用,所以不改也不影响实际行为。)
---
## 5. 🔲【新增】接口7 C端生发 v2`/api/v1/hair/grow-v2`
**背景**:worker 侧已新增接口 7,功能与接口 2 完全一致,区别仅在于 ComfyUI 工作流使用 `add_hair2.json`(而非 `add_hair.json`)。
**网关需新增一个路由**,代理转发到 worker 的同路径:
```python
# gateway/app.py
@app.post("/api/v1/hair/grow-v2", tags=["生发"])
async def hair_grow_v2(request: Request):
"""接口7C端生发 v2add_hair2 工作流)"""
return await _proxy(request, "/api/v1/hair/grow-v2")
```
**无需额外改动**
- 请求:multipart/form-data,参数与接口 2 完全相同(`image_file/url/base64` 三选一 + `gender` + `hair_style` + `beauty_enabled` + `use_mask` + `prompt`),网关盲转发即可
- 响应:结构与接口 2 完全一致,`results[].image_base64` / `results[].grown_image_base64` 经现有 `rewrite_base64_to_url` 自动改写为 URL
- base64→URL:数组内图片字段递归改写已覆盖,无需修改
### 入参(与接口 2 一致)
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| image_file / image_url / image_base64 | — | **三选一** | 用户正面照 |
| gender | string | **是** | `male` / `female` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female: 15male: 14 |
| beauty_enabled | bool | 否 | 美颜开关(本期不生效) |
| use_mask | bool | 否 | 默认 `true``false` 跳过遮罩 |
| prompt | string | 否 | ComfyUI 提示词 |
### 出参(与接口 2 一致)
```json
{
"code": 0,
"message": "success",
"request_id": "gw-xxxxxxxx",
"data": {
"results": [
{
"image_url": "https://hair.xiangsilian.com/static/annotations/xxx.jpg",
"grown_image_url": "https://hair.xiangsilian.com/static/annotations/xxx.jpg",
"hairline_type": "ellipse",
"order": 1
}
]
}
}
```
### worker 侧信息
| 项目 | 值 |
|------|-----|
| worker 路径 | `/api/v1/hair/grow-v2` |
| 工作流文件 | `add_hair2.json` |
| 输入/遮罩节点 | 26LoadImage |
| 输出节点 | 75SaveImage,自动检测) |
| 提示词节点 | 60JjkText |
### 🔲【可选·仅影响 /docs】OpenAPI 表单声明
若想让网关 `/docs` 展示准确,在 `gateway/app.py` 新增 `_GROW_V2_FORMS`(或复用 `_GROW_FORMS` 并补充 `gender`/`hair_style` 字段),然后将路由函数签名改为显式声明 Form 参数(参考接口 4 的写法)。不改也不影响实际转发。
---
## 6. ✅【已完成】接口6 四庭七眼测量 v2`/api/v1/face/measure-v2`
**背景**worker 侧接口 6 是接口 1 的**去顶庭变体**(不再完全一致):不画头顶横线/不返回顶庭数据、竖线范围发际线→下巴尖、不画人头最左/最右端线。worker 侧与接口 1 共用 `_face_measure_impl(variant="v6")`
**网关已新增路由**`gateway/app.py` 已实现,网关机器 pull 后生效):
```python
# gateway/app.py
@app.post("/api/v1/face/measure-v2", tags=["人脸分析"])
async def face_measure_v2(request: Request):
"""接口6:四庭七眼测量 v2(去顶庭 + 去头部端线)"""
return await _proxy(request, "/api/v1/face/measure-v2")
```
**无需额外改动**
- 入参:与接口 1 完全相同(image_file/url/base64 三选一)
- 出参:`annotated_image_base64` → 经现有 `rewrite_base64_to_url` 自动改写为 `annotated_image_url`
- worker 侧 v6 差异(去顶庭字段、变体标注)由 `_face_measure_impl` 内部处理,网关透明转发
---
## 已经做好、无需再动的
- **接口4 在网关本机实现**(调豆包,不转发 worker)——已完成;config 里配 `ark`
- **接口4 业务错误 HTTP 状态**已统一为 200(与其余接口一致)。
- **接口3 去 original / best_hairline**——网关盲转发,无需改(映射表里也没有 best_hairline)。
---
> 对外字段映射总表见 [`实现说明.md`](实现说明.md) §1;契约以 [`接口文档.md`](接口文档.md) 为准。
+244 -75
View File
@@ -1,9 +1,14 @@
"""标注图层生成(透明底 RGBA PNG,仅标注、不含人物)。
规格(技术方案 §6):线/字色 #FFFFFF、字体 10pt、线宽 1pt、透明底。
规格(技术方案 §6 + img1.png 版本5):线/字色 #FFFFFF、透明底。
- 字号/线宽/虚线/箭头尺寸全部按图片尺寸自适应缩放(大图也清晰)。
- 四庭水平分界线:numpy 向量化渐变消失(中间亮、两侧渐隐)。
- 四庭 cm 数值:图片左侧。
- 七眼标注:眼宽/两眼间距/脸宽,虚线箭头,标签上下穿插
- 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右,
把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线箭头。
人头最左/最右取自耳朵分割外缘,看不到耳朵则省略该侧(最少 6 点 5 段)。
- 四庭:图片左侧,「名」「数值(带 cm)」「百分比」三行换行,带竖向虚线双箭头。
- 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。
- 每段数值直接带 cm 后缀,下方另起一行标百分比(不再单独标底部「单位cm」)。
中文字体用打包的思源黑体绝对路径加载,缺字体直接抛错(不静默降级成方块)。
"""
import os
@@ -12,44 +17,39 @@ import numpy as np
from PIL import Image, ImageDraw, ImageFont
FONT_PATH = os.path.join(os.path.dirname(__file__), "fonts", "NotoSansCJKsc-Regular.otf")
FONT_SIZE = 10
LINE_COLOR = (255, 255, 255, 255) # #FFFFFF 100%
LINE_WIDTH = 1
def _load_font():
def _load_font(size):
if not os.path.isfile(FONT_PATH):
raise FileNotFoundError(f"中文字体缺失:{FONT_PATH}(请按 OFFLINE_ASSETS.md 放置)")
return ImageFont.truetype(FONT_PATH, FONT_SIZE)
return ImageFont.truetype(FONT_PATH, size)
def draw_gradient_horizontal_line(buf, cx, cy, color=LINE_COLOR, half_length=None):
"""在 RGBA numpy 缓冲 buf 上,以 (cx,cy) 为中心画向两侧渐变消失的水平线。
numpy 向量化:一次性算整行 alpha,避免逐像素 draw.point。
"""
def draw_gradient_horizontal_line(buf, cx, cy, color=LINE_COLOR, half_length=None, width=1):
"""在 RGBA numpy 缓冲 buf 上,以 (cx,cy) 为中心画向两侧渐变消失的水平线。"""
h, w = buf.shape[:2]
cy = int(round(cy)); cx = int(round(cx))
if not (0 <= cy < h):
return
half = half_length or (w // 3)
xs = np.arange(w)
dist = np.abs(xs - cx)
alpha = np.clip(1.0 - dist / half, 0.0, 1.0) * color[3]
mask = alpha > 0
row = buf[cy]
for off in range(-(width // 2), width - width // 2):
y = cy + off
if not (0 <= y < h):
continue
row = buf[y]
row[mask, 0] = color[0]
row[mask, 1] = color[1]
row[mask, 2] = color[2]
row[mask, 3] = np.maximum(row[mask, 3], alpha[mask].astype(np.uint8))
def draw_gradient_vertical_line(buf, cx, y0, y1, color=LINE_COLOR, fade=None):
def draw_gradient_vertical_line(buf, cx, y0, y1, color=LINE_COLOR, fade=None, width=1):
"""在 RGBA numpy 缓冲 buf 上画一条竖线,两端渐变消失(中间实、上下淡)。"""
h, w = buf.shape[:2]
cx = int(round(cx))
if not (0 <= cx < w):
return
y0, y1 = int(round(y0)), int(round(y1))
y0, y1 = max(0, min(y0, y1)), min(h - 1, max(y0, y1))
if y1 <= y0:
@@ -59,8 +59,12 @@ def draw_gradient_vertical_line(buf, cx, y0, y1, color=LINE_COLOR, fade=None):
fade = fade or max(1, span // 5) # 仅两端 ~1/5 段渐隐
d = np.minimum(ys - y0, y1 - ys) # 到最近端点的距离
alpha = np.clip(d / fade, 0.0, 1.0) * color[3]
col = buf[y0:y1 + 1, cx]
m = alpha > 0
for off in range(-(width // 2), width - width // 2):
x = cx + off
if not (0 <= x < w):
continue
col = buf[y0:y1 + 1, x]
col[m, 0] = color[0]
col[m, 1] = color[1]
col[m, 2] = color[2]
@@ -68,28 +72,35 @@ def draw_gradient_vertical_line(buf, cx, y0, y1, color=LINE_COLOR, fade=None):
def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color=LINE_COLOR,
dash_len=6, gap_len=4, arrow_size=5):
"""两点间画虚线,两端带箭头(等腰三角)。"""
dash_len=6, gap_len=4, arrow_size=5, width=1):
"""两点间画稀疏虚线主干,两端用实心三角箭头(尖端精确落在端点,便于对齐)。
虚线只画到「端点向内 arrow_len」处,箭头三角填补剩余,避免虚线穿出箭头。
"""
total = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
if total == 0:
return
dx = (x2 - x1) / total
dy = (y2 - y1) / total
pos = 0.0
while pos < total:
seg_end = min(pos + dash_len, total)
nx, ny = -dy, dx # 法向量
arrow_len = arrow_size * 2.0 # 三角沿线方向长度
arrow_half = arrow_size # 三角底边半宽
# 主干虚线:两端各留出 arrow_len 给箭头
pos = arrow_len
main_end = max(arrow_len, total - arrow_len)
while pos < main_end:
seg_end = min(pos + dash_len, main_end)
draw.line([(x1 + dx * pos, y1 + dy * pos),
(x1 + dx * seg_end, y1 + dy * seg_end)], fill=color, width=LINE_WIDTH)
(x1 + dx * seg_end, y1 + dy * seg_end)], fill=color, width=width)
pos += dash_len + gap_len
# 法向量(用于箭头两翼张开)
nx, ny = -dy, dx
for (ex, ey, sdx, sdy) in [(x1, y1, dx, dy), (x2, y2, -dx, -dy)]:
p1 = (ex + sdx * arrow_size + nx * arrow_size * 0.6,
ey + sdy * arrow_size + ny * arrow_size * 0.6)
p2 = (ex + sdx * arrow_size - nx * arrow_size * 0.6,
ey + sdy * arrow_size - ny * arrow_size * 0.6)
draw.line([p1, (ex, ey)], fill=color, width=LINE_WIDTH)
draw.line([p2, (ex, ey)], fill=color, width=LINE_WIDTH)
# 两端实心三角箭头:尖端=端点,底边在向内 arrow_len 处展开 ±arrow_half
for (tipx, tipy, ix, iy) in [(x1, y1, dx, dy), (x2, y2, -dx, -dy)]:
bx, by = tipx + ix * arrow_len, tipy + iy * arrow_len
draw.polygon([(tipx, tipy),
(bx + nx * arrow_half, by + ny * arrow_half),
(bx - nx * arrow_half, by - ny * arrow_half)], fill=color)
def _text_size(draw, text, font):
@@ -106,82 +117,239 @@ _LINE_NAMES = {
}
def create_annotated_image(image_bgr, measure_result):
def _grow_outward(start_col, fg_band, direction, limit):
"""从 start_col 沿 direction(+1 右 / -1 左) 在前景带 fg_band 内逐列外扩。
用于回收被误标成「头发」的外耳轮廓:耳朵被头发遮挡时,外耳轮廓那一圈常被
分割并入头发类,故耳朵掩膜外缘会偏内。这里把外缘沿紧邻的前景(耳∪发)向外
延伸,最多 limit 列;一旦下一列无前景(背景间隙)立即停止,绝不窜到分离的
那缕头发上。返回外扩后的列号。
"""
w = fg_band.shape[1]
c = int(start_col)
for _ in range(int(limit)):
nc = c + direction
if not (0 <= nc < w) or not fg_band[:, nc].any():
break
c = nc
return c
def _ear_edges_from_mask(ear_mask, hair_mask, y0, y1, left_cheek_x, right_cheek_x,
face_center_x):
"""从耳朵分割掩膜取人头最左/最右 x(仅在脸纵向范围 [y0,y1] 内统计)。
左线 = 脸中线左侧耳朵像素的最左列;右线 = 右侧耳朵像素的最右列;再沿紧邻的
前景(耳∪发)按脸宽自适应外扩,回收被误标成头发的外耳轮廓(见 _grow_outward)。
某侧耳朵不可见(被头发/侧脸遮挡 → 掩膜为空),或外缘未越过对应脸颊线(非真实
头宽边缘)时该侧返回 None —— 即「看不到耳朵就不画这条线」。
"""
if ear_mask is None:
return None, None
m = np.asarray(ear_mask)
if m.ndim == 3:
m = m[..., 0]
m = m > 0
h = m.shape[0]
y0 = max(0, int(y0)); y1 = min(h - 1, int(y1))
if y1 <= y0:
return None, None
ear_band = m[y0:y1 + 1]
cols = np.where(ear_band.any(axis=0))[0]
if cols.size == 0:
return None, None
# 前景带 = 耳∪发(外耳轮廓常被误标为发),外扩上限按脸宽自适应
fg_band = ear_band
if hair_mask is not None:
hm = np.asarray(hair_mask)
if hm.ndim == 3:
hm = hm[..., 0]
fg_band = ear_band | (hm[y0:y1 + 1] > 0)
grow = max(2, round(max(1.0, right_cheek_x - left_cheek_x) * 0.045))
left_cols = cols[cols < face_center_x]
right_cols = cols[cols > face_center_x]
# 左/右耳外缘(外扩后),且必须在对应脸颊线外侧(否则视为残缺/噪声,只保留脸颊线)
head_l = head_r = None
if right_cols.size:
edge = _grow_outward(right_cols.max(), fg_band, +1, grow)
head_r = float(edge) if edge >= right_cheek_x else None
if left_cols.size:
edge = _grow_outward(left_cols.min(), fg_band, -1, grow)
head_l = float(edge) if edge <= left_cheek_x else None
return head_l, head_r
def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=None,
variant="v1"):
"""生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。
布局:
- 线条只覆盖人脸范围(横线=脸宽,竖线=脸高),渐变消失。
- 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,**线名在右侧**;
四庭 cm 数值(顶庭/上庭/中庭/下庭)在左侧各段中点
- 6线:左脸颊/左眼外角/左眼内角/右眼内角/右眼外角/右脸颊,把脸宽切 5 段;
每段宽度在脸的**上端和下端**各标一次(只标 `X.XXcm`,不写名)
布局(对齐 img1.png 版本5
- 纵向竖线:人头最左 + 七眼 6 点 + 人头最右,切 7 段(七眼)。人头最左/最右
取自耳朵分割掩膜的外缘(方案 B,BiSeNet 类 7/8);耳朵不可见(被头发/侧脸
遮挡 → 掩膜空)或无掩膜时省略该侧端线,只画对应脸颊线
- 5分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。
- 四庭(顶/上/中/下庭)在左侧:名 + 数值(带 cm) + 百分比三行换行,竖向虚线双箭头
- 七眼段宽上下交替(上 3 / 下 4):数值(带 cm) 上、百分比(占头宽比)下,横向虚线双箭头。
variant="v6"(接口6):去掉头顶横线与顶庭(只画发际线/眉心/鼻翼下缘/下巴尖 4 条
横线 + 上/中/下庭),竖线纵向范围改为发际线→下巴尖,且不画人头最左/最右端线
(仅七眼 6 点 5 段,不取头部端)。
"""
h, w = image_bgr.shape[:2]
v = measure_result.vertical
pc = measure_result.px_per_cm
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
s = min(w, h)
font_size = max(9, round(s * 0.020)) # 字号上调一档
line_w = max(1, round(s * 0.0022))
dash_len = max(4, round(s * 0.008))
gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线)
arrow_size = max(2, round(s * 0.0045)) # 箭头更小
pad = max(4, round(s * 0.012)) # 文字与线的间距
line_h = font_size + max(2, round(font_size * 0.18))
buf = np.zeros((h, w, 4), dtype=np.uint8)
# 发际线弃用(hairline_discarded):保留头顶横线,去掉发际线横线,
# 也不标顶/上庭(缺发际线作边界,算不出)。横线 = 头顶/眉心/鼻翼下缘/下巴尖。
if getattr(measure_result, "hairline_discarded", False):
order = ["hair_top", "brow_center", "nose_bottom", "chin_tip"]
elif variant == "v6":
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
else:
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
ys = [v[name][1] for name in order]
pts = measure_result.eyes["points"]
seven_keys = ["left_cheek", "left_outer", "left_inner",
"right_inner", "right_outer", "right_cheek"]
xs = sorted(pts[k][0] for k in seven_keys) # 自左向右
base_xs = [pts[k][0] for k in seven_keys]
if variant == "v6":
xs = sorted(base_xs) # 接口6:仅七眼 6 点,不画人头最左/最右端线
else:
# 人头最左/最右:取自耳朵分割掩膜外缘(方案B,类7/8),脸纵向范围内统计。
# 看不到耳朵(被头发/侧脸遮挡 → 掩膜空)或外缘未越过脸颊线则省略该侧端线。
lcx, rcx = pts["left_cheek"][0], pts["right_cheek"][0]
head_l, head_r = _ear_edges_from_mask(
ear_mask, hair_mask, ys[0], ys[-1], lcx, rcx, (lcx + rcx) / 2)
head_xs = [x for x in (head_l, head_r) if x is not None]
xs = sorted(base_xs + head_xs) # 自左向右(6 或 7/8 点)
# 人脸包围盒:x 为脸宽(左右脸颊),y 为脸高(头顶→下巴)
# 人脸/人头包围盒
fx0, fx1 = xs[0], xs[-1]
fy0, fy1 = ys[0], ys[-1]
face_cx = (fx0 + fx1) / 2
face_half = (fx1 - fx0) / 2 * 1.08 # 略放大确保横线覆盖到脸颊
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
# 竖线纵向范围:v6 = 发际线→下巴尖(不超出);v1(含发际线弃用)= 头顶→下巴尖并两端超出一点
v_top = fy0 if variant == "v6" else fy0 - over
v_bot = fy1 if variant == "v6" else fy1 + over
# --- 1. 横向 5 条分界线(渐变,覆盖脸宽 ---
# --- 1. 横向分界线(渐变,覆盖头宽并超出一点 ---
for cy in ys:
draw_gradient_horizontal_line(buf, face_cx, cy, half_length=face_half)
draw_gradient_horizontal_line(buf, face_cx, cy, half_length=face_half, width=line_w)
# --- 2. 纵向 6 条线(渐变,覆盖脸高 头顶→下巴 ---
# --- 2. 纵向线(渐变,覆盖 v_top→v_bot ---
for vx in xs:
draw_gradient_vertical_line(buf, vx, fy0, fy1)
draw_gradient_vertical_line(buf, vx, v_top, v_bot, width=line_w)
canvas = Image.fromarray(buf, mode="RGBA")
draw = ImageDraw.Draw(canvas)
font = _load_font()
font = _load_font(font_size)
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖) ---
name_x = fx1 + 8
# --- 2b. 每条横线在「中间线段」(两内眼角之间)中点画原点突出 ---
# 注意:原点不在整条线的中点 face_cx,而在被竖线切出的中间段(左内眼角↔右内眼角)
# 的正中,即脸的竖直中轴附近、两内眼角连线中点。
li_x = pts["left_inner"][0]
ri_x = pts["right_inner"][0]
dot_cx = (li_x + ri_x) / 2
dot_r = max(2, round(s * 0.0045)) # 原点半径,与 arrow_size 同档自适应
for cy in ys:
x0, y0 = dot_cx - dot_r, cy - dot_r
x1, y1 = dot_cx + dot_r, cy + dot_r
draw.ellipse((x0, y0, x1, y1), fill=LINE_COLOR)
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字纵向居中对齐到线 ---
name_x = fx1 + over + pad # 移到横线右端外侧一点(往右)
for i, name in enumerate(order):
text = _LINE_NAMES[name]
tw, _ = _text_size(draw, text, font)
x = min(name_x, w - 2 - tw) # 右侧越界时回收
draw.text((x, ys[i] - FONT_SIZE / 2), 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 数值(各段中点,右对齐到脸盒左缘) ---
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
# court_start:庭段在 order 里的起始索引。发际线弃用时 order 首位是头顶(无下界发际线,
# 顶/上庭不标),中庭从眉心开始 → 跳过 order[0]。
if getattr(measure_result, "hairline_discarded", False):
court_cm = [measure_result.middle_cm, measure_result.lower_cm]
court_name = ["中庭", "下庭"]
n_court = 2
court_start = 1
elif variant == "v6":
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
court_name = ["上庭", "中庭", "下庭"]
n_court = 3
court_start = 0
else:
court_cm = [measure_result.top_cm, measure_result.upper_cm,
measure_result.middle_cm, measure_result.lower_cm]
court_name = ["顶庭", "上庭", "中庭", "下庭"]
for i in range(4):
text = f"{court_name[i]} {court_cm[i]:.2f}cm"
tw, _ = _text_size(draw, text, font)
x = max(2, fx0 - 8 - tw) # 贴脸盒左缘,右对齐
y_mid = (ys[i] + ys[i + 1]) / 2 - FONT_SIZE / 2
draw.text((x, y_mid), text, fill=LINE_COLOR, font=font)
n_court = 4
court_start = 0
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
court_total = sum(court_cm) or 1.0 # 各庭占比分母 = 四庭(v6 三庭)之和
for i in range(n_court):
y_a, y_b = ys[court_start + i], ys[court_start + i + 1]
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
inset = min(arrow_size, (y_b - y_a) * 0.12)
draw_dashed_line_with_arrows(
draw, arrow_x, y_a + inset, arrow_x, y_b - inset,
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
# 名 + 数值(带 cm) + 百分比三行,右对齐到箭头左侧
name = court_name[i]
val = f"{court_cm[i]:.2f}cm"
pct = f"{court_cm[i] / court_total * 100:.1f}%"
nw, _ = _text_size(draw, name, font)
vw, _ = _text_size(draw, val, font)
pw, _ = _text_size(draw, pct, font)
label_right = arrow_x - pad
y_mid = (y_a + y_b) / 2
y_top = y_mid - 1.5 * line_h
draw.text((max(2, label_right - nw), y_top), name, fill=LINE_COLOR, font=font)
draw.text((max(2, label_right - vw), y_top + line_h), val, fill=LINE_COLOR, font=font)
draw.text((max(2, label_right - pw), y_top + 2 * line_h), pct, fill=LINE_COLOR, font=font)
# --- 4. 七眼每段宽度:脸的上端 + 下端各标一次(相邻段上下错行防重叠) ---
row_h = FONT_SIZE + 2
y_top_a = max(1, fy0 - row_h - 2) # 上端:头顶线上方两行
y_top_b = max(1, fy0 - 2 * row_h - 2)
y_bot_a = min(h - FONT_SIZE - 1, fy1 + 2) # 下端:下巴线下方两行
y_bot_b = min(h - FONT_SIZE - 1, fy1 + row_h + 2)
# --- 4. 七眼每段宽度:上下交替(上 3 / 下 4),横向虚线双箭头 + 数值(带 cm) + 百分比 ---
# 每段两行:数值(带 cm) 上、百分比 下;百分比分母 = 整个头宽(七段之和)
txt_off = arrow_size + pad * 2
txt_block = 2 * line_h # 两行文字总高(数值 + 百分比)
y_arrow_top = max(txt_off + txt_block + 2, fy0 - pad - arrow_size)
y_arrow_bot = min(h - txt_off - txt_block - 2, fy1 + pad + arrow_size)
head_w = (xs[-1] - xs[0]) or 1.0 # 头宽(像素)= 百分比分母
for i in range(len(xs) - 1):
seg_cm = (xs[i + 1] - xs[i]) / pc
cx_seg = (xs[i] + xs[i + 1]) / 2
text = f"{seg_cm:.2f}cm"
tw, _ = _text_size(draw, text, font)
ty_top = y_top_a if i % 2 == 0 else y_top_b
ty_bot = y_bot_a if i % 2 == 0 else y_bot_b
draw.text((cx_seg - tw / 2, ty_top), text, fill=LINE_COLOR, font=font)
draw.text((cx_seg - tw / 2, ty_bot), text, fill=LINE_COLOR, font=font)
x_a, x_b = xs[i], xs[i + 1]
if x_b - x_a < 1:
continue
seg_cm = (x_b - x_a) / pc
seg_pct = (x_b - x_a) / head_w * 100
cx_seg = (x_a + x_b) / 2
val = f"{seg_cm:.2f}cm"
pct = f"{seg_pct:.1f}%"
vw, _ = _text_size(draw, val, font)
pw, _ = _text_size(draw, pct, font)
inset = min(arrow_size, (x_b - x_a) * 0.12)
on_top = (i % 2 == 1) # 奇数段在上 → 上 3 / 下 4
y_arrow = y_arrow_top if on_top else y_arrow_bot
draw_dashed_line_with_arrows(
draw, x_a + inset, y_arrow, x_b - inset, y_arrow,
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
# 数值行在上、百分比行在下;on_top 时整块置于箭头上方,否则下方
text_top = (y_arrow - txt_off - txt_block) if on_top else (y_arrow + txt_off)
draw.text((cx_seg - vw / 2, text_top), val, fill=LINE_COLOR, font=font)
draw.text((cx_seg - pw / 2, text_top + line_h), pct, fill=LINE_COLOR, font=font)
return canvas
@@ -205,15 +373,16 @@ if __name__ == "__main__":
print("未检出人脸")
sys.exit(1)
mask = None
ears = None
try:
from face_analysis.hair_segmenter import get_segmenter
mask = get_segmenter().segment_hair(img)
mask, ears = get_segmenter().segment_hair_and_ears(img)
except Exception as e: # noqa: BLE001
print(f"[warn] 分割不可用,回退方案 A:{e}")
result = measure_face(lms, mask, w, h)
t0 = time.time()
canvas = create_annotated_image(img, result)
canvas = create_annotated_image(img, result, ear_mask=ears, hair_mask=mask)
dt = time.time() - t0
os.makedirs(os.path.dirname(out), exist_ok=True)
canvas.save(out)
+1 -1
View File
@@ -79,7 +79,7 @@ class Resnet18(nn.Module):
def init_weight(self):
# 优先本地骨干权重(内网离线),缺失才回退 torch model_zoo(会查缓存)。
if os.path.isfile(_LOCAL_RESNET18):
state_dict = torch.load(_LOCAL_RESNET18, map_location="cpu")
state_dict = torch.load(_LOCAL_RESNET18, map_location="cpu", weights_only=False)
else:
state_dict = modelzoo.load_url(resnet18_url)
self_state_dict = self.state_dict()
+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 的版本)
+50 -9
View File
@@ -1,7 +1,8 @@
"""方案 B:BiSeNet 头发分割 + 发际线/头顶定位。
"""方案 BBiSeNet 头发/耳朵分割 + 发际线/头顶定位。
加载 face-parsing BiSeNet19 类,hair=17),对整图做像素级语义分割得到头发
mask,再沿面部中轴线扫描得到真实发际线与头顶。GPU 可用时走 CUDA,否则 CPU。
加载 face-parsing BiSeNetCelebAMask-HQ 19 类,hair=17、l_ear=7、r_ear=8),对整图
做像素级语义分割:得到头发 mask 用于沿面部中轴线扫描真实发际线与头顶,并得到耳朵
mask 供标注图取人头最左/最右竖线(耳朵外缘)。GPU 可用时走 CUDA,否则 CPU。
单例加载权重,避免每请求重载。详见技术方案 §1.4 / §4.0。
"""
import os
@@ -15,6 +16,7 @@ import numpy as np
_WEIGHTS = os.path.join(os.path.dirname(__file__), "weights", "79999_iter.pth")
HAIR_CLASS = 17 # CelebAMask-HQ 19 类中 hair 的索引
EAR_CLASSES = (7, 8) # 7=l_ear / 8=r_ear(类名以人为参照,图像左右另行判定,不依赖类名)
N_CLASSES = 19
_INPUT_SIZE = 512 # BiSeNet 推理输入边长
@@ -50,7 +52,7 @@ class HairSegmenter:
self._torch = torch
self.device = _select_device(torch)
self.net = BiSeNet(n_classes=N_CLASSES)
state = torch.load(weights_path, map_location="cpu")
state = torch.load(weights_path, map_location="cpu", weights_only=False)
self.net.load_state_dict(state)
self.net.to(self.device)
self.net.eval()
@@ -59,8 +61,8 @@ class HairSegmenter:
transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),
])
def segment_hair(self, image_bgr):
"""返回 hair_maskH×W boolTrue=头发),尺寸同输入原图"""
def _parse(self, image_bgr):
"""整图语义分割,返回原图尺寸的类别图(H×W int,值为 0–18 类别号)"""
torch = self._torch
h, w = image_bgr.shape[:2]
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
@@ -70,10 +72,49 @@ class HairSegmenter:
with torch.no_grad():
out = self.net(inp)[0] # 主输出 (1, C, 512, 512)
parsing = out.squeeze(0).argmax(0).cpu().numpy() # (512, 512) 类别图
hair_small = (parsing == HAIR_CLASS).astype(np.uint8)
# 还原到原图尺寸(最近邻保持类别边界)
hair_mask = cv2.resize(hair_small, (w, h), interpolation=cv2.INTER_NEAREST)
return hair_mask.astype(bool)
return cv2.resize(parsing.astype(np.int32), (w, h),
interpolation=cv2.INTER_NEAREST)
def segment_hair(self, image_bgr):
"""返回 hair_maskH×W boolTrue=头发),尺寸同输入原图。"""
return self._parse(image_bgr) == HAIR_CLASS
def _parse_face_cropped(self, image_bgr, face_box):
"""按人脸框裁剪后再分割,结果映射回原图尺寸(裁剪外填背景 0)。
BiSeNet 在 CelebAMask-HQ「紧裁对齐人脸」上训练,整张大场景图(全身/街拍,
脸只占一小块、背景复杂)会严重欠分割、丢耳朵。先按人脸放大裁剪,让脸接近
训练分布,耳朵/头发分割明显更稳。裁剪含足够上/侧边距以纳入发顶与双耳。
"""
h, w = image_bgr.shape[:2]
x0, y0, x1, y1 = face_box
fw, fh = max(1.0, x1 - x0), max(1.0, y1 - y0)
cx0 = int(max(0, x0 - fw * 0.8)); cx1 = int(min(w, x1 + fw * 0.8))
cy0 = int(max(0, y0 - fh * 1.0)); cy1 = int(min(h, y1 + fh * 0.5))
if cx1 - cx0 < 2 or cy1 - cy0 < 2:
return self._parse(image_bgr)
full = np.zeros((h, w), dtype=np.int32)
full[cy0:cy1, cx0:cx1] = self._parse(image_bgr[cy0:cy1, cx0:cx1])
return full
def segment_hair_and_ears(self, image_bgr, face_box=None):
"""单次推理返回 (hair_mask, ear_mask),均为 H×W bool,尺寸同原图。
ear_mask = 左耳(7) ∪ 右耳(8);耳朵被头发/侧脸遮挡时对应区域天然为空,
正好用于「看不到耳朵就不画线」的判定。两类合并、左右按图像位置另判,
不依赖以人为参照的类名(详见 EAR_CLASSES 注释)。
face_box=(x0,y0,x1,y1)(人脸关键点包围盒像素坐标)给定时先按人脸裁剪再
分割(见 _parse_face_cropped),整张大场景图也能稳定分出耳朵;不给则整图分割。
"""
if face_box is None:
parsing = self._parse(image_bgr)
else:
parsing = self._parse_face_cropped(image_bgr, face_box)
hair_mask = parsing == HAIR_CLASS
ear_mask = np.isin(parsing, EAR_CLASSES)
return hair_mask, ear_mask
_segmenter = None
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
"""接口10:头部外缘膨胀带遮罩。
先和接口9 一样得到**内缩后的基准遮罩**(含额头的闭合区域外缘朝151内缩 erode_cm、底线不动,默认1.2cm),
在这个基础上:
1. 取基准遮罩的**外轮廓线**(1px),去掉贴着底部分界线的那一段(只留头发/头部外缘弧线)。
2. 把这条外轮廓线膨胀成带子(半径 = dilate_cm/2,即带子**总宽 ≈ dilate_cm**,默认 2cm)。
3. 裁到分界线以上(不越过底线)。
输出这条带子作为 mask。BiSeNet / SegFormer 两套并排对比,分步可视化。
两个可调参数:erode_cm(同接口9 的内缩,默认1.2)+ dilate_cm(带子总宽,默认2)。
复用 `head_mask` 的构件,避免重复实现。
"""
import cv2
import numpy as np
from face_analysis.detector import detector
from face_analysis.calibration import estimate_scale_factor
from face_analysis.head_mask import (
BASELINE_IDX, ERODE_CM, NoFaceError,
_baseline_points, _upper_region_mask, _fill_to_baseline, _largest_cc,
_bisenet_hair_mask, _segformer_hair_mask,
_erode, _overlay, _draw_baseline, _b64png, _mask_png,
)
DILATE_CM = 2.0 # 膨胀后带子总宽(厘米,默认;半径 = 总宽/2;可由入参覆盖)
def _dilate(mask_bool, r):
"""圆盘核膨胀半径 r(像素)。r<=0 原样返回。"""
if r <= 0:
return mask_bool.copy()
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1))
return cv2.dilate(mask_bool.astype(np.uint8), k).astype(bool)
def _baseline_line_mask(baseline_pts, w, h, thickness):
"""底部分割线(含左右水平延长线)画成一条带厚度的线,用于从外轮廓里剔除底边。"""
m = np.zeros((h, w), np.uint8)
y_l = baseline_pts[0][1]
y_r = baseline_pts[-1][1]
chain = [(0, y_l)] + baseline_pts + [(w - 1, y_r)]
for a, b in zip(chain[:-1], chain[1:]):
cv2.line(m, a, b, 1, thickness)
return m.astype(bool)
def _outer_contour_no_bottom(region, baseline_band):
"""区域外轮廓(1px)去掉贴着底部分界线的那一段。"""
contour = region & ~_erode(region, 1)
return contour & ~baseline_band
def _model_result(image_bgr, hair_mask, upper, baseline_pts, baseline_band, r_erode, r_dilate, w):
"""单个分割模型的分步结果(内缩后基准遮罩 / 外轮廓线 / 膨胀带 / 纯遮罩)。"""
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
base = _largest_cc(_erode(top_fill, r_erode) & upper) # 接口9 内缩后的基准遮罩
contour = _outer_contour_no_bottom(base, baseline_band) # 外轮廓,去底线
band = _largest_cc(_dilate(contour, r_dilate) & upper) # 膨胀成带、裁到底线以上
return {
"base_pixels": int(base.sum()),
"band_pixels": int(band.sum()),
"base_mask_base64": _b64png(
_draw_baseline(_overlay(image_bgr, base, (255, 150, 0)), baseline_pts, w)),
"contour_base64": _b64png(
_draw_baseline(_overlay(image_bgr, _dilate(contour, 2), (0, 255, 0)), baseline_pts, w)),
"band_overlay_base64": _b64png(
_draw_baseline(_overlay(image_bgr, band, (0, 0, 255)), baseline_pts, w)),
"mask_base64": _mask_png(band),
}
def generate_head_band(image_bgr, erode_cm=ERODE_CM, dilate_cm=DILATE_CM):
"""接口10 完整管线。返回可直接进 ok() 的 data dict。
erode_cm:基准遮罩外缘朝151 内缩距离(厘米,同接口9),页面可调,默认 1.2cm。
dilate_cm:外轮廓线膨胀后带子总宽(厘米),页面可调,默认 2cm。
未检出人脸抛 NoFaceError。单个分割模型异常不影响另一个(记为 {"error": ...})。
"""
h, w = image_bgr.shape[:2]
landmarks = detector.detect(image_bgr)
if landmarks is None:
raise NoFaceError()
erode_cm = max(0.0, float(erode_cm))
dilate_cm = max(0.0, float(dilate_cm))
px_per_cm = estimate_scale_factor(landmarks, w, h)
r_erode = int(round(erode_cm * px_per_cm)) # 内缩半径
r_dilate = int(round((dilate_cm / 2.0) * px_per_cm)) # 膨胀半径 = 总宽/2
baseline_pts = _baseline_points(landmarks, w, h)
upper = _upper_region_mask(baseline_pts, w, h)
# 剔除底边用的分界线带:几像素宽即可,独立于膨胀/内缩半径
baseline_band = _dilate(_baseline_line_mask(baseline_pts, w, h, 5), 2)
baseline_viz = _draw_baseline(image_bgr, baseline_pts, w)
data = {
"px_per_cm": round(float(px_per_cm), 4),
"erode_cm": round(erode_cm, 2),
"erode_px": r_erode,
"dilate_cm": round(dilate_cm, 2),
"dilate_radius_px": r_dilate,
"image_size": {"width": w, "height": h},
"baseline_landmarks": [
{"index": idx, "x": p[0], "y": p[1]}
for idx, p in zip(BASELINE_IDX, baseline_pts)
],
"steps_common": {
"landmarks_baseline_base64": _b64png(baseline_viz),
"upper_region_base64": _b64png(_overlay(baseline_viz, upper, (0, 200, 0))),
},
}
seg_fns = {
"bisenet": lambda: _bisenet_hair_mask(image_bgr, landmarks, w, h),
"segformer": lambda: _segformer_hair_mask(image_bgr),
}
for name, fn in seg_fns.items():
try:
hair_mask = fn()
data[name] = _model_result(image_bgr, hair_mask, upper, baseline_pts,
baseline_band, r_erode, r_dilate, w)
except Exception as ex: # noqa: BLE001 单模型失败不影响整体
data[name] = {"error": f"{type(ex).__name__}: {ex}"}
return data
+226
View File
@@ -0,0 +1,226 @@
"""接口9:头发遮罩生成。
流程(详见需求讨论):
1. MediaPipe 关键点检测。
2. 底部分割线 = 关键点 [162,71,68,104,69,108,151,337,299,333,298,301,389] 的连线(左端162→中心151→右端389),
再把左端点 162 水平延伸到图片最左边、右端点 389 水平延伸到图片最右边。
3. 上半区 = 分割线以上区域(多边形填充:左边缘→弧线→右边缘→上边缘闭合)。
4. 头发分割:BiSeNet 与 SegFormer 各出一张 hair_mask(两套供对比)。
5. 闭合区域(含额头):每列从最顶端头发像素向下填充到分割线,把头发与画线之间的额头皮肤
也包进来(不再从发际线割断),底边即分割线。
6. 外缘内缩 erode_cm(默认 1.2cm,可调)、底线不动:对「填充到图底的实心块」做半径 r 的腐蚀,
再与上半区相交。腐蚀只把外轮廓(顶/两侧)朝内(朝 151)收 r;平底边是相交后才产生的,
所以底线纹丝不动。cm→像素用虹膜标定(calibration.estimate_scale_factor)。
对外返回每一步叠加在原图上的可视化图(base64 PNG,data URI),供测试页逐步展示。
"""
import base64
import cv2
import numpy as np
from face_analysis.detector import detector
from face_analysis.calibration import estimate_scale_factor, normalized_to_pixel
# 底部分割线关键点(图像上从左到右,眉骨弧线 → 中心 151 → 右侧对称)
# 左端 104 → 中心 151 → 右端 333;首末点向图片左右边缘水平延长
# BASELINE_IDX = [104, 69, 108, 151, 337, 299, 333]
# BASELINE_IDX = [34, 139, 71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301, 368, 264]
BASELINE_IDX = [71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301]
CENTER_IDX = 151 # 内缩方向的目标点(额头中心)
ERODE_CM = 1.2 # 外缘内缩距离(厘米,默认;可由入参覆盖)
SEGFORMER_HAIR = 13 # jonathandinu/face-parsing 中 hair 类索引
class NoFaceError(Exception):
"""未检测到人脸。"""
# ---------------------------------------------------------------------------
# 几何:分割线与上半区
# ---------------------------------------------------------------------------
def _px(landmarks, idx, w, h):
p = landmarks.landmark[idx]
return (int(round(p.x * w)), int(round(p.y * h)))
def _baseline_points(landmarks, w, h):
"""额头弧线各关键点的像素坐标(按 BASELINE_IDX 顺序,左→右)。"""
return [_px(landmarks, i, w, h) for i in BASELINE_IDX]
def _upper_region_mask(baseline_pts, w, h):
"""分割线以上区域(boolH×W)。
多边形顶点:左上角 →(0, y左端)→ 弧线各点 →(w-1, y右端)→ 右上角,闭合后填充。
左端/右端为两段水平延长线(向图片左右边缘延伸)。
"""
x0, y0 = baseline_pts[0]
x1, y1 = baseline_pts[-1]
poly = [(0, 0), (0, y0)] + baseline_pts + [(w - 1, y1), (w - 1, 0)]
mask = np.zeros((h, w), np.uint8)
cv2.fillPoly(mask, [np.array(poly, np.int32)], 1)
return mask.astype(bool)
# ---------------------------------------------------------------------------
# 头发分割(两套)
# ---------------------------------------------------------------------------
def _bisenet_hair_mask(image_bgr, landmarks, w, h):
"""BiSeNet(接口1 同款):先按人脸框裁剪再分割,稳住小脸大图。"""
from face_analysis.hair_segmenter import get_segmenter
pxs = [normalized_to_pixel(p, w, h) for p in landmarks.landmark]
face_box = (min(p[0] for p in pxs), min(p[1] for p in pxs),
max(p[0] for p in pxs), max(p[1] for p in pxs))
hair_mask, _ear = get_segmenter().segment_hair_and_ears(image_bgr, face_box=face_box)
return np.asarray(hair_mask, dtype=bool)
def _segformer_hair_mask(image_bgr):
"""复用接口2/3 的 SegFormer 单例(hairline.service.get_parser),
共用权重与设备策略(SEG_DEVICE,默认 cpu;本机 5090 上 CUDA 内核不可用故走 CPU)。"""
from hairline.service import get_parser
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
labels = get_parser().parse(rgb)
return labels == SEGFORMER_HAIR
# ---------------------------------------------------------------------------
# 形态学 & 可视化
# ---------------------------------------------------------------------------
def _erode(mask_bool, r):
"""圆盘核腐蚀半径 r(像素)。r<=0 原样返回。"""
if r <= 0:
return mask_bool.copy()
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1))
return cv2.erode(mask_bool.astype(np.uint8), k).astype(bool)
def _overlay(image, mask_bool, color, alpha=0.45):
"""把纯色以 alpha 叠加到 mask 区域上(非 mask 区域保持原样)。"""
out = image.copy()
if mask_bool.any():
out[mask_bool] = (out[mask_bool] * (1 - alpha)
+ np.array(color, np.float32) * alpha).astype(np.uint8)
return out
def _draw_baseline(image, baseline_pts, w):
"""画分割线(含左右水平延长线)+ 关键点,中心点 151 标红。"""
out = image.copy()
y0 = baseline_pts[0][1]
y1 = baseline_pts[-1][1]
chain = [(0, y0)] + baseline_pts + [(w - 1, y1)]
for a, b in zip(chain[:-1], chain[1:]):
cv2.line(out, a, b, (0, 255, 255), 2, cv2.LINE_AA)
for idx, p in zip(BASELINE_IDX, baseline_pts):
col = (0, 0, 255) if idx == CENTER_IDX else (0, 200, 0)
cv2.circle(out, p, 4, col, -1, cv2.LINE_AA)
cv2.putText(out, str(idx), (p[0] + 4, p[1] - 6),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, col, 1, cv2.LINE_AA)
return out
def _b64png(bgr):
"""BGR 图 → data URIPNG base64)。gateway 会把 *_base64 字段落盘改成 *_url。"""
ok, buf = cv2.imencode(".png", bgr)
return "data:image/png;base64," + base64.b64encode(buf.tobytes()).decode()
def _mask_png(mask_bool):
"""纯遮罩:白(255)为遮罩、黑为背景。"""
m = (mask_bool.astype(np.uint8)) * 255
return _b64png(cv2.cvtColor(m, cv2.COLOR_GRAY2BGR))
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
def _largest_cc(mask_bool):
"""保留最大连通域,去掉背景里孤立的杂散头发列。空掩膜原样返回。"""
m = mask_bool.astype(np.uint8)
if m.sum() == 0:
return mask_bool
n, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8)
if n <= 2: # 只有背景 + 一个前景
return mask_bool
largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
return labels == largest
def _fill_to_baseline(hair_mask, upper):
"""含额头的实心区域:每列从最顶端头发像素向下填充(延伸到图底,未按基线裁剪)。
这样头发与画线之间的额头皮肤被包进闭合区域(不再被割断);未裁剪到基线是为了
后续腐蚀时底线不动(腐蚀在延伸到图底的实心块上做,再与上半区相交切平底边)。
"""
has_hair = (hair_mask & upper).astype(np.uint8)
return np.maximum.accumulate(has_hair, axis=0).astype(bool)
def _model_result(image_bgr, hair_mask, upper, baseline_pts, r, w):
"""单个分割模型的分步结果(闭合区域 / 最终遮罩 / 可视化)。"""
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
closed = _largest_cc(top_fill & upper) # 闭合区域:头发+额头,底=基线
final = _largest_cc(_erode(top_fill, r) & upper) # 外缘朝151内缩 r、底线不动
return {
"hair_pixels": int(hair_mask.sum()),
"closed_pixels": int(closed.sum()),
"mask_pixels": int(final.sum()),
"hair_mask_base64": _b64png(_overlay(image_bgr, hair_mask, (255, 150, 0))),
"closed_region_base64": _b64png(
_draw_baseline(_overlay(image_bgr, closed, (255, 150, 0)), baseline_pts, w)),
"final_overlay_base64": _b64png(
_draw_baseline(_overlay(image_bgr, final, (0, 0, 255)), baseline_pts, w)),
"mask_base64": _mask_png(final),
}
def generate_head_mask(image_bgr, erode_cm=ERODE_CM):
"""接口9 完整管线。返回可直接进 ok() 的 data dict。
erode_cm:外缘朝 151 内缩的距离(厘米),页面可调,默认 1cm。
未检出人脸抛 NoFaceError。单个分割模型异常不影响另一个(记为 {"error": ...})。
"""
h, w = image_bgr.shape[:2]
landmarks = detector.detect(image_bgr)
if landmarks is None:
raise NoFaceError()
erode_cm = max(0.0, float(erode_cm))
px_per_cm = estimate_scale_factor(landmarks, w, h)
r = int(round(erode_cm * px_per_cm))
baseline_pts = _baseline_points(landmarks, w, h)
upper = _upper_region_mask(baseline_pts, w, h)
baseline_viz = _draw_baseline(image_bgr, baseline_pts, w)
data = {
"px_per_cm": round(float(px_per_cm), 4),
"erode_cm": round(erode_cm, 2),
"erode_px": r,
"image_size": {"width": w, "height": h},
"baseline_landmarks": [
{"index": idx, "x": p[0], "y": p[1]}
for idx, p in zip(BASELINE_IDX, baseline_pts)
],
"steps_common": {
"landmarks_baseline_base64": _b64png(baseline_viz),
"upper_region_base64": _b64png(_overlay(baseline_viz, upper, (0, 200, 0))),
},
}
seg_fns = {
"bisenet": lambda: _bisenet_hair_mask(image_bgr, landmarks, w, h),
"segformer": lambda: _segformer_hair_mask(image_bgr),
}
for name, fn in seg_fns.items():
try:
hair_mask = fn()
data[name] = _model_result(image_bgr, hair_mask, upper, baseline_pts, r, w)
except Exception as ex: # noqa: BLE001 单模型失败不影响整体
data[name] = {"error": f"{type(ex).__name__}: {ex}"}
return data
+87 -21
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,21 +142,46 @@ def measure_seven_eyes(landmarks, image_width, image_height):
}
def pt_or_none(vertical, name):
"""vertical dict 的点 → {"x","y"},值为 None 时返回 None。"""
v = vertical.get(name)
if v is None:
return None
return {"x": int(round(v[0])), "y": int(round(v[1]))}
class MeasureResult:
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose):
# 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。
# hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线
# 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。
HAIRLINE_DISCARD_TOP_CM = 0.7
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose,
landmarks=None, image_width=None, image_height=None):
self.vertical = vertical
self.eyes = eyes
self.px_per_cm = px_per_cm
self.hairline_source = hairline_source
self.head_pose = head_pose # (yaw, pitch, roll) 或 None
# 原始 mediapipe 点集 + 图像尺寸,供 to_response 输出 21/251 号定位点
self.landmarks = landmarks
self.w = image_width
self.h = image_height
# 各庭厘米
self.top_cm = vertical["top_court_px"] / px_per_cm
self.upper_cm = vertical["upper_court_px"] / px_per_cm
self.middle_cm = vertical["middle_court_px"] / px_per_cm
self.lower_cm = vertical["lower_court_px"] / px_per_cm
# 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。
# 弃用时 hairline_source 改为 "discarded"face_total 只算中庭+下庭。
self.hairline_discarded = self.top_cm < self.HAIRLINE_DISCARD_TOP_CM
if self.hairline_discarded:
self.hairline_source = "discarded"
self.face_total_cm = self.middle_cm + self.lower_cm
else:
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
# 七眼厘米
@@ -167,14 +190,45 @@ class MeasureResult:
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
def to_response(self):
# 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭;
# landmarks.hair_top/hairline 置 null。否则按四庭正常输出。
if self.hairline_discarded:
base_px = (self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": None,
"upper_court_cm": None,
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": None,
"upper_court": None,
"middle_court": round(self.vertical["middle_court_px"] / base_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / base_px, 3),
},
},
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
},
"landmarks": {
"hair_top": None,
"hairline": None,
"brow_center": pt_or_none(self.vertical, "brow_center"),
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
},
"hairline_source": self.hairline_source,
}
else:
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
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 +248,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_positionmediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。
# landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。
if self.landmarks is not None and self.w and self.h:
lm = _lm_list(self.landmarks)
def _pt_lm(idx):
px, py = normalized_to_pixel(lm[idx], self.w, self.h)
return {"x": int(round(px)), "y": int(round(py))}
data["left_position"] = _pt_lm(LEFT_POSITION)
data["right_position"] = _pt_lm(RIGHT_POSITION)
if self.head_pose is not None:
yaw, pitch, roll = self.head_pose
data["head_pose"] = {
@@ -220,7 +285,8 @@ def measure_face(landmarks, hair_mask, image_width, image_height, head_pose=None
vertical, source = decide_vertical(landmarks, image_width, image_height, hair_mask)
eyes = measure_seven_eyes(landmarks, image_width, image_height)
px_per_cm = estimate_scale_factor(landmarks, image_width, image_height)
return MeasureResult(vertical, eyes, px_per_cm, source, head_pose)
return MeasureResult(vertical, eyes, px_per_cm, source, head_pose,
landmarks, image_width, image_height)
if __name__ == "__main__":
+10 -1
View File
@@ -50,12 +50,21 @@ def estimate_head_pose(landmarks, image_width, image_height):
[0, 0, 1]], dtype=np.float64)
dist = np.zeros((4, 1)) # 假设无畸变
success, rvec, _tvec = cv2.solvePnP(
success, rvec, tvec = cv2.solvePnP(
_MODEL_POINTS, image_points, cam_matrix, dist,
flags=cv2.SOLVEPNP_ITERATIVE,
)
if not success:
return None
# ITERATIVE 偶发收敛到相机后方的翻转解(tz<0),此时 roll 落在 ±180° 附近,
# 会把真正的正面照误判为 1003。改用 SQPNP 重解正深度解。
if float(tvec[2, 0]) < 0:
ok2, rvec2, tvec2 = cv2.solvePnP(
_MODEL_POINTS, image_points, cam_matrix, dist,
flags=cv2.SOLVEPNP_SQPNP,
)
if ok2 and float(tvec2[2, 0]) > 0:
rvec = rvec2
rot, _ = cv2.Rodrigues(rvec)
# 在「相机坐标系」(x右 y下 z内) 下抽取 Tait-Bryan 欧拉角,物理含义对齐:
# yaw = 绕 Y(竖轴)转 → 左右扭头
+50 -37
View File
@@ -18,7 +18,7 @@ logger = logging.getLogger("hair.worker")
ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-6-vision-250815")
# doubao 中文键 → 接口4 英文优先字段
# doubao 中文键 → 接口4 英文优先字段(仅保留这 6 项)
_KEY_MAP = {
"脸型": "face_shape",
"眉形": "eyebrow_shape",
@@ -28,55 +28,54 @@ _KEY_MAP = {
"基因风格": "gene_style",
}
# 移植自 fuyan FaceArk.GetPicDesc 的特征枚举(去掉身高体重前缀
# 仅请求接口4 需要的 6 个字段(+「图片是否有人脸」用于 1001 判定,不进最终输出
_PROMPT = (
"分析一下图片告诉我以下特征,只要答案,格式为json字符串,"
"图片是否有人脸(有人/没人) 三庭五眼特征(答案要有三庭五眼四个字,9个字以内) "
"面部年龄(给出区间年龄)鼻长(鼻长适中/长鼻/短鼻) "
"脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 嘴型 "
"眼袋(答案要有眼袋两个个字) 眼型 鼻型 眼皮(双眼皮/单眼皮) "
"法令纹(有法令纹/无法令纹) 人中(人中适中/人中长/人中短) 眉形 "
"瞳色(答案要有瞳色两个字) 脖长(脖长适中/脖子短/脖子长) "
"肤色(粉一白/粉二白/粉三白/黄一白/黄二白/黄黑皮)"
"直得分 曲得分 直曲总分(直得分-曲得分) 大量感得分 小量感得分 量感总分(大量感得分-小量感得) "
"面部立体度(总分十分)瞳距(毫米)对比度(对比度较强/对比度适中/对比度较弱)"
"鼻子立体度(立体度高/立体度适中/立体度低)"
"色相(中间表示0,最大值分别是-5和5,负数表示偏冷,正数表示偏暖)"
"亮度(中间表示0,最大值分别是-5和5;负数表示暗沉,正数表示白皙)"
"色度(中间表示0,最大值分别是-5和5;负数表示饱和度低,正数表示鲜艳)"
"面部颜色对比度(10分制)"
"四季色彩季型(净春型/暖春型/浅春型/浅夏型/冷夏型/柔夏型/柔秋型/暖秋型/深秋型/净冬型/冷冬型/深冬型)"
"图片是否有人脸(有人/没人) "
"脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 眉形 "
"面部年龄(给出区间年龄) 动静类型(静态型/动态型) 性别(男/女) "
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
"量感类型(大量感/中量感/小量感)轮廓类型(轮廓偏曲/轮廓适中/轮廓偏直)"
"动静类型(静态型/动态型)性别(男/女)"
)
_client = None
_client = None # 缓存的 Ark clientapi_key 变更时自动重建)
_client_key: str | None = None # _client 构建时使用的 api_key,用于检测配置变更
def _load_api_key() -> str | None:
"""ARK_API_KEY 环境变量优先,否则读 worker_config.json 的 ark_api_key。"""
"""ARK_API_KEY 环境变量优先,否则读 worker_config.json / gateway/config.json 的 ark_api_key。"""
key = os.getenv("ARK_API_KEY")
if key:
return key
cfg = os.path.join(os.path.dirname(__file__), "worker_config.json")
base = os.path.dirname(__file__)
for cfg_name in ("worker_config.json", "gateway/config.json"):
cfg = os.path.join(base, cfg_name)
if os.path.isfile(cfg):
try:
with open(cfg, encoding="utf-8") as f:
return json.load(f).get("ark_api_key")
v = json.load(f).get("ark_api_key")
if v:
return v
except Exception as e: # noqa: BLE001
logger.warning("读取 worker_config.json ark_api_key 失败:%s", e)
logger.warning("读取 %s ark_api_key 失败:%s", cfg_name, e)
return None
def get_client():
global _client
if _client is None:
from volcenginesdkarkruntime import Ark
"""返回 Ark client。
client 全局缓存但每次都会重新读取 api_key 一旦配置环境变量 /
worker_config.json / gateway/config.json ark_api_key发生变化
自动重建 client这样换 key 后无需重启进程
"""
global _client, _client_key
key = _load_api_key()
if not key:
raise RuntimeError("缺少火山方舟 API Key(设 ARK_API_KEY 或 worker_config.json.ark_api_key")
# client 未建、或 key 变了 → 重建
if _client is None or key != _client_key:
from volcenginesdkarkruntime import Ark
_client = Ark(base_url=ARK_BASE_URL, api_key=key)
_client_key = key
return _client
@@ -98,11 +97,11 @@ def _image_to_url(image_bytes: bytes = None, image_url: str = None) -> str:
return f"data:image/{fmt};base64," + base64.b64encode(image_bytes).decode()
def analyze_features(image_bytes: bytes = None, image_url: str = None) -> dict:
def analyze_features(image_bytes: bytes = None, image_url: str = None):
"""调 doubao 视觉模型分析人脸特征。
Returns: dict 含接口4 英文优先字段(face_shape ) + doubao 全部中文字段
无人脸时 doubao 图片是否有人脸= 没人调用方据此 1001
Returns: dict 含接口4 6 个英文字段(face_shape/eyebrow_shape/facial_age/
dynamic_static_type/gender/gene_style)**无人脸返回 None**调用方据此判 1001
"""
url = _image_to_url(image_bytes, image_url)
resp = get_client().chat.completions.create(
@@ -114,14 +113,26 @@ def analyze_features(image_bytes: bytes = None, image_url: str = None) -> dict:
{"type": "text", "text": _PROMPT},
],
}],
max_tokens=1024, # 限制输出长度,模型秒回
temperature=0, # 固定输出,无随机采样,提速+结果稳定
stream=False, # 关闭流式,单次返回结果更快
extra_body={
"thinking": {
"type": "disabled", # 彻底关闭深度思考模式,提速50%+
},
},
)
text = resp.choices[0].message.content
data = _parse_json(text) # doubao 中文字段
# 英文优先字段映射(doubao 缺某字段则跳过)
for zh, en in _KEY_MAP.items():
if zh in data and en not in data:
data[en] = data[zh]
return data
logger.info("doubao raw response (first 500 chars): %s", text[:500])
try:
raw = _parse_json(text) # doubao 原始中文字段
except (json.JSONDecodeError, ValueError) as e:
logger.error("doubao 返回非 JSON,原文: %s", text[:1000])
raise RuntimeError(f"豆包模型返回格式异常,无法解析为 JSON:{text[:200]}") from e
if not has_face(raw):
return None
# 只保留 6 个英文字段(doubao 缺某字段则跳过)
return {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
def has_face(features: dict) -> bool:
@@ -135,5 +146,7 @@ if __name__ == "__main__":
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
with open(path, "rb") as f:
feats = analyze_features(image_bytes=f.read())
print("has_face:", has_face(feats))
if feats is None:
print("无人脸(1001")
else:
print(json.dumps(feats, ensure_ascii=False, indent=2))
+339
View File
@@ -0,0 +1,339 @@
INFO: Started server process [26440]
INFO: Waiting for application startup.
2026-07-01 23:35:15 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://127.0.0.1:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
2026-07-01 23:35:15 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:16 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
2026-07-01 23:35:16 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
2026-07-01 23:35:16 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
2026-07-01 23:35:16 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:35872 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:35:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:35:27 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/422b7786adc4486989d7f8770df40693.png (21864 bytes)
INFO: 127.0.0.1:57220 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
2026-07-01 23:35:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:57232 - "GET /static/annotations/422b7786adc4486989d7f8770df40693.png HTTP/1.1" 200 OK
INFO: 127.0.0.1:57240 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:35:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:35:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:36:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:37:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:38:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:39:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:40:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:41:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:42:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:43:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:44:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:45:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:46:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:47:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:48:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:49:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:50:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:51:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:44470 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:52:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:52:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:53:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:6017 - "GET / HTTP/1.1" 200 OK
INFO: 111.192.98.24:6017 - "GET /favicon.ico HTTP/1.1" 404 Not Found
2026-07-01 23:53:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:54:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:39090 - "GET /docs HTTP/1.1" 200 OK
INFO: 127.0.0.1:39092 - "GET / HTTP/1.1" 200 OK
2026-07-01 23:55:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:56172 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56174 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56184 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56196 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56202 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56206 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56212 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56214 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56218 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56230 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56242 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56256 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56272 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56278 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56282 - "GET /static/integration.html HTTP/1.1" 200 OK
INFO: 127.0.0.1:56284 - "GET /static/integration.html HTTP/1.1" 200 OK
2026-07-01 23:55:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:55:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:6434 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
2026-07-01 23:56:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:08 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:56:08 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/8a70c12c19ef4868af555ef84eaeafae.png (35965 bytes)
INFO: 111.192.98.24:6435 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
2026-07-01 23:56:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:56:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:57:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: Shutting down
INFO: Waiting for application shutdown.
2026-07-01 23:58:08 [INFO] gateway: 网关关闭中...
2026-07-01 23:58:08 [INFO] gateway: 清理任务已停止
2026-07-01 23:58:08 [INFO] gateway.pool: 健康检查循环已停止
2026-07-01 23:58:08 [INFO] gateway.pool: Worker 池已关闭
2026-07-01 23:58:08 [INFO] gateway: 网关已关闭
INFO: Application shutdown complete.
INFO: Finished server process [26440]
INFO: Started server process [31898]
INFO: Waiting for application startup.
2026-07-01 23:58:10 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://117.50.213.111:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
2026-07-01 23:58:10 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:10 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
2026-07-01 23:58:10 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
2026-07-01 23:58:10 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
2026-07-01 23:58:10 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 127.0.0.1:33732 - "GET /gateway-health HTTP/1.1" 200 OK
2026-07-01 23:58:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:28 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:58:28 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/478efab4566a46c3af0065ad0ffafb67.png (21864 bytes)
INFO: 127.0.0.1:32982 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
INFO: 117.50.213.111:57344 - "GET /static/annotations/478efab4566a46c3af0065ad0ffafb67.png HTTP/1.1" 200 OK
2026-07-01 23:58:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:58:54 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
2026-07-01 23:58:54 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png (35965 bytes)
INFO: 111.192.98.24:7030 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
INFO: 111.192.98.24:7030 - "GET /static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png HTTP/1.1" 200 OK
2026-07-01 23:58:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:7031 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
2026-07-01 23:59:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg (105970 bytes)
2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg (113320 bytes)
INFO: 111.192.98.24:7062 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
INFO: 111.192.98.24:7062 - "GET /static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:7166 - "GET /static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg HTTP/1.1" 200 OK
2026-07-01 23:59:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-01 23:59:48 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg (105641 bytes)
2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg (111495 bytes)
INFO: 111.192.98.24:5164 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
INFO: 111.192.98.24:5164 - "GET /static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5188 - "GET /static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg HTTP/1.1" 200 OK
2026-07-01 23:59:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:5213 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
2026-07-02 00:00:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:16 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow-b "HTTP/1.1 200 OK"
2026-07-02 00:00:16 [INFO] gateway.forward: base64→URL: hair_growth_image_base64 → http://117.50.213.111:8080/static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg (152092 bytes)
INFO: 111.192.98.24:5212 - "POST /api/v1/hair/grow-b HTTP/1.1" 200 OK
INFO: 111.192.98.24:5212 - "GET /static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg HTTP/1.1" 200 OK
2026-07-02 00:00:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:5332 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
2026-07-02 00:00:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:39 [ERROR] gateway: 接口4 豆包调用失败
Traceback (most recent call last):
File "/home/ubuntu/hair/gateway/app.py", line 298, in face_features
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/starlette/concurrency.py", line 37, in run_in_threadpool
return await anyio.to_thread.run_sync(func)
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "/home/ubuntu/hair/face_features.py", line 98, in analyze_features
resp = get_client().chat.completions.create(
File "/home/ubuntu/hair/face_features.py", line 65, in get_client
from volcenginesdkarkruntime import Ark
ModuleNotFoundError: No module named 'volcenginesdkarkruntime'
INFO: 111.192.98.24:5331 - "POST /api/v1/face/features HTTP/1.1" 200 OK
2026-07-02 00:00:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: 111.192.98.24:5436 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
2026-07-02 00:00:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:00:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:00 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hairline/generate "HTTP/1.1 200 OK"
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/4516a9491a1c41f4af048020f6709870.jpg (105674 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg (105970 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg (105735 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/316d8352062848e4b28337ef233d820e.jpg (105641 bytes)
2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg (105705 bytes)
INFO: 111.192.98.24:5435 - "POST /api/v1/hairline/generate HTTP/1.1" 200 OK
INFO: 111.192.98.24:5435 - "GET /static/annotations/4516a9491a1c41f4af048020f6709870.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5466 - "GET /static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5467 - "GET /static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5469 - "GET /static/annotations/316d8352062848e4b28337ef233d820e.jpg HTTP/1.1" 200 OK
INFO: 111.192.98.24:5471 - "GET /static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg HTTP/1.1" 200 OK
2026-07-02 00:01:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:01:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
2026-07-02 00:02:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
INFO: Shutting down
INFO: Waiting for application shutdown.
2026-07-02 00:02:59 [INFO] gateway: 网关关闭中...
2026-07-02 00:02:59 [INFO] gateway: 清理任务已停止
2026-07-02 00:02:59 [INFO] gateway.pool: 健康检查循环已停止
2026-07-02 00:02:59 [INFO] gateway.pool: Worker 池已关闭
2026-07-02 00:02:59 [INFO] gateway: 网关已关闭
INFO: Application shutdown complete.
INFO: Finished server process [31898]
+366 -10
View File
@@ -5,14 +5,25 @@
"""
import asyncio
import base64
import json
import logging
import time
from contextlib import asynccontextmanager
from io import BytesIO
from pathlib import Path
from fastapi import FastAPI, Request
from typing import Optional
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from gateway.config import load_config
from gateway.logging_middleware import (
get_stats,
init_logging as _init_req_logging,
request_logging_middleware,
)
# ---------------------------------------------------------------------------
# 日志
@@ -37,6 +48,9 @@ async def lifespan(app: FastAPI):
cfg = load_config()
logger.info("网关启动中... workers=%s", cfg["workers"])
# 初始化请求日志
_init_req_logging(cfg)
# 初始化健康池(阶段二实现)
try:
from gateway.pool import init_pool, shutdown_pool as _pool_shutdown
@@ -99,6 +113,9 @@ static_root.mkdir(parents=True, exist_ok=True)
(static_root / "annotations").mkdir(parents=True, exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_root)), name="static")
# 请求日志中间件(在所有路由之前,静态文件之后)
app.middleware("http")(request_logging_middleware)
# ---------------------------------------------------------------------------
# 健康检查(网关自身)
@@ -179,14 +196,280 @@ async def health():
@app.get("/", include_in_schema=False)
async def index():
cfg = load_config()
return {
"service": "旷视五接口 — 网关",
"version": "0.1.0",
"docs": f"{cfg['public_base_url']}/docs",
"docs": "/docs",
"stats": "/admin/stats",
"integration_guide": "/static/integration.html",
"test_pages": {
"if1_measure": "/static/test_interface1.html",
"if2_hair_grow": "/static/test_interface2.html",
"if3_hair_grow_b": "/static/test_interface3.html",
"if4_features": "/static/test_interface4.html",
"if5_hairline": "/static/test_interface5.html",
"if6_measure_v2": "/static/test_interface6.html",
"if7_hair_grow_v2": "/static/test_interface7.html",
"if9_head_mask": "/static/test_interface9.html",
"if10_head_band": "/static/test_interface10.html",
},
}
# ---------------------------------------------------------------------------
# 请求统计仪表盘 HTML
# ---------------------------------------------------------------------------
_STATS_PAGE_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网关请求统计</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1300px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 4px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; }
.nav { margin-bottom: 20px; }
.nav a { color: #2563eb; text-decoration: none; font-size: 13px; }
.nav a:hover { text-decoration: underline; }
/* 汇总卡片 */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card { background: #fff; border-radius: 12px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.stat-card .value { font-size: 28px; font-weight: 700; color: #111827; }
.stat-card .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
.stat-card.ok .value { color: #059669; }
.stat-card.warn .value { color: #d97706; }
/* 表格 */
.section { margin-bottom: 24px; }
.section h2 { font-size: 16px; margin-bottom: 10px; color: #374151; }
.table-wrap { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 9px 14px; text-align: left; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
th { background: #f8fafc; font-weight: 700; color: #475569; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; white-space: nowrap; }
tr:hover td { background: #fafbfc; }
td.mono { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.badge-ok { background: #d1fae5; color: #065f46; }
.badge-err { background: #fee2e2; color: #991b1b; }
.badge-other { background: #f3f4f6; color: #6b7280; }
.duration-fast { color: #059669; }
.duration-mid { color: #d97706; }
.duration-slow { color: #dc2626; }
.footer { text-align: right; font-size: 12px; color: #9ca3af; margin-top: 20px; }
.auto-refresh { display: flex; align-items: center; gap: 8px; }
.auto-refresh input { accent-color: #2563eb; }
.empty { text-align: center; padding: 40px; color: #9ca3af; font-size: 14px; }
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
th, td { padding: 6px 8px; font-size: 12px; }
}
</style>
</head>
<body>
<div class="container">
<h1>📊 网关请求统计</h1>
<p class="subtitle">实时请求监控 &nbsp;|&nbsp; 3 秒自动刷新</p>
<div class="nav">
<a href="/"> 返回首页</a> &nbsp;|&nbsp;
<a href="/docs">API 文档</a> &nbsp;|&nbsp;
<a href="/static/integration.html">接入指南</a>
</div>
<!-- 汇总卡片 -->
<div class="stats-grid">
<div class="stat-card"><div class="value" id="totalCount"></div><div class="label">请求总数</div></div>
<div class="stat-card ok"><div class="value" id="successRate"></div><div class="label">成功率code=0</div></div>
<div class="stat-card"><div class="value" id="avgTime"></div><div class="label">平均响应时间</div></div>
<div class="stat-card"><div class="value" id="minTime"></div><div class="label">最短响应</div></div>
<div class="stat-card warn"><div class="value" id="maxTime"></div><div class="label">最长响应</div></div>
</div>
<!-- 按接口 -->
<div class="section">
<h2>📋 按接口统计</h2>
<div class="table-wrap">
<table>
<thead><tr><th>路径</th><th>请求数</th><th>平均耗时</th><th>最大耗时</th><th>成功率</th></tr></thead>
<tbody id="endpointTable"><tr><td class="empty" colspan="5">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<!-- Worker -->
<div class="section">
<h2>🖥 GPU Worker 统计</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Worker</th><th>请求数</th><th>平均耗时</th><th>成功率</th></tr></thead>
<tbody id="workerTable"><tr><td class="empty" colspan="4">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<!-- 最近请求 -->
<div class="section">
<h2>🕐 最近请求最新 100 </h2>
<div class="table-wrap" style="max-height:600px;overflow:auto;">
<table>
<thead><tr><th>时间</th><th>方法</th><th>路径</th><th>Worker</th><th>HTTP</th><th>业务码</th><th>耗时</th><th>客户端 IP</th></tr></thead>
<tbody id="recentTable"><tr><td class="empty" colspan="8">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<div class="footer">
<label class="auto-refresh">
<input type="checkbox" id="autoRefresh" checked onchange="toggleAutoRefresh()"> 自动刷新3s
</label>
<span style="margin-left:16px" id="lastUpdated">加载中</span>
</div>
</div>
<script>
let _timer = null;
function formatDuration(ms) {
if (ms < 1000) return ms.toFixed(1) + 'ms';
if (ms < 60000) return (ms / 1000).toFixed(2) + 's';
return (ms / 60000).toFixed(1) + 'min';
}
function durationClass(ms) {
if (ms < 500) return 'duration-fast';
if (ms < 2000) return 'duration-mid';
return 'duration-slow';
}
function badgeClass(code) {
if (code === 0) return 'badge-ok';
if (code !== null && code !== undefined && code !== 0) return 'badge-err';
return 'badge-other';
}
function badgeText(code) {
if (code === 0) return 'OK';
if (code !== null && code !== undefined) return 'ERR ' + code;
return '';
}
async function refresh() {
try {
const r = await fetch('/admin/stats/json');
const data = await r.json();
const s = data.summary;
document.getElementById('totalCount').textContent = s.total.toLocaleString();
document.getElementById('successRate').textContent = s.success_rate + '%';
document.getElementById('avgTime').textContent = formatDuration(s.avg_duration_ms);
document.getElementById('minTime').textContent = formatDuration(s.min_duration_ms);
document.getElementById('maxTime').textContent = formatDuration(s.max_duration_ms);
// 按接口
let ehtml = '';
if (data.endpoints.length === 0) {
ehtml = '<tr><td class="empty" colspan="5">暂无数据</td></tr>';
} else {
data.endpoints.forEach(function(e) {
ehtml += '<tr>' +
'<td class="mono">' + e.path + '</td>' +
'<td>' + e.count + '</td>' +
'<td class="' + durationClass(e.avg_duration_ms) + '">' + formatDuration(e.avg_duration_ms) + '</td>' +
'<td>' + formatDuration(e.max_duration_ms) + '</td>' +
'<td><span class="badge ' + badgeClass(0) + '" style="opacity:' + (e.success_rate / 100) + '">' + e.success_rate + '%</span></td>' +
'</tr>';
});
}
document.getElementById('endpointTable').innerHTML = ehtml;
// Worker
let whtml = '';
if (!data.workers || data.workers.length === 0) {
whtml = '<tr><td class="empty" colspan="4">暂无数据</td></tr>';
} else {
data.workers.forEach(function(w) {
whtml += '<tr>' +
'<td class="mono">' + w.worker + '</td>' +
'<td>' + w.count + '</td>' +
'<td class="' + durationClass(w.avg_duration_ms) + '">' + formatDuration(w.avg_duration_ms) + '</td>' +
'<td><span class="badge ' + badgeClass(0) + '" style="opacity:' + (w.success_rate / 100) + '">' + w.success_rate + '%</span></td>' +
'</tr>';
});
}
document.getElementById('workerTable').innerHTML = whtml;
// 最近请求
let rhtml = '';
if (data.recent.length === 0) {
rhtml = '<tr><td class="empty" colspan="8">暂无数据</td></tr>';
} else {
data.recent.forEach(function(entry) {
var ts = entry.timestamp.replace('T', ' ').substring(0, 23);
var workerDisplay = entry.worker || '';
// worker 显示只取主机部分
if (workerDisplay.length > 30) {
workerDisplay = workerDisplay.replace(/^https?:\/\//, '').substring(0, 28) + '';
}
rhtml += '<tr>' +
'<td class="mono">' + ts + '</td>' +
'<td>' + entry.method + '</td>' +
'<td class="mono">' + entry.path + '</td>' +
'<td class="mono" style="font-size:11px">' + workerDisplay + '</td>' +
'<td>' + entry.status_code + '</td>' +
'<td><span class="badge ' + badgeClass(entry.response_code) + '">' + badgeText(entry.response_code) + '</span></td>' +
'<td class="' + durationClass(entry.duration_ms) + '">' + formatDuration(entry.duration_ms) + '</td>' +
'<td class="mono">' + entry.client_ip + '</td>' +
'</tr>';
});
}
document.getElementById('recentTable').innerHTML = rhtml;
document.getElementById('lastUpdated').textContent = '最后更新: ' + new Date().toLocaleTimeString();
} catch(err) {
document.getElementById('lastUpdated').textContent = '加载失败: ' + err.message;
}
}
function toggleAutoRefresh() {
var checked = document.getElementById('autoRefresh').checked;
if (checked) {
_timer = setInterval(refresh, 3000);
} else {
clearInterval(_timer);
_timer = null;
}
}
refresh();
_timer = setInterval(refresh, 3000);
</script>
</body>
</html>"""
# ---------------------------------------------------------------------------
# 请求统计页面
# ---------------------------------------------------------------------------
@app.get("/admin/stats", include_in_schema=False)
async def admin_stats():
"""请求统计仪表盘(HTML 页面)。"""
return HTMLResponse(content=_STATS_PAGE_HTML)
@app.get("/admin/stats/json", include_in_schema=False)
async def admin_stats_json():
"""请求统计数据(JSON,供页面轮询)。"""
return get_stats()
# ---------------------------------------------------------------------------
# 代理路由
# ---------------------------------------------------------------------------
@@ -203,7 +486,7 @@ def _proxy(request: Request, path: str):
# 声明各接口的 form 参数用于 OpenAPI schema(实际转发直接读 Request
_MEASURE_FORMS = {
"image_file": {"type": "file", "description": "上传图片文件(JPG/PNG,≤ 1 MB"},
"image_file": {"type": "file", "description": "上传图片文件(JPG/PNG"},
"image_url": {"type": "string", "description": "图片 URL"},
"image_base64": {"type": "string", "description": "图片 base64(需带前缀)"},
}
@@ -217,9 +500,6 @@ _GROW_B_FORMS = {
"marked_image_file": {"type": "file", "description": "划线图片文件"},
"marked_image_url": {"type": "string", "description": "划线图片 URL"},
"marked_image_base64": {"type": "string", "description": "划线图片 base64"},
"original_image_file": {"type": "file", "description": "原始用户照片文件"},
"original_image_url": {"type": "string", "description": "原始用户照片 URL"},
"original_image_base64": {"type": "string", "description": "原始用户照片 base64"},
}
@@ -229,6 +509,12 @@ async def face_measure(request: Request):
return await _proxy(request, "/api/v1/face/measure")
@app.post("/api/v1/face/measure-v2", tags=["人脸分析"])
async def face_measure_v2(request: Request):
"""接口6:四庭七眼测量标注 v2(去顶庭 + 去头部端线)"""
return await _proxy(request, "/api/v1/face/measure-v2")
@app.post("/api/v1/hair/grow", tags=["生发"])
async def hair_grow(request: Request):
"""接口2C端生发"""
@@ -242,12 +528,82 @@ async def hair_grow_b(request: Request):
@app.post("/api/v1/face/features", tags=["人脸分析"])
async def face_features(request: Request):
"""接口4:用户特征分析"""
return await _proxy(request, "/api/v1/face/features")
async def face_features(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
):
"""接口4:用户特征分析 — 本机直接调豆包视觉模型,不经过 worker。"""
import uuid as _uuid
# 三选一校验
provided = [x for x in (image_file, image_url, image_base64) if x]
if len(provided) != 1:
return JSONResponse(status_code=200, content={
"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
img_bytes = None
if image_file:
img_bytes = await image_file.read()
elif image_base64:
b64 = image_base64
if "," in b64:
b64 = b64.split(",", 1)[1]
try:
img_bytes = base64.b64decode(b64)
except Exception:
return JSONResponse(status_code=200, content={
"code": 1008, "message": "图片格式不支持(base64 解码失败)",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
from fastapi.concurrency import run_in_threadpool
from face_features import analyze_features
try:
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
except Exception as ex:
logger.exception("接口4 豆包调用失败")
return JSONResponse(status_code=200, content={
"code": 1007, "message": f"分析服务异常:{ex}",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
if feats is None:
return JSONResponse(status_code=200, content={
"code": 1001, "message": "无法识别人像",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
return JSONResponse(status_code=200, content={
"code": 0,
"message": "success",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}",
"data": {"features": json.dumps(feats, ensure_ascii=False)},
})
@app.post("/api/v1/hairline/generate", tags=["人脸分析"])
async def hairline_generate(request: Request):
"""接口5:发际线PNG生成"""
return await _proxy(request, "/api/v1/hairline/generate")
@app.post("/api/v1/hair/grow-v2", tags=["生发"])
async def hair_grow_v2(request: Request):
"""接口7C端生发 v2add_hair2 工作流)"""
return await _proxy(request, "/api/v1/hair/grow-v2")
@app.post("/api/v1/head/mask", tags=["人脸分析"])
async def head_mask(request: Request):
"""接口9:头发遮罩生成 + 分步可视化"""
return await _proxy(request, "/api/v1/head/mask")
@app.post("/api/v1/head/band", tags=["人脸分析"])
async def head_band(request: Request):
"""接口10:头部外缘膨胀带遮罩 + 分步可视化"""
return await _proxy(request, "/api/v1/head/band")
+1
View File
@@ -13,6 +13,7 @@
"unhealthy_threshold": 2,
"healthy_threshold": 1
},
"ark_api_key": "",
"dispatch": {
"per_worker_concurrency": 1,
"queue_wait_seconds": 30,
+7
View File
@@ -33,6 +33,13 @@ DEFAULTS = {
"interval_minutes": 60,
"max_age_hours": 24,
},
"request_log": {
"enabled": True,
"log_file": "gateway/request_log.jsonl",
"buffer_size": 2000,
"max_file_lines": 10000,
"max_file_age_days": 7,
},
}
_config_cache = None
+5 -2
View File
@@ -102,8 +102,9 @@ def rewrite_base64_to_url(
if key.endswith("_base64") and isinstance(value, str):
img_bytes = _decode_base64_value(value)
if img_bytes is not None:
# 生成文件名并落盘
filename = f"{uuid.uuid4().hex}.png"
# 按内容嗅探扩展名:PNG(接口1标注图,含透明) / JPEG(接口2/3/5 照片)
ext = "png" if img_bytes[:8] == b"\x89PNG\r\n\x1a\n" else "jpg"
filename = f"{uuid.uuid4().hex}.{ext}"
filepath = Path(static_dir) / filename
filepath.write_bytes(img_bytes)
@@ -161,6 +162,8 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
worker = None
try:
worker = await acquire_worker(cfg)
# 记录当前使用的 worker,供日志中间件读取
request.state.worker_url = worker.url
except NoWorkerAvailable:
logger.warning("无可用 worker,返回 1007")
return JSONResponse(
+406
View File
@@ -0,0 +1,406 @@
"""请求日志中间件:为每个请求记录时间、路径、耗时等,并提供统计查询。
- 内存环形缓冲区最近 N
- JSON Lines 文件持久化自动轮转
- ASGI 中间件透明捕获请求/响应
"""
import datetime
import json
import logging
import time
from collections import deque
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger("gateway.logging_middleware")
# ---------------------------------------------------------------------------
# 数据结构
# ---------------------------------------------------------------------------
@dataclass
class RequestLogEntry:
"""单条请求日志。"""
timestamp: str # ISO-8601
method: str
path: str
status_code: int
duration_ms: float
client_ip: str
worker: str = "" # 处理请求的 worker URL(空串表示网关本地处理)
response_code: Optional[int] = None # 响应 JSON 中的 code 字段
request_id: Optional[str] = None # 响应 JSON 中的 request_id
# ---------------------------------------------------------------------------
# 环形缓冲区
# ---------------------------------------------------------------------------
class RingBuffer:
"""固定大小的环形缓冲区,线程安全。"""
def __init__(self, maxlen: int = 2000):
self._deque: deque = deque(maxlen=maxlen)
def append(self, entry: RequestLogEntry) -> None:
self._deque.append(entry)
def snapshot(self) -> List[RequestLogEntry]:
"""返回当前缓冲区副本(最新在前)。"""
return list(reversed(self._deque))
def __len__(self) -> int:
return len(self._deque)
# ---------------------------------------------------------------------------
# JSON Lines 文件写入(含轮转)
# ---------------------------------------------------------------------------
class LogFileWriter:
"""追加写入 JSON Lines 日志文件,自动按行数 / 天数轮转。
轮转策略保留 1 个备份 (.jsonl.1)不保留更多历史
"""
def __init__(self, filepath: str, max_lines: int = 10000, max_age_days: int = 7):
self.filepath = Path(filepath)
self.max_lines = max_lines
self.max_age_seconds = max_age_days * 86400
def write(self, entry: RequestLogEntry) -> None:
try:
self._maybe_rotate()
self.filepath.parent.mkdir(parents=True, exist_ok=True)
line = json.dumps(asdict(entry), ensure_ascii=False) + "\n"
with open(self.filepath, "a", encoding="utf-8") as f:
f.write(line)
except Exception:
logger.warning("写入请求日志失败", exc_info=True)
def _maybe_rotate(self) -> None:
if not self.filepath.exists():
return
# 按天数轮转
mtime = self.filepath.stat().st_mtime
if time.time() - mtime > self.max_age_seconds:
self._rotate()
return
# 按行数轮转
try:
with open(self.filepath, "r", encoding="utf-8") as f:
count = sum(1 for _ in f)
if count >= self.max_lines:
self._rotate()
except Exception:
pass # 读不到就算了,下次再说
def _rotate(self) -> None:
backup = self.filepath.with_suffix(".jsonl.1")
if backup.exists():
backup.unlink()
try:
self.filepath.rename(backup)
logger.info("请求日志已轮转: %s%s", self.filepath.name, backup.name)
except Exception:
logger.warning("日志轮转失败", exc_info=True)
# ---------------------------------------------------------------------------
# 模块级全局状态(由 init_logging 初始化)
# ---------------------------------------------------------------------------
_buffer: Optional[RingBuffer] = None
_writer: Optional[LogFileWriter] = None
def init_logging(cfg: dict) -> None:
"""初始化日志缓冲区与文件写入器。"""
global _buffer, _writer
log_cfg = cfg.get("request_log", {})
if not log_cfg.get("enabled", True):
logger.info("请求日志已禁用")
return
log_file = log_cfg.get("log_file", "gateway/request_log.jsonl")
log_path = Path(log_file)
if not log_path.is_absolute():
log_path = Path(__file__).resolve().parent.parent / log_file
_buffer = RingBuffer(maxlen=log_cfg.get("buffer_size", 2000))
_writer = LogFileWriter(
filepath=str(log_path),
max_lines=log_cfg.get("max_file_lines", 10000),
max_age_days=log_cfg.get("max_file_age_days", 7),
)
# 从历史日志文件加载最近 N 条到缓冲区
buffer_size = log_cfg.get("buffer_size", 2000)
loaded = _load_from_logfile(str(log_path), buffer_size)
if loaded > 0:
logger.info("从日志文件恢复 %d 条历史记录", loaded)
logger.info("请求日志已启用 | 缓冲=%d | 文件=%s",
buffer_size, log_path)
def _load_from_logfile(filepath: str, max_entries: int) -> int:
"""从 JSON Lines 日志文件读取最近 max_entries 条到缓冲区。"""
try:
p = Path(filepath)
if not p.exists():
return 0
# 从文件末尾反向读取(高效处理大文件)
with open(p, "rb") as f:
# 估算:每条约 200 bytes,读最后 max_entries * 250 bytes 足够
chunk_size = max_entries * 250
f.seek(0, 2) # 文件末尾
file_size = f.tell()
read_size = min(chunk_size, file_size)
f.seek(max(0, file_size - read_size))
raw = f.read().decode("utf-8", errors="replace")
# 跳过可能不完整的第一行
lines = raw.split("\n")
if file_size > read_size:
# 第一行可能不完整,跳过
lines = lines[1:]
# 去掉末尾空行
lines = [l for l in lines if l.strip()]
# 只取最后 max_entries 条
lines = lines[-max_entries:]
count = 0
for line in lines:
try:
data = json.loads(line)
entry = RequestLogEntry(
timestamp=data.get("timestamp", ""),
method=data.get("method", ""),
path=data.get("path", ""),
status_code=data.get("status_code", 0),
duration_ms=data.get("duration_ms", 0.0),
client_ip=data.get("client_ip", ""),
worker=data.get("worker", ""),
response_code=data.get("response_code"),
request_id=data.get("request_id"),
)
_buffer.append(entry)
count += 1
except (json.JSONDecodeError, KeyError):
continue
return count
except Exception:
logger.warning("从日志文件恢复历史记录失败", exc_info=True)
return 0
def is_initialized() -> bool:
return _buffer is not None
# ---------------------------------------------------------------------------
# ASGI 中间件
# ---------------------------------------------------------------------------
def _should_log(path: str) -> bool:
"""只记录 API 请求(/api/ 路径),跳过静态文件、健康检查等。"""
return path.startswith("/api/")
async def request_logging_middleware(request, call_next):
"""记录每个请求的耗时、状态码等信息。"""
# 未初始化或不需要记录的路径 → 直接放行
if _buffer is None or not _should_log(request.url.path):
return await call_next(request)
start = time.perf_counter()
# 获取客户端 IP(优先级:X-Forwarded-For > X-Real-IP > client.host
client_ip = request.client.host if request.client else "unknown"
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
client_ip = forwarded.split(",")[0].strip()
else:
real_ip = request.headers.get("x-real-ip")
if real_ip:
client_ip = real_ip.strip()
response = await call_next(request)
duration_ms = round((time.perf_counter() - start) * 1000, 2)
# 读取 worker URL(由 forward.py 在转发时写入 request.state
worker_url = getattr(request.state, "worker_url", None) or ""
# 提取响应 body 并解析业务字段(仅 JSON 响应)
response_code = None
request_id = None
content_type = response.headers.get("content-type", "")
if "application/json" in content_type or "application/json" in (response.media_type or ""):
# 读取 body(兼容 body_iterator 和 body 两种属性)
body = getattr(response, "body", None)
if body is None:
body = b""
async for chunk in response.body_iterator:
body += chunk
try:
data = json.loads(body)
response_code = data.get("code")
request_id = data.get("request_id")
except (json.JSONDecodeError, UnicodeDecodeError):
pass
# 如果读取了 body_iterator,需要重建响应
if not hasattr(response, "body") or response.body is None:
from starlette.responses import Response as StarletteResponse
response = StarletteResponse(
content=body,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type,
)
# 记录
now = datetime.datetime.utcnow()
entry = RequestLogEntry(
timestamp=now.strftime("%Y-%m-%dT%H:%M:%S.") +
f"{now.microsecond // 1000:03d}Z",
method=request.method,
path=request.url.path,
status_code=response.status_code,
duration_ms=duration_ms,
client_ip=client_ip,
worker=worker_url,
response_code=response_code,
request_id=request_id,
)
_buffer.append(entry)
if _writer is not None:
_writer.write(entry)
return response
# ---------------------------------------------------------------------------
# 统计查询
# ---------------------------------------------------------------------------
def get_stats() -> Dict[str, Any]:
"""基于缓冲区数据计算统计摘要,返回给统计页面使用。"""
if _buffer is None:
return {
"summary": {"total": 0, "success_rate": 0, "avg_duration_ms": 0,
"min_duration_ms": 0, "max_duration_ms": 0},
"endpoints": [],
"workers": [],
"recent": [],
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
}
snapshot = _buffer.snapshot()
total = len(snapshot)
if total == 0:
return {
"summary": {"total": 0, "success_rate": 0, "avg_duration_ms": 0,
"min_duration_ms": 0, "max_duration_ms": 0},
"endpoints": [],
"workers": [],
"recent": [],
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
}
# 汇总指标
durations = [e.duration_ms for e in snapshot]
ok_count = sum(1 for e in snapshot if e.response_code == 0)
# 按路径聚合
by_path: Dict[str, dict] = {}
for e in snapshot:
path = e.path
if path not in by_path:
by_path[path] = {"count": 0, "total_duration": 0.0, "ok": 0}
by_path[path]["count"] += 1
by_path[path]["total_duration"] += e.duration_ms
if e.response_code == 0:
by_path[path]["ok"] += 1
endpoints = sorted(
({
"path": path,
"count": v["count"],
"avg_duration_ms": round(v["total_duration"] / v["count"], 1),
"max_duration_ms": round(
max(e.duration_ms for e in snapshot if e.path == path), 1),
"success_rate": round(v["ok"] / v["count"] * 100, 1),
} for path, v in by_path.items()),
key=lambda x: -x["count"],
)
# 最近 100 条(最新在前)
recent_100 = snapshot[:100]
recent = [
{
"timestamp": e.timestamp,
"method": e.method,
"path": e.path,
"status_code": e.status_code,
"duration_ms": e.duration_ms,
"client_ip": e.client_ip,
"worker": e.worker,
"response_code": e.response_code,
"request_id": e.request_id,
}
for e in recent_100
]
# 按 worker 聚合
by_worker: Dict[str, dict] = {}
for e in snapshot:
w = e.worker or "(网关本地)"
if w not in by_worker:
by_worker[w] = {"count": 0, "total_duration": 0.0, "ok": 0}
by_worker[w]["count"] += 1
by_worker[w]["total_duration"] += e.duration_ms
if e.response_code == 0:
by_worker[w]["ok"] += 1
workers = sorted(
({
"worker": w,
"count": v["count"],
"avg_duration_ms": round(v["total_duration"] / v["count"], 1),
"success_rate": round(v["ok"] / v["count"] * 100, 1) if v["count"] else 0,
} for w, v in by_worker.items()),
key=lambda x: -x["count"],
)
return {
"summary": {
"total": total,
"success_rate": round(ok_count / total * 100, 1),
"avg_duration_ms": round(sum(durations) / len(durations), 1),
"min_duration_ms": round(min(durations), 1),
"max_duration_ms": round(max(durations), 1),
},
"endpoints": endpoints,
"workers": workers,
"recent": recent,
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
}
+12 -3
View File
@@ -223,15 +223,24 @@ def create_app(port: int, password: str) -> FastAPI:
if x_internal_token != password:
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
delay = float(request.query_params.get("delay", "1"))
_png = f"data:image/png;base64,{TINY_PNG_BASE64}"
data = {
"hairline_images": [
{
"image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
"hairline_type": "ellipse",
"image_middle_base64": _png,
"image_high_base64": _png,
"image_low_base64": _png,
"grown_image_base64": _png,
"order": 1,
},
{
"image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
"order": 2,
"hairline_type": "heart",
"image_middle_base64": _png,
"image_high_base64": _png,
"image_low_base64": _png,
"grown_image_base64": None,
"order": 3,
},
],
"best_hairline_center_point": {"x": 540, "y": 430},
+8 -12
View File
@@ -1,19 +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
# 分辨率门槛(可选,默认 600/800)
# Environment=MIN_SHORT_SIDE=600
# Environment=MIN_LONG_SIDE=800
ExecStart=/home/xsl/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
Restart=always
RestartSec=3
User=ubuntu
WorkingDirectory=/home/ubuntu/hair
ExecStart=/home/ubuntu/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+450
View File
@@ -0,0 +1,450 @@
{
"1": {
"inputs": {
"scheduler": "simple",
"steps": 6,
"denoise": 1,
"model": [
"2",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "基本调度器"
}
},
"2": {
"inputs": {
"max_shift": 1.15,
"base_shift": 0.5,
"width": [
"14",
0
],
"height": [
"14",
1
],
"model": [
"16",
0
]
},
"class_type": "ModelSamplingFlux",
"_meta": {
"title": "采样算法(Flux"
}
},
"3": {
"inputs": {
"vae_name": "flux2-vae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "加载VAE"
}
},
"5": {
"inputs": {
"conditioning": [
"19",
0
],
"latent": [
"13",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "参考Latent"
}
},
"6": {
"inputs": {
"noise_seed": 808990860769642
},
"class_type": "RandomNoise",
"_meta": {
"title": "随机噪波"
}
},
"7": {
"inputs": {
"width": [
"14",
0
],
"height": [
"14",
1
],
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": {
"title": "空Latent图像(SD3"
}
},
"8": {
"inputs": {
"sampler_name": "euler"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "K采样器选择"
}
},
"9": {
"inputs": {
"noise": [
"6",
0
],
"guider": [
"20",
0
],
"sampler": [
"8",
0
],
"sigmas": [
"1",
0
],
"latent_image": [
"7",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "自定义采样器(高级)"
}
},
"10": {
"inputs": {
"samples": [
"9",
0
],
"vae": [
"3",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE解码"
}
},
"13": {
"inputs": {
"pixels": [
"44",
0
],
"vae": [
"3",
0
]
},
"class_type": "VAEEncode",
"_meta": {
"title": "VAE编码"
}
},
"14": {
"inputs": {
"image": [
"44",
0
]
},
"class_type": "GetImageSize+",
"_meta": {
"title": "🔧 Get Image Size"
}
},
"16": {
"inputs": {
"unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn"
},
"class_type": "UNETLoader",
"_meta": {
"title": "UNet加载器"
}
},
"17": {
"inputs": {
"filename_prefix": "ComfyUI",
"images": [
"62",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "保存图像"
}
},
"19": {
"inputs": {
"guidance": 1,
"conditioning": [
"22",
0
]
},
"class_type": "FluxGuidance",
"_meta": {
"title": "Flux引导"
}
},
"20": {
"inputs": {
"model": [
"2",
0
],
"conditioning": [
"5",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "基本引导器"
}
},
"22": {
"inputs": {
"text": [
"60",
0
],
"clip": [
"61",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP文本编码"
}
},
"26": {
"inputs": {
"image": "clipspace/clipspace-painted-masked-1784045785080.png [input]"
},
"class_type": "LoadImage",
"_meta": {
"title": "加载图像"
}
},
"31": {
"inputs": {
"image": [
"26",
0
]
},
"class_type": "easy imageSize",
"_meta": {
"title": "图像尺寸"
}
},
"32": {
"inputs": {
"aspect_ratio": "custom",
"proportional_width": [
"31",
0
],
"proportional_height": [
"31",
1
],
"fit": "letterbox",
"method": "lanczos",
"round_to_multiple": "8",
"scale_to_side": "None",
"scale_to_length": 1024,
"background_color": "#000000",
"image": [
"26",
0
],
"mask": [
"37",
0
]
},
"class_type": "LayerUtility: ImageScaleByAspectRatio V2",
"_meta": {
"title": "LayerUtility: ImageScaleByAspectRatio V2"
}
},
"33": {
"inputs": {
"masks": [
"26",
1
]
},
"class_type": "Mask Fill Holes",
"_meta": {
"title": "遮罩填充漏洞"
}
},
"36": {
"inputs": {
"masks": [
"33",
0
]
},
"class_type": "Convert Masks to Images",
"_meta": {
"title": "遮罩到图像"
}
},
"37": {
"inputs": {
"method": "intensity",
"image": [
"39",
0
]
},
"class_type": "Image To Mask",
"_meta": {
"title": "图像到遮罩"
}
},
"39": {
"inputs": {
"upscale_method": "nearest-exact",
"width": [
"31",
0
],
"height": [
"31",
1
],
"crop": "disabled",
"image": [
"36",
0
]
},
"class_type": "ImageScale",
"_meta": {
"title": "缩放图像"
}
},
"44": {
"inputs": {
"mask_opacity": 1,
"mask_color": "FFFF00",
"pass_through": true,
"image": [
"32",
0
],
"mask": [
"32",
1
]
},
"class_type": "ImageAndMaskPreview",
"_meta": {
"title": "图像与遮罩预览"
}
},
"45": {
"inputs": {
"images": [
"44",
0
]
},
"class_type": "PreviewImage",
"_meta": {
"title": "预览图像"
}
},
"53": {
"inputs": {
"rgthree_comparer": {
"images": [
{
"name": "A",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_kzrpg_00019_.png&type=temp&subfolder=&rand=0.8964945384546902"
},
{
"name": "B",
"selected": true,
"url": "/api/view?filename=rgthree.compare._temp_kzrpg_00020_.png&type=temp&subfolder=&rand=0.6762414189274947"
}
]
},
"image_a": [
"62",
0
],
"image_b": [
"26",
0
]
},
"class_type": "Image Comparer (rgthree)",
"_meta": {
"title": "图像对比"
}
},
"60": {
"inputs": {
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
},
"class_type": "JjkText",
"_meta": {
"title": "Text"
}
},
"61": {
"inputs": {
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
"type": "flux2",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "加载CLIP"
}
},
"62": {
"inputs": {
"method": "mkl",
"strength": 1,
"multithread": true,
"image_ref": [
"26",
0
],
"image_target": [
"10",
0
]
},
"class_type": "ColorMatch",
"_meta": {
"title": "Color Match"
}
}
}
+109 -23
View File
@@ -1,12 +1,17 @@
"""ComfyUI 客户端:用 add_hair.json 工作流跑生发图(Flux-2 inpaint)。
"""ComfyUI 客户端:用 add_hair.json / add_hair2.json 工作流跑生发图(Flux-2 inpaint)。
worker 不跑 Flux只把划线图 + 遮罩 RGBA 上传到本机 ComfyUI(默认 8182)
worker 不跑 Flux只把划线图 + 遮罩 RGBA 上传到远端 ComfyUI
默认 http://10.60.74.221:8188可用环境变量 COMFYUI_URL 覆盖
替换工作流节点 26 的输入图随机 seed提交 /prompt轮询 /history取回 /view 输出
ComfyUI 若开启了 HTTP Basic Authuser `admin` + 密码所有请求都带凭据
支持多工作流run() 可通过 workflow_path 指定不同工作流 JSON自动检测 SaveImage 输出节点
"""
from __future__ import annotations
import copy
import json
import logging
import os
import random
import time
@@ -14,32 +19,85 @@ import uuid
import httpx
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://127.0.0.1:8182").rstrip("/")
WORKFLOW_PATH = os.getenv(
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188").rstrip("/")
_WORKFLOW_DEFAULT = os.getenv(
"ADD_HAIR_WORKFLOW",
os.path.join(os.path.dirname(os.path.dirname(__file__)), "add_hair.json"),
)
COMFY_TIMEOUT = float(os.getenv("COMFYUI_TIMEOUT", "600")) # 单张出图最长等待(秒)
_REPO = os.path.dirname(os.path.dirname(__file__))
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
_SEED_NODE = "6" # RandomNoise
_OUTPUT_NODE = "17" # SaveImage
_PROMPT_NODE = "60" # JjkText:提示词
_workflow = None
_wf_cache: dict[str, dict] = {} # path → workflow JSON
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
def _load_workflow() -> dict:
global _workflow
if _workflow is None:
with open(WORKFLOW_PATH, encoding="utf-8") as f:
_workflow = json.load(f)
return _workflow
def _comfy_auth():
"""ComfyUI Basic Auth 凭据 (user, password)。
user环境变量 COMFYUI_USER默认 admin
password环境变量 COMFYUI_PASSWORD worker_config.json.comfyui_password password.txt
无密码则返回 None不带鉴权兼容未开启 auth 的实例
"""
user = os.getenv("COMFYUI_USER", "admin")
pw = os.getenv("COMFYUI_PASSWORD")
if not pw:
cfg = os.path.join(_REPO, "worker_config.json")
if os.path.isfile(cfg):
try:
with open(cfg, encoding="utf-8") as f:
pw = json.load(f).get("comfyui_password")
except Exception: # noqa: BLE001
pw = None
if not pw:
pwfile = os.path.join(_REPO, "password.txt")
if os.path.isfile(pwfile):
with open(pwfile, encoding="utf-8") as f:
pw = f.read().strip()
return (user, pw) if pw else None
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT) -> bytes:
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常"""
def _load_workflow(workflow_path: str | None = None) -> dict:
"""加载工作流 JSON(按路径缓存)。自动检测 SaveImage 节点 ID"""
path = workflow_path or _WORKFLOW_DEFAULT
if path not in _wf_cache:
with open(path, encoding="utf-8") as f:
wf = json.load(f)
_wf_cache[path] = wf
# 自动检测 SaveImage 输出节点
for node_id, node in wf.items():
if node.get("class_type") == "SaveImage":
_wf_output_node[path] = node_id
break
else:
raise ValueError(f"工作流 {path} 中未找到 SaveImage 节点")
return _wf_cache[path]
def _get_output_node(workflow_path: str | None = None) -> str:
"""返回指定工作流的 SaveImage 节点 ID。"""
path = workflow_path or _WORKFLOW_DEFAULT
if path not in _wf_output_node:
_load_workflow(path) # 触发检测
return _wf_output_node[path]
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
workflow_path: str | None = None, front: bool = False) -> bytes:
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
prompt None 时替换工作流节点60(JjkText)的文本None 时用工作流内置默认提示词
workflow_path工作流 JSON 路径None 则用默认 add_hair.json
frontTrue 时任务插到 ComfyUI 队列最前server "front" 字段队列号取负
接口2 对时延敏感用 True避免排在接口3/5 的批量任务后面其余接口保持 False
"""
path = workflow_path or _WORKFLOW_DEFAULT
output_node = _get_output_node(path)
client_id = uuid.uuid4().hex
with httpx.Client(base_url=COMFYUI_URL, timeout=30.0) as cli:
with httpx.Client(base_url=COMFYUI_URL, timeout=30.0, auth=_comfy_auth()) as cli:
# 1. 上传输入图(含 alpha 遮罩)到 ComfyUI input 目录
fname = f"hair_{client_id}.png"
r = cli.post("/upload/image", files={"image": (fname, rgba_png_bytes, "image/png")},
@@ -49,12 +107,40 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT) -> bytes:
name = (up.get("subfolder") + "/" if up.get("subfolder") else "") + up["name"]
# 2. 改工作流:节点26 输入图 + 随机 seed
wf = copy.deepcopy(_load_workflow())
try:
import io as _io
from PIL import Image as _Img
_sz = _Img.open(_io.BytesIO(rgba_png_bytes)).size
logging.getLogger("hair.worker").info(
"ComfyUI 输入尺寸 %dx%d workflow=%s", _sz[0], _sz[1], os.path.basename(path))
except Exception: # noqa: BLE001
pass
wf = copy.deepcopy(_load_workflow(path))
wf[_INPUT_NODE]["inputs"]["image"] = name
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
if prompt is not None:
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
# 3. 提交
r = cli.post("/prompt", json={"prompt": wf, "client_id": client_id})
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
try:
import os as _os
_diag = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
"log", "comfyui_last_submit")
_os.makedirs(_diag, exist_ok=True)
with open(_os.path.join(_diag, "workflow.json"), "w", encoding="utf-8") as _f:
json.dump(wf, _f, ensure_ascii=False, indent=2)
with open(_os.path.join(_diag, "input.png"), "wb") as _f:
_f.write(rgba_png_bytes)
with open(_os.path.join(_diag, "prompt.txt"), "w", encoding="utf-8") as _f:
_f.write(prompt if prompt is not None else "(None=用工作流内置默认)")
except Exception: # noqa: BLE001
pass
# 3. 提交(front=True 时插队到队列最前)
payload = {"prompt": wf, "client_id": client_id}
if front:
payload["front"] = True
r = cli.post("/prompt", json=payload)
r.raise_for_status()
prompt_id = r.json()["prompt_id"]
@@ -71,14 +157,14 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT) -> bytes:
if status.get("status_str") == "error":
raise RuntimeError(f"ComfyUI 执行报错: {status}")
outputs = entry.get("outputs")
if outputs and _OUTPUT_NODE in outputs:
if outputs and output_node in outputs:
break
time.sleep(1.0)
if not outputs or _OUTPUT_NODE not in outputs:
time.sleep(0.05)
if not outputs or output_node not in outputs:
raise TimeoutError(f"ComfyUI 出图超时({timeout}s) prompt_id={prompt_id}")
# 5. 取回输出图
imgs = outputs[_OUTPUT_NODE].get("images") or []
imgs = outputs[output_node].get("images") or []
if not imgs:
raise RuntimeError("ComfyUI 输出无图像")
info = imgs[0]
@@ -92,7 +178,7 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT) -> bytes:
def ping() -> bool:
"""探测 ComfyUI 是否在线(/system_stats)。"""
try:
with httpx.Client(base_url=COMFYUI_URL, timeout=3.0) as cli:
with httpx.Client(base_url=COMFYUI_URL, timeout=3.0, auth=_comfy_auth()) as cli:
return cli.get("/system_stats").status_code == 200
except Exception: # noqa: BLE001
return False
+47
View File
@@ -186,6 +186,53 @@ def smooth_hairline_corner_aware(
return out
def clamp_hairline_to_silhouette(
hairline_norm: np.ndarray,
parse_map: np.ndarray,
margin_px: float = 2.0,
) -> np.ndarray:
"""把发际线点的 y 钳制在 (skinhair) silhouette 上沿之下(不含 margin 以上)。
根因 issue男性 ellipse 发际线贴到头部外面`sample_hairline` 对射线
未命中 hair 像素的锚点会 fallback 锚点 + 固定 0.18 归一化偏移与头部实际
大小/位置无关 短发/剃光头场景下这个偏移量常常把点顶到头部轮廓外面的背景里
在有效/失效锚点交界处形成尖角被贴图上的不透明像素蒙到就会露出戳出头部的线条
本函数在几何检测之后追加一步安全网对每个点按其 x 所在列 silhouette
SegFormer skinhair 近似头部实际轮廓上沿 y若点比这个上沿还高y
直接钳制到 上沿 + margin_px 保证曲线永远不会跑到头部轮廓外面的背景
"""
h, w = parse_map.shape
cols_with_head, top_y = _head_top_y_per_column(parse_map, use_full_hair=True)
if cols_with_head.size == 0:
return hairline_norm
out = hairline_norm.copy()
for i in range(out.shape[0]):
x_px = float(out[i, 0]) * w
idx = int(np.searchsorted(cols_with_head, x_px))
idx = min(max(idx, 0), cols_with_head.size - 1)
sil_y = float(top_y[idx]) + margin_px
y_px = float(out[i, 1]) * h
if y_px < sil_y:
out[i, 1] = sil_y / h
return out
def sample_hairline_clamped(
landmarks_norm: np.ndarray,
parse_map: np.ndarray,
fallback_extrapolation: float = 0.18,
) -> tuple[np.ndarray, np.ndarray]:
"""策略 A(baseline + 头部轮廓钳制):与默认 `sample_hairline` 完全一致的检测,
额外用 `clamp_hairline_to_silhouette` 兜底 检测失效 fallback 出的点不再可能
跑到头部外面的背景而是贴着头部实际轮廓顶部改动小风险低只在检测失效/
fallback 越界时才生效正常长发照片的结果与 baseline 完全一致
"""
hairline, valid = sample_hairline(landmarks_norm, parse_map, fallback_extrapolation)
hairline = clamp_hairline_to_silhouette(hairline, parse_map)
return hairline, valid
# ---------------------------------------------------------------------------
# Alternative hairline sampling strategies.
#
+78
View File
@@ -0,0 +1,78 @@
"""直接调 ComfyUI 重绘 — 替代 local_test HTTP 服务。
local_test/app.py 的核心逻辑遮罩处理 + ComfyUI 调用提取为 Python 函数
不再需要独立 Flask 服务使用 0716add-hair-api.json 工作流steps=4
"""
from __future__ import annotations
import io
import logging
import os
import numpy as np
from PIL import Image, ImageFilter
from . import comfyui
logger = logging.getLogger("hair.worker")
_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
_REPO = os.path.dirname(os.path.dirname(__file__))
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
def _process_mask_to_rgba(image_bytes: bytes, mask_bytes: bytes) -> bytes:
"""将分开的 image + mask 处理为 ComfyUI 用的 RGBA PNG bytes。
复制 local_test/app.py 的遮罩处理逻辑
1. 加载 image RGB
2. 加载 mask RGBA取所有通道 max 支持红//alpha 遮罩
3. resize mask 到与 image 一致
4. 高斯模糊(radius=4) 柔化边缘
5. alpha = 255 - mask绘制区=255 alpha=0 重绘区
6. 合成 RGBA PNG
"""
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
mask_img = Image.open(io.BytesIO(mask_bytes)).convert("RGBA")
mask_arr = np.array(mask_img)
mask_data = np.max(mask_arr, axis=2) # (H, W) uint8
mask_data_img = Image.fromarray(mask_data, mode="L")
if mask_data_img.size != image.size:
mask_data_img = mask_data_img.resize(image.size, Image.LANCZOS)
mask_data_img = mask_data_img.filter(ImageFilter.GaussianBlur(radius=4))
# ComfyUI LoadImage: mask = 1.0 - (alpha/255)
# alpha=0 -> mask=1.0 (inpaint), alpha=255 -> mask=0.0 (keep)
comfyui_alpha = Image.eval(mask_data_img, lambda x: 255 - x)
r, g, b = image.split()
rgba = Image.merge("RGBA", (r, g, b, comfyui_alpha))
buf = io.BytesIO()
rgba.save(buf, format="PNG")
return buf.getvalue()
def run_redraw(image_bytes: bytes, mask_bytes: bytes,
prompt: str | None = None, timeout: float = 300.0,
front: bool = False) -> bytes:
"""直接调 ComfyUI 重绘 — 替代 local_test /api/generate。
Args:
image_bytes: 人物图片字节JPG/PNG
mask_bytes: 遮罩图片字节支持红//alpha 遮罩格式
prompt: 提示词None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
timeout: ComfyUI 超时秒数
front: True 时任务插到 ComfyUI 队列最前接口2 时延敏感路径用
Returns:
重绘后的 PNG 图片字节
Raises:
RuntimeError: ComfyUI 执行失败
TimeoutError: ComfyUI 超时
"""
rgba_png = _process_mask_to_rgba(image_bytes, mask_bytes)
return comfyui.run(rgba_png, timeout=timeout, prompt=prompt,
workflow_path=_REPAINT_WORKFLOW, front=front)
+432 -46
View File
@@ -14,28 +14,100 @@ from . import constants as C
from . import comfyui
from .face_landmarks import FaceLandmarker
from .face_parsing import FaceParser
from .hairline_2d import sample_hairline, smooth_hairline
from .hairline_2d import (
smooth_hairline, sample_hairline_clamped,
)
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve
from .marker_detect import detect_marker_hairline, path_to_curve_mask
import base64
import io
import logging
logger = logging.getLogger("hair.worker")
# 接口2 女性发型 key → change_hair hair_idchang_*)映射:换发型+Flux-2 整帧重绘用。
# 与接口12 final 的 5 型一一对应。female 6/7bigflower/clasicalflower)无对应 LoRA
# 走与男性一致的原生生发(ComfyUI add_hair)管线,故不在本表。
_FEMALE_KEY_TO_CHANG = {
"ellipse": "chang_tuoyuan", # 椭圆
"flower": "chang_huaban", # 花瓣
"heart": "chang_xinxing", # 心形
"straight": "chang_zhixian", # 直线
"wave": "chang_bolang", # 波浪
}
# 发际线贴图显式顺序表:决定 hair_style 序号(1-indexed)。
# 不再依赖文件名字母序——字母序会因新增/重命名文件而错位,破坏现有前端/客户端取值。
# key 须与 _gender_key 派生结果一致(已去空格):如 "inverse_arc"(源 man_ inverse_arc.png)、
# "Softpetal"(源 man_Soft petal.png,大写 S 保留)。表外未知 key 兜底排到末尾。
_HAIRSTYLE_ORDER = {
"female": ["ellipse", "flower", "heart", "straight", "wave",
"bigflower", "clasicalflower"], # 1..7
"male": ["ellipse", "inverse_arc", "m", "straight", "heart", "Softpetal"], # 1..6
}
_REPO = os.path.dirname(os.path.dirname(__file__))
_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture")
_BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
# ⚠️ 本 worker 是 RTX 5090(sm_120)torch 2.2.2(cu121) 只编到 sm_90CUDA 跑算子会报
# "no kernel image"。SegFormer 默认走 CPU~2.5s/张)。换 torch cu128 后可设 SEG_DEVICE=cuda
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cpu")
# 三接口(接口2女重绘 / 接口2男 / 接口3)统一的 ComfyUI 重绘 prompt。
# 关键:ComfyUI 单卡显存装不下 Flux(7.7G)+qwen CLIP(3.9G) 同驻,靠缓存 CLIP 文本条件避免重载
# prompt 不同会使缓存失效 → 重载 CLIP 并挤出 Flux(每次 +4s)。三接口用同一字符串即可全程命中。
# 与 app.py 接口2/接口3 的默认 prompt 保持一致;可用 REDRAW_PROMPT 覆盖。
_REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
# 接口2 女重绘整条管线(swapHair + ComfyUI)送模型前限边。真实照片常达 1257x1495:
# 全分辨率 ComfyUI 重绘要 13~21s 且激活显存把模型挤出。女性路径含 swapHair(SD WebUI ~5.3s
# 固定地板) + ComfyUI 两段串行。1024 档画质更好但部分大图会踩 12s 线,
# 默认压到 896 兜底(ComfyUI ~4s,女性总耗时 9~11s);追画质可设 REDRAW_MAX_SIDE=1024。
_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "896"))
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
final + 纯红遮罩 PNG返回重绘后的 PNG bytes
失败抛异常调用方负责 try/except 跳过
"""
from .redraw import run_redraw
img = cv2.imdecode(np.frombuffer(image_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
scale = 1.0
orig_w = orig_h = 0
if img is not None:
orig_h, orig_w = img.shape[:2]
m = max(orig_h, orig_w)
if _REDRAW_MAX_SIDE > 0 and m > _REDRAW_MAX_SIDE:
scale = _REDRAW_MAX_SIDE / float(m)
nw, nh = max(1, round(orig_w * scale)), max(1, round(orig_h * scale))
msk = cv2.imdecode(np.frombuffer(mask_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
img_s = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA)
msk_s = cv2.resize(msk, (nw, nh), interpolation=cv2.INTER_NEAREST)
image_png_bytes = cv2.imencode(".png", img_s)[1].tobytes()
mask_png_bytes = cv2.imencode(".png", msk_s)[1].tobytes()
logger.info("接口2女 缩图送 Comfy: %dx%d%dx%d (max_side=%d)",
orig_w, orig_h, nw, nh, _REDRAW_MAX_SIDE)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前,避免排在接口3/5 的批量任务后面
out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout,
prompt=_REDRAW_PROMPT, front=True)
if scale < 1.0 and out:
out = _upscale_png_to(out, orig_w, orig_h)
return out
# 发际线贴图档位:middle=默认(hairline_texture/)high/low 各自独立文件夹。
_TEXTURE_DIRS = {
"middle": _TEXTURE_DIR,
"high": os.path.join(_REPO, "hairline_texture_high"),
"low": os.path.join(_REPO, "hairline_texture_low"),
}
# torch 2.7.1+cu128 已支持 RTX 5090 (sm_120)SegFormer 走 GPU~0.05s/张)
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cuda")
_landmarker = None
_parser = None
_texture_map = None
_texture_maps: dict = {} # {level: {gender: [(key, path)]}},按档位缓存
def get_landmarker() -> FaceLandmarker:
@@ -61,24 +133,32 @@ def _gender_key(stem: str):
return None, None
def get_texture_map() -> dict:
"""扫描 hairline_texture/ {gender: [(key, path)]},按 key 排序、缓存。
def get_texture_map(level: str = "middle") -> dict:
"""扫描指定档位贴图目录{gender: [(key, path)]},按显式顺序表排序、按档位缓存。
levelmiddle(默认) / high / low分别对应 hairline_texture[/_high|/_low]
文件名规范化去空格 `man_ inverse_arc.png` key `inverse_arc`
排序依据 _HAIRSTYLE_ORDER表内 key 按表序表外未知 key 兜底排到末尾再按字母序
保证新增/重命名文件不会打乱现有 hair_style 序号
"""
global _texture_map
if _texture_map is not None:
return _texture_map
if level not in _TEXTURE_DIRS:
raise ValueError(f"hairline_level 必须是 middle/high/low,收到 {level!r}")
cached = _texture_maps.get(level)
if cached is not None:
return cached
mapping: dict[str, list] = {"female": [], "male": []}
for path in sorted(glob.glob(os.path.join(_TEXTURE_DIR, "*.png"))):
for path in sorted(glob.glob(os.path.join(_TEXTURE_DIRS[level], "*.png"))):
stem = os.path.splitext(os.path.basename(path))[0]
gender, key = _gender_key(stem)
if gender:
mapping[gender].append((key, path))
for g in mapping:
mapping[g].sort(key=lambda kp: kp[0])
_texture_map = mapping
return _texture_map
order = _HAIRSTYLE_ORDER.get(g, [])
idx = {k: i for i, k in enumerate(order)}
mapping[g].sort(key=lambda kp: (idx.get(kp[0], len(idx)), kp[0]))
_texture_maps[level] = mapping
return mapping
def extract_502(image_bgr: np.ndarray):
@@ -90,13 +170,19 @@ def extract_502(image_bgr: np.ndarray):
def extract_context(image_bgr: np.ndarray):
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。"""
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。
发际线几何检测固定用 `sample_hairline_clamped`射线检测 + 头部轮廓钳制
短发/剃光头照片 man_test.jpg中间锚点检测失效时纯射线检测的固定 fallback
偏移会把点顶到头部轮廓外面的背景产生"发际线贴到头部外面"的视觉 bug钳制兜底后
fallback 点不会再跑出头部轮廓正常长发照片结果与旧行为一致
"""
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
landmarks = get_landmarker().detect(rgb)
if landmarks is None:
return None
parse_map = get_parser().parse(rgb)
hairline_2d, valid = sample_hairline(landmarks, parse_map)
hairline_2d, valid = sample_hairline_clamped(landmarks, parse_map)
hairline_2d = smooth_hairline(hairline_2d, valid)
hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
middle_3d = build_middle_row(landmarks, hairline_3d)
@@ -128,11 +214,20 @@ def generate_previews(image_bgr: np.ndarray, gender: str):
return results
def generate_grow_results(image_bgr: np.ndarray, gender: str):
"""该性别全部发际线:预览图(白线) + 生发图(ComfyUI)。同步、串行。
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):
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
Returns: list[dict] {"hairline_type","order","image_bgr"(预览), "grown_png"(bytes None)}
无人脸返回 None某张 ComfyUI 失败时该项 grown_png=None不影响其余
hair_styles1-indexed 列表指定生成哪几张发际线按贴图排序female: 1..7male: 1..6
None 时生成全部兼容旧调用
use_mask默认 True是否启用 inpaint 遮罩用于测试对比同接口3
False 时用**干净原图 + 空遮罩** ComfyUI不烧黑色模板线
prompt默认 NoneComfyUI 提示词 None 时替换工作流节点60文本
workflow_path默认 NoneComfyUI 工作流 JSON 路径None 用默认 add_hair.json
Returns: list[dict] {"hairline_type","order","overlay"((H,W,4) RGBA 透明层),
"grown_png"(bytes None)}
无人脸返回 None某张 ComfyUI 失败时该项 grown_png=None不抛异常
"""
if gender not in ("male", "female"):
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
@@ -140,34 +235,237 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str):
if ctx is None:
return None
uv, ext_faces = load_ext_mesh()
results = []
for order, (key, white_path) in enumerate(get_texture_map()[gender], start=1):
white = load_texture_rgba(white_path)
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white)
textures = get_texture_map()[gender] # [(key, path), ...] 已排序
if hair_styles is not None:
items = [(s, textures[s - 1]) for s in hair_styles]
else:
items = list(enumerate(textures, start=1))
# 禁用遮罩:干净原图 + 空遮罩,与模板无关 → 只跑一次 ComfyUI,下面 N 项复用
shared_grown = None
if not use_mask:
try:
h, w = image_bgr.shape[:2]
img_s, msk_s, gsc = _prep_comfy_input(image_bgr, np.zeros((h, w), np.uint8))
buf = io.BytesIO()
compose_comfy_rgba(img_s, msk_s).save(buf, format="PNG", compress_level=1)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt,
workflow_path=workflow_path, front=True)
if gsc < 1.0 and shared_grown:
shared_grown = _upscale_png_to(shared_grown, w, h)
except Exception as e: # noqa: BLE001
logger.warning("接口2 生发图失败(无遮罩)%s", e)
results = []
h, w = image_bgr.shape[:2]
for order, (key, white_path) in items:
white = load_texture_rgba(white_path)
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
if not use_mask:
grown_png = shared_grown
else:
grown_png = None
try:
black = load_texture_rgba(_black_texture_path(white_path))
marked, mask = build_inpaint_mask(
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
m_s, msk_s, gsc = _prep_comfy_input(marked, mask)
buf = io.BytesIO()
compose_comfy_rgba(marked, mask).save(buf, format="PNG")
grown_png = comfyui.run(buf.getvalue())
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
grown_png = comfyui.run(buf.getvalue(), prompt=prompt,
workflow_path=workflow_path, front=True)
if gsc < 1.0 and grown_png:
grown_png = _upscale_png_to(grown_png, w, h)
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
logger.warning("接口2 生发图失败 type=%s%s", key, e)
results.append({"hairline_type": key, "order": order,
"image_bgr": preview, "grown_png": grown_png})
"overlay": overlay, "grown_png": grown_png})
return results
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str):
"""接口5:该性别全部发际线叠图(同接口2预览) + 最佳(order1)发际线曲线的面部中间点。
def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
redraw_defaults: dict,
redraw_max_side: int | None = None,
unet_name: str | None = None,
prompt: str | None = None):
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results+ 生发图。
Returns: {"images":[{hairline_type,order,image_bgr}], "best_center":(x,y)}无人脸 None
生发图来源按发型分两路
- 换发型chang_* 表内1..5female key change_hair chang_* hair_id
face_analysis.hairline_grow.generate_hairline_redraw= 接口12 final 管线参数用
redraw_defaults拿到 final接缝融合基底+ - 纯红遮罩 PNG**后端直接调
ComfyUI**0716add-hair-api.json 工作流完成发际线带重绘
- 原生生发表外6/7 bigflower/clasicalflower 无对应 change_hair LoRA改走与男性
一致的原生生发ComfyUI add_hair _grow_native_one 完成黑模板 + inpaint 遮罩
overlay 仍是发际线曲线透明层 generate_grow_results 完全一致
prompt 仅用于原生生发分支换发型分支的提示词由 redraw 流程内部固定
Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes None)}
无人脸返回 None单个发型换发型/重绘失败时 grown_png=None不抛异常
"""
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError
from face_analysis.head_mask import SEGFORMER_HAIR
ctx = extract_context(image_bgr)
if ctx is None:
return None
uv, ext_faces = load_ext_mesh()
# 复用 extract_context 已算好的 SegFormer parse_map,避免 generate_hairline_redraw 内部重复分割
hair_mask_reuse = (ctx["parse_map"] == SEGFORMER_HAIR)
textures = get_texture_map()["female"] # [(key, path), ...] 已排序
if hair_styles is not None:
items = [(s, textures[s - 1]) for s in hair_styles]
else:
items = list(enumerate(textures, start=1))
results = []
h, w = image_bgr.shape[:2]
# 重绘管线(swapHair + ComfyUI)统一降分辨率:真实照片 swap(SD WebUI)~5s、blend、ComfyUI
# 均随分辨率线性下降。overlay 预览仍用全分辨率;grown_png 最后放大回原尺寸。
redraw_img = image_bgr
hair_mask_redraw = hair_mask_reuse
if _REDRAW_MAX_SIDE > 0 and max(h, w) > _REDRAW_MAX_SIDE:
redraw_img, _rs = _downscale_max_side(image_bgr, _REDRAW_MAX_SIDE)
_nh, _nw = redraw_img.shape[:2]
if hair_mask_redraw is not None:
hair_mask_redraw = cv2.resize(hair_mask_reuse.astype(np.uint8), (_nw, _nh),
interpolation=cv2.INTER_NEAREST).astype(bool)
logger.info("接口2女 管线降分辨率: %dx%d%dx%d (max_side=%d)",
w, h, _nw, _nh, _REDRAW_MAX_SIDE)
for order, (key, white_path) in items:
white = load_texture_rgba(white_path)
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
grown_png = None
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
if chang_id is None:
# 无对应 change_hair LoRA(如 bigflower/clasicalflower)→ 走与男性一致的原生生发
logger.info("接口2 女 key=%s 无 chang_id,走原生生发(ComfyUI add_hair)", key)
grown_png = _grow_native_one(image_bgr, ctx, white_path,
prompt=prompt, unet_name=unet_name)
else:
try:
import time as _t
_ts0 = _t.perf_counter()
data = generate_hairline_redraw(redraw_img, chang_id, hair_mask=hair_mask_redraw, **redraw_defaults)
_ts1 = _t.perf_counter()
steps = data.get("steps") or {}
# ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG
final_b64 = steps.get("final_base64") or ""
mask_b64 = steps.get("redraw_band_mask_base64") or ""
if not final_b64 or not mask_b64:
logger.warning("接口2 换发型:type=%s final/遮罩缺失(final=%d mask=%d",
key, len(final_b64), len(mask_b64))
else:
# 去掉 data URI 前缀
if final_b64.startswith("data:"):
final_b64 = final_b64.split(",", 1)[1]
if mask_b64.startswith("data:"):
mask_b64 = mask_b64.split(",", 1)[1]
final_bytes = base64.b64decode(final_b64)
mask_bytes = base64.b64decode(mask_b64)
# 后端直接调 ComfyUI 重绘,返回重绘后的 PNG
_tr0 = _t.perf_counter()
grown_png = _call_local_redraw(final_bytes, mask_bytes)
_tr1 = _t.perf_counter()
_tm = data.get("timings_ms") or {}
logger.info("接口2女 分段计时 type=%s: swapHair管线=%.2fs (mask=%dms swap=%dms blend=%dms), ComfyUI重绘=%.2fs",
key, _ts1 - _ts0,
_tm.get("mask", 0), _tm.get("swap", 0), _tm.get("blend", 0),
_tr1 - _tr0)
if grown_png is None:
logger.warning("接口2 换发型:type=%s 重绘结果为空", key)
elif redraw_img is not image_bgr:
# 管线在降分辨率图上跑,结果放大回原尺寸
grown_png = _upscale_png_to(grown_png, w, h)
except NoFaceError:
logger.warning("接口2 换发型:type=%s 未检出人脸", key)
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
logger.warning("接口2 换发型图失败 type=%s%s", key, e)
results.append({"hairline_type": key, "order": order,
"overlay": overlay, "grown_png": grown_png})
return results
def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
use_mask: bool, prompt: str | None):
"""对单个发际线做生发(ComfyUI)。黑模板固定取 hairline_texture_black/middle),
hairline_level 无关high/low 贴图与 middle 同名basename 映射即落回 middle 黑模板
use_mask=False 时用干净原图+空遮罩与贴图无关white_path 可为 None
失败返回 None不抛异常
"""
try:
if use_mask:
black = load_texture_rgba(_black_texture_path(white_path))
marked, mask = build_inpaint_mask(
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
else:
h, w = image_bgr.shape[:2]
marked, mask = image_bgr, np.zeros((h, w), np.uint8)
buf = io.BytesIO()
compose_comfy_rgba(marked, mask).save(buf, format="PNG", compress_level=1)
return comfyui.run(buf.getvalue(), prompt=prompt)
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
logger.warning("接口5 生发图失败:%s", e)
return None
def _grow_native_one(image_bgr: np.ndarray, ctx: dict, white_path: str,
prompt: str | None = None, unet_name: str | None = None):
"""对单个发际线做原生生发(ComfyUI add_hair),与男性 generate_grow_results 一致。
接口2 女性新发型bigflower/clasicalflower 无对应 change_hair LoRA改走此路径
黑模板 build_inpaint_mask 限边_prep_comfy_input comfyui.run(front=True)
失败返回 None不抛异常结果按限边前原图尺寸放大回原尺寸仅展示对齐
"""
try:
h, w = image_bgr.shape[:2]
black = load_texture_rgba(_black_texture_path(white_path))
marked, mask = build_inpaint_mask(
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
m_s, msk_s, gsc = _prep_comfy_input(marked, mask)
buf = io.BytesIO()
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, front=True, unet_name=unet_name)
if gsc < 1.0 and grown_png:
grown_png = _upscale_png_to(grown_png, w, h)
return grown_png
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
logger.warning("接口2 女原生生发图失败:%s", e)
return None
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
hair_styles: list[int], use_mask: bool = True,
prompt: str | None = None,
generate_grow_image: bool = True):
"""接口5:对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。
入参同接口2先选 gender再多选 hair_styles必填1-indexed 按贴图排序
每个选中发型返回三档叠图middle/high/lowRGBA 透明层只含发际线曲线与一张生发图
三档贴图同名生发黑模板固定取自 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 取首个选中发型三档各自的发际线中点
"""
if gender not in ("male", "female"):
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
if not hair_styles:
raise ValueError("hair_styles 必填且不能为空")
ctx = extract_context(image_bgr)
if ctx is None:
return None
@@ -177,29 +475,115 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str):
# 面部中轴 x = 眉心(9/151 中点)
face_cx = float((lm[9, 0] + lm[151, 0]) / 2 * w)
textures = get_texture_map()[gender]
images, best_center = [], None
for order, (key, path) in enumerate(textures, start=1):
white = load_texture_rgba(path)
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white)
images.append({"hairline_type": key, "order": order, "image_bgr": preview})
if order == 1: # 最佳发际线曲线的中点(面部中轴处的发际线 y)
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
# 三档贴图表(同性别、同 key 顺序,因三个文件夹同名)
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 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):
"""从某档发际线透明叠图取面部中轴处的发际线中点 (x,y),无像素返回 None。"""
ys, xs = np.where(overlay[:, :, 3] > 40)
if xs.size:
if not xs.size:
return None
near = np.abs(xs - face_cx) <= max(2, int(w * 0.02))
col_ys = ys[near] if near.any() else ys[np.argsort(np.abs(xs - face_cx))[:20]]
best_center = (int(round(face_cx)), int(round(float(col_ys.mean()))))
return {"images": images, "best_center": best_center}
return (int(round(face_cx)), int(round(float(col_ys.mean()))))
images, best_centers = [], None
for s in hair_styles: # s = 1-indexed 发型序号
key, mid_path = tex_by_level["middle"][s - 1]
overlays = {}
for lv in _TEXTURE_DIRS:
white = load_texture_rgba(tex_by_level[lv][s - 1][1])
overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
# 生发:固定 middle 黑模板(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)发际线中点
if best_centers is None:
best_centers = {lv: _center_of(overlays[lv]) for lv in _TEXTURE_DIRS}
return {"images": images, "best_centers": best_centers}
def generate_grow_b(marked_bgr: np.ndarray):
# 接口3 送 ComfyUI 前限边,降低峰值显存,避免与接口2 切换时把 Flux 挤出。
# 统一 prompt 后 Flux 不再被 CLIP 挤出,接口3 可用较高分辨率。可用 GROW_B_MAX_SIDE 覆盖。
_GROW_B_MAX_SIDE = int(os.getenv("GROW_B_MAX_SIDE", "1024"))
def _downscale_max_side(img_bgr: np.ndarray, max_side: int) -> tuple[np.ndarray, float]:
"""长边超过 max_side 时等比例缩小;返回 (图, scale)scale=新/旧。"""
h, w = img_bgr.shape[:2]
m = max(h, w)
if max_side <= 0 or m <= max_side:
return img_bgr, 1.0
scale = max_side / float(m)
nw = max(1, int(round(w * scale)))
nh = max(1, int(round(h * scale)))
out = cv2.resize(img_bgr, (nw, nh), interpolation=cv2.INTER_AREA)
return out, scale
def _upscale_png_to(png_bytes: bytes, out_w: int, out_h: int) -> bytes:
"""把 Comfy 输出 PNG 双线性拉回原图尺寸(仅展示对齐,不增加推理细节)。"""
arr = np.frombuffer(png_bytes, np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
if img is None:
return png_bytes
if img.shape[1] == out_w and img.shape[0] == out_h:
return png_bytes
resized = cv2.resize(img, (out_w, out_h), interpolation=cv2.INTER_LINEAR)
ok, buf = cv2.imencode(".png", resized)
return buf.tobytes() if ok else png_bytes
def _prep_comfy_input(img_bgr: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray, float]:
"""单段 ComfyUI 生发(接口2男 / 接口3)送图前限边到 GROW_B_MAX_SIDE。
返回 (缩后图, 缩后遮罩, scale)scale<1 时调用方需把结果放大回原尺寸"""
h, w = img_bgr.shape[:2]
if _GROW_B_MAX_SIDE <= 0 or max(h, w) <= _GROW_B_MAX_SIDE:
return img_bgr, mask, 1.0
out, scale = _downscale_max_side(img_bgr, _GROW_B_MAX_SIDE)
nh, nw = out.shape[:2]
msk = cv2.resize(mask, (nw, nh), interpolation=cv2.INTER_NEAREST)
logger.info("接口2男/接口3 缩图送 Comfy: %dx%d%dx%d (max_side=%d)",
w, h, nw, nh, _GROW_B_MAX_SIDE)
return out, msk, scale
def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str = None):
"""接口3:检测医生手绘发际线 → 遮罩 → 送 ComfyUI 生发(仅需划线图一张)。
检测路径只用来**建遮罩**ComfyUI 输入图用 **marked 原图**含医生手绘线
工作流提示词会清除黑线再生发
Comfy 前若长边 > GROW_B_MAX_SIDE默认 896会先等比例缩小降低峰值显存
输出再拉回原图尺寸
use_mask默认 True是否启用自动检测的遮罩用于测试对比
- True检测手绘线 建遮罩 alpha=255mask透明区=重绘区节点44 画黄色参考区
- False跳过检测直接送划线图alpha 255空遮罩节点26 mask 为空
模型仅凭医生黑线参考生发无需改工作流唯一变量是遮罩
Returns: {"grown_png": bytes None, "status": "ok"|"no_face"|"no_line"}
"""
orig_h, orig_w = marked_bgr.shape[:2]
marked_bgr, _scale = _downscale_max_side(marked_bgr, _GROW_B_MAX_SIDE)
if _scale < 1.0:
logger.info(
"接口3 缩图送 Comfy: %dx%d%dx%d (max_side=%d)",
orig_w, orig_h, marked_bgr.shape[1], marked_bgr.shape[0], _GROW_B_MAX_SIDE,
)
h, w = marked_bgr.shape[:2]
if use_mask:
rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB)
landmarks = get_landmarker().detect(rgb)
if landmarks is None:
@@ -208,15 +592,17 @@ def generate_grow_b(marked_bgr: np.ndarray):
path = detect_marker_hairline(marked_bgr, landmarks, parse_map)
if path is None:
return {"grown_png": None, "status": "no_line"}
h, w = marked_bgr.shape[:2]
line_w = max(2, int(w * 0.006))
curve_mask = path_to_curve_mask(path, h, w, thickness=max(3, line_w))
mask = mask_from_curve(curve_mask, landmarks, parse_map)
else:
mask = np.zeros((h, w), np.uint8) # 空遮罩:alpha 全 255,跳过检测
buf = io.BytesIO()
compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG") # marked 原图 + 遮罩
grown_png = comfyui.run(buf.getvalue())
compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG", compress_level=1) # marked + 遮罩
grown_png = comfyui.run(buf.getvalue(), prompt=prompt)
if _scale < 1.0 and grown_png:
grown_png = _upscale_png_to(grown_png, orig_w, orig_h)
return {"grown_png": grown_png, "status": "ok"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

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