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

364 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 接口 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 个月效果)