Files
hair/docs/接口2-C端生发-技术实现方案.md
T
xslandClaude Opus 4.8 d4e6a794d8 docs(接口2): 标注生发图已实现,清理"只做预览"过期文案
接口2 的生发后图片(§10 ComfyUI/Flux)已实现,更新早期"本期只做预览/不做生发"的描述:
- 技术方案:标题/§0/§9.6 改为"预览+生发两步均已实现",§9.6 列出后续优化方向
- 接口文档:接口2 说明改为"每方案返回 image_url(预览)+grown_image_url(生发图)"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:47:42 +08:00

22 KiB
Raw Blame History

接口 2:C 端生发 — 技术实现方案(发际线预览 + 生发图)

当前状态(2026-06-15):两步均已实现。 ① 发际线曲线叠加预览图(§1~§9); ② 生发后图片(§10ComfyUI/Flux)。results[] 每项同时返回 image_base64(预览) 与 grown_image_base64(生发图)。下文 §0「本期只做预览」为初版历史描述,以本说明与 §10 为准。

高性能 workerGPU 机) 上实现,与接口 1 同机。对外接口经网关代理(见 系统架构-网关与高性能后端.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 新增必填 gendermale/femalebeauty_enabled 保留但本期不生效
输出 results[] 数量 Mock 2 个 = 该性别的贴图数量(female 5 张 / male 4 张
results[].image_url 生发后图片 本期 = 发际线曲线叠加在原照片上的预览图
results[].hairline_type 中文(花瓣形…) 英文 keyflower/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.pyDEFAULT_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.pyHF_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. GPUFaceParser(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 步骤

# 伪代码
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 调换。
  • flipYface_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-pythonnumpyPillow、torch(CUDA)。接口 2 新增

mediapipe>=0.10            # Tasks Vision FaceLandmarker(注意与接口1的 solutions API 可共存)
transformers>=4.40         # SegFormer 人脸分割
# torch/torchvision 已由接口1引入(worker CUDA 版)

⚠️ 两套人脸分割模型:接口 1 用 BiSeNet79999_iter.pth),接口 2 用 head3d 的 SegFormerjonathandinu/face-parsing)。两者并存,显存/内存够(worker 32G+GPU)。后续可评估是否统一为一个分割模型,本期先各用各的,不强行合并

⚠️ MediaPipe API 差异:接口 1 用 mp.solutions.face_mesh468 点 + 虹膜 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)。
  • 模型单例:FaceLandmarkerFaceParser 在模块加载时初始化一次,避免每请求重建。face_ext.obj 的 UV/faces 也只解析一次缓存。

7. 离线资产(内网部署)

接口 2 新增需要随项目带入内网的模型(已下载,登记到 OFFLINE_ASSETS.md):

  • hairline/models/face_landmarker.taskMediaPipe~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_base64image_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 一张 RGBARGB = 画了黑色发际线划线的照片,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/growresults[] 每项新增生发图字段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 个月效果)