# 接口 2:C 端生发 — 技术实现方案(第一步:发际线遮罩渲染) > 在 **高性能 worker(GPU 机)** 上实现,与接口 1 同机。对外接口经网关代理(见 [`系统架构-网关与高性能后端.md`](系统架构-网关与高性能后端.md))。 > 发际线检测算法移植自 **head3d** 项目(已实现 502 点 mesh + UV 贴图方案)。 --- ## 0. 本期范围(第一步) 接口 2 输入用户正面照 + **性别**,按性别对应的发际线类型贴图,**逐张把发际线曲线渲染到照片上**,输出多张「叠加了建议发际线的预览图」,按固定顺序返回。 - **本期只做「渲染遮罩/预览图」**,不做真正的文生图生发(那是后续步骤)。当前 `image_url` 返回的是「原照片 + 发际线曲线叠加图」。 - 排序 `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 indexMap(build_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.obj:UV(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_raw(V=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/`(SegFormer:config + 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. **真正的生发(文生图)**:本期只出遮罩/预览。下一步把预览图/曲线作为 ControlNet/inpaint 输入接文生图模型,再替换 `image_url` 为真实生发图。 --- ## 10. 第二步:生发图生成(ComfyUI + Flux inpaint)★ 新增 > 在「发际线预览」基础上,**新增真实生发后图片**:把发际线划线 + 遮罩送入本机 > ComfyUI(Flux-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 step2;headmark 用 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*(1−mask))` # **透明=重绘区**,对齐 ComfyUI `mask=1−alpha` - `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 时每种附带 grown,handler 返回新字段 | curl:results 含 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-14(v1.1:新增 §10 生发图生成 ComfyUI 管线) > 算法来源: head3d(502 点 mesh + UV)+ Flux-2 Klein 9b(ComfyUI add_hair.json)| 运行位置: worker(GPU) + 本机 ComfyUI(8182) > **产出**: ① 发际线曲线叠加预览图 ② 生发后图片(植发 3 个月效果)