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>
This commit is contained in:
xsl
2026-06-15 20:58:33 +08:00
co-authored by Claude Opus 4.8
parent 68fe1c1406
commit 76f7c06905
15 changed files with 131 additions and 2624 deletions
+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,网关保持轻量。
---
+11 -26
View File
@@ -679,32 +679,17 @@ async def face_features(
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
):
# 三选一。image_url 直接交给豆包拉取;file/base64 解成字节转 data URI
provided = [x for x in (image_file, image_url, image_base64) if x]
if len(provided) != 1:
return err(1007, "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个")
img_bytes = None
if not image_url:
raw, e = await resolve_image_bytes(image_file, None, image_base64)
if e is not None:
return e
if len(raw) > MAX_FILE_BYTES:
return err(1006, "文件超出 1 MB 限制")
img_bytes = raw
try:
from fastapi.concurrency import run_in_threadpool
from face_features import analyze_features, has_face
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
if not has_face(feats):
return err(1001, "无法识别人像")
# 契约:data.features 为 JSON 字符串
return ok({"features": json.dumps(feats, ensure_ascii=False)})
except Exception as ex: # noqa: BLE001
logger.exception("接口4 处理异常")
return err(1007, f"处理失败:{ex}")
# ⚠️ 接口4 已迁到**网关本机**实现(直接调豆包视觉模型,见 gateway/app.py
# 网关不会把本接口转发到 worker,故此处仅留 Mock 占位、保持 worker 无外网依赖。
features = json.dumps(
{
"_note": "接口4 由网关实现,worker 此响应为占位 Mock",
"face_shape": "鹅蛋脸", "eyebrow_shape": "平眉", "facial_age": "18-25岁",
"dynamic_static_type": "静态型", "gender": "", "gene_style": "少年型",
},
ensure_ascii=False,
)
return ok({"features": features})
# ---------------------------------------------------------------------------
+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 历史)。
+98
View File
@@ -0,0 +1,98 @@
# 旷视五接口 — 实现说明(总)
> 把原先分散的「各接口技术方案 + 开发任务书 + 系统架构 + 网关任务书」合并成这一份**简要总览**。
> 对外 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
```
- **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` |
| 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_base64` | `hairline_images[].image_url` |
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
### 错误码
`1001` 无法识别人像 `1002` 分辨率过低 `1003` 非正面 `1004` gender 必填/非法(接口2/5)|
`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°)。
### 接口2 C端生发 `/api/v1/hair/grow`worker)—— 预览 + 生发图
- **做什么**:正面照 + `gender`(必填) → 该性别全部发际线(female5/male4) 各一组:**预览图**(发际线叠在照片上) + **生发后图**(植发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 序。
同步、一次 N 张(~18s)。返回 `results[].image_base64` + `grown_image_base64`
### 接口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
- **做什么**:照片 + `gender`(必填) → N 张发际线叠加图(同接口2预览) + 最佳(order1)发际线曲线的面部中间点坐标。
- **怎么实现**:复用接口2 的 502 点渲染管线,输出 N 张叠图 + `best_hairline_center_point`(眉心 x × 该处发际线 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(8182)**Flux-2,它自带支持 5090 的 torch);worker 只调其 HTTP API,不跑 Flux。
- `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 比例推算兜底)
@@ -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`
+3 -3
View File
@@ -80,7 +80,7 @@
| 1001 | 无法识别人像 | 图片中未检测到人脸 |
| 1002 | 人像分辨率过低 | 低于最低分辨率要求 |
| 1003 | 角度问题,非正面照 | 非正面 / 角度过大 |
| 1004 | 性别标签判断异常 | 男女标签无法判定 **【待确认】** 是否作为错误 |
| 1004 | gender 必填/非法 | 接口2/5 的 `gender` 缺失或非 `male`/`female` |
| 1005 | 检测到多张人脸 | 默认仅支持单人,检测到 2 人或以上时返回 |
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
@@ -180,7 +180,7 @@
**说明**:输入用户正面照 + 性别,按性别对应的发际线类型,逐张把建议发际线渲染到照片上,输出多张方案。
> **每个方案返回两张图**`image_url`=「原照片 + 发际线曲线叠加的**预览图**」;`grown_image_url`=
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」。两者均已实现,见 [`接口2-C端生发-技术实现方案.md`](接口2-C端生发-技术实现方案.md)。
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。
**请求**`POST /api/v1/hair/grow`
@@ -224,7 +224,7 @@
}
```
> 识别失败时返回通用错误码(1001 / 1002 / 1003 等)。`gender` 缺失或非法值返回参数错误(1008;本接口已改为必填入参,不再自动判别性别1004 不再使用)
> 识别失败时返回通用错误码(1001 / 1002 / 1003 等)。`gender` 缺失或非法值返回 **1004**;本接口已改为必填入参,不再自动判别性别。
---
@@ -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
+5 -5
View File
@@ -256,7 +256,7 @@ async def face_features(
# 三选一校验
provided = [x for x in (image_file, image_url, image_base64) if x]
if len(provided) != 1:
return JSONResponse(status_code=400, content={
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,
})
@@ -265,7 +265,7 @@ async def face_features(
if image_file:
raw = await image_file.read()
if len(raw) > 1_000_000:
return JSONResponse(status_code=400, content={
return JSONResponse(status_code=200, content={
"code": 1006, "message": "文件超出 1 MB 限制",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
@@ -277,7 +277,7 @@ async def face_features(
try:
img_bytes = base64.b64decode(b64)
except Exception:
return JSONResponse(status_code=400, content={
return JSONResponse(status_code=200, content={
"code": 1008, "message": "图片格式不支持(base64 解码失败)",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
@@ -289,13 +289,13 @@ async def face_features(
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
except Exception as ex:
logger.exception("接口4 豆包调用失败")
return JSONResponse(status_code=503, content={
return JSONResponse(status_code=200, content={
"code": 1007, "message": f"分析服务异常:{ex}",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
if not has_face(feats):
return JSONResponse(status_code=400, content={
return JSONResponse(status_code=200, content={
"code": 1001, "message": "无法识别人像",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
+2 -2
View File
@@ -26,8 +26,8 @@ transformers==4.45.2 # SegFormer 人脸分割(jonathandinu/face-parsing
# ⚠️ 必须 0.24.x —— 0.25+ 强依赖 numpy>=2,会顶掉 mediapipe 需要的 numpy<2
scikit-image==0.24.0 # route_through_array(黑帽响应图上的 Dijkstra 最小路径)
# 接口4:用户特征(调用火山方舟 豆包视觉模型,唯一外网依赖)
volcengine-python-sdk[ark] # from volcenginesdkarkruntime import ArkAPI Key 走配置不入 git
# 接口4:用户特征(火山方舟 豆包视觉模型)—— 已迁到**网关**实现,worker 不需要。
# 网关机装:volcengine-python-sdk[ark]from volcenginesdkarkruntime import ArkAPI Key 走配置不入 git
# 测试
pytest==8.3.3
+2 -26
View File
@@ -151,32 +151,8 @@ def test_hairline_gen_female(client):
assert "image_url" not in d["hairline_images"][0]
FEATURES = "/api/v1/face/features"
def test_features_success(client, monkeypatch):
import face_features as ff
monkeypatch.setattr(ff, "analyze_features", lambda *a, **k: {
"图片是否有人脸": "有人", "脸型": "鹅蛋脸", "face_shape": "鹅蛋脸",
"gender": "", "gene_style": "少女型"})
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
body = client.post(FEATURES, headers=H, files=files).json()
assert body["code"] == 0, body
f = json.loads(body["data"]["features"]) # features 是 JSON 字符串
assert f["face_shape"] == "鹅蛋脸" and f["gender"] == ""
def test_features_no_face_1001(client, monkeypatch):
import face_features as ff
monkeypatch.setattr(ff, "analyze_features", lambda *a, **k: {"图片是否有人脸": "没人"})
files = {"image_file": ("x.jpg", open(fixture("landscape.jpg"), "rb"), "application/octet-stream")}
assert client.post(FEATURES, headers=H, files=files).json()["code"] == 1001
def test_features_multi_param_1007(client):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
r = client.post(FEATURES, headers=H, files=files, data={"image_url": "http://x/y.jpg"})
assert r.json()["code"] == 1007
# 接口4(用户特征)已迁到网关本机实现(直接调豆包),不再在 worker;
# 其测试随实现一起在网关侧做,worker 这边不再覆盖。
def test_success_structure(client):