diff --git a/OFFLINE_ASSETS.md b/OFFLINE_ASSETS.md index 6009544..fdf2924 100644 --- a/OFFLINE_ASSETS.md +++ b/OFFLINE_ASSETS.md @@ -84,7 +84,7 @@ model.safetensors https://huggingface.co/jonathandinu/face-parsing/resol - **worker(GPU 机)**:`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,网关保持轻量。 --- diff --git a/app.py b/app.py index 91fa320..cc6fe89 100644 --- a/app.py +++ b/app.py @@ -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}) # --------------------------------------------------------------------------- diff --git a/docs/README.md b/docs/README.md index 14a6325..475331e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,33 +1,14 @@ -# 文档索引(按开发机器划分) +# 文档索引 -系统拆分为两台机器开发:**外网网关** 和 **高性能 worker(GPU)**。下面标清每台机器该读哪些文档。 - -## 🌐 两端共享(都要读) +旷视五接口(四庭七眼测量 / 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 历史)。 diff --git a/docs/实现说明.md b/docs/实现说明.md new file mode 100644 index 0000000..c7e6953 --- /dev/null +++ b/docs/实现说明.md @@ -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. 部署 / 环境要点 + +**worker(GPU 机)** +- 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 历史中已合并的旧技术方案文档。 diff --git a/docs/接口1-四庭七眼测量-开发任务书.md b/docs/接口1-四庭七眼测量-开发任务书.md deleted file mode 100644 index fc47c06..0000000 --- a/docs/接口1-四庭七眼测量-开发任务书.md +++ /dev/null @@ -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}` 与错误码 1001–1008 不变。 - -**核心算法策略**(见技术方案 §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、虹膜 468–477、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 通常在 20–120 之间,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 bool,True=头发)。预处理 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.15–0.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==0,data 含 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: " -# 0) 正常请求(直连 worker)→ code==0,data 含 annotated_image_base64 -curl -s -H "$TOK" -X POST http://: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://:28187/api/v1/face/measure -F image_file=@tests/fixtures/frontal.jpg -# 就绪) /health → 200 -curl -s http://: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` 全绿 -- [ ] worker(GPU 机 :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-6;Tier 2 通过;Tier 3 叠加图人工确认 OK。 - ---- - -> **任务书版本**: v1.5 | **创建日期**: 2026-06-13(v1.5:拆出网关任务书到独立文档,本书聚焦 worker 侧)| 配套技术方案 v2.0 / 系统架构 v1.0 / 网关任务书 v1.0 diff --git a/docs/接口1-四庭七眼测量-技术实现方案.md b/docs/接口1-四庭七眼测量-技术实现方案.md deleted file mode 100644 index 1e2d9c3..0000000 --- a/docs/接口1-四庭七眼测量-技术实现方案.md +++ /dev/null @@ -1,880 +0,0 @@ -# 接口 1:四庭七眼测量 — 技术实现方案 - -> 基于 MediaPipe Face Mesh(468 关键点)测量「眉心以下」+ 人脸解析分割(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.3–1s/张) | -| MODNet | 人像 matting | 前景/背景 | ~25 MB | ✅ | 只分前景,不区分头发 | -| SegFormer-b0 face-parsing | CelebAMask-HQ | 19 | ~15 MB | ✅ HuggingFace | 更轻,需 transformers | - -**选型:BiSeNet(face-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 或 151(glabella,双眉间) - │ 中庭 (~28%) - ★ 鼻翼下缘 (nose_bottom) ← 索引 94(subnasale / 人中顶部) - │ 下庭 (~25%) - ★ 下巴尖 (chin_tip) ← 索引 152(menton) -``` - -| 测量点 | 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 -``` - -> **注意**:虹膜关键点(索引 468–477)需要 `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 # 方案 B:BiSeNet 头发分割封装 -│ ├── 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.jpg,682×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 5090(compute 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.3–1s/张,方案 B 正常可用)。 -- **要真正用上 5090 GPU**:需换装支持 sm_120 的构建(**torch cu128,≥2.7**,配套 - torchvision),代码无需改动(`_select_device` 会自动选 CUDA)。可设 `FORCE_CPU=1` 强制 CPU。 - ---- - -> **文档版本**: v2.0 -> **创建日期**: 2026-06-13(v2.0 修订:修复循环论证/分辨率/字体/numpy 等问题,引入方案 B 分割 + solvePnP 姿态) -> **依赖模型**: MediaPipe Face Mesh (468 landmarks) + BiSeNet face-parsing (头发分割) -> **测量策略**: 眉心以下实测关键点 + 方案 B 分割取真实发际线/头顶(方案 A 比例推算兜底) diff --git a/docs/接口2-C端生发-技术实现方案.md b/docs/接口2-C端生发-技术实现方案.md deleted file mode 100644 index 6b19ea1..0000000 --- a/docs/接口2-C端生发-技术实现方案.md +++ /dev/null @@ -1,363 +0,0 @@ -# 接口 2:C 端生发 — 技术实现方案(发际线预览 + 生发图) - -> **当前状态(2026-06-15):两步均已实现。** ① 发际线曲线叠加预览图(§1~§9); -> ② 生发后图片(§10,ComfyUI/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 indexMap(build_extended_obj 用,本期渲染不需要) -├── _mediapipe_subprocess.py# WSL 下子进程跑 MediaPipe 的兜底(可选) -├── models/ -│ ├── face_landmarker.task # MediaPipe 模型(3.7MB,已复制) -│ └── face-parsing/ # SegFormer 权重(离线,已下载,见 OFFLINE_ASSETS.md) -│ ├── config.json -│ ├── preprocessor_config.json -│ └── model.safetensors -├── mesh/ -│ ├── face_ext.obj # 502 点扩展 mesh + UV + 三角面(渲染器读这个) -│ └── face.obj # 原始 468 点 mesh(参考/重生成用) -└── reference/ - ├── texture0.png # head3d 原 5 弧线贴图(核对 UV 用) - └── uv_template.png # UV 布局参考 -``` - -发际线类型贴图在仓库根目录 `hairline_texture/`(用户提供,9 张)。 - -### 2.1 移植后需要修改的集成点 - -1. **`face_landmarks.py` 的 `DEFAULT_MODEL_PATH`**:原逻辑是 `dirname(dirname(__file__))/models/...`(head3d 里模块在 `python/` 子目录)。现在模块在 `hairline/` 根,该路径会指向 `hair/models/`,而模型在 `hairline/models/`。**改为** `os.path.join(os.path.dirname(__file__), "models", "face_landmarker.task")`。 -2. **`face_parsing.py` 离线加载**:`C.HF_FACE_PARSER_MODEL` 当前是 HF 在线 id `"jonathandinu/face-parsing"`。内网/离线改为本地目录:把 `constants.py` 的 `HF_FACE_PARSER_MODEL` 指向 `hairline/models/face-parsing` 的绝对路径(`from_pretrained` 支持本地目录);或设 `HF_HUB_OFFLINE=1`。 -3. **相对导入**:模块用 `from . import constants`,已加 `hairline/__init__.py`,作为包导入即可(`from hairline.extract_hairline import run`)。 -4. **GPU**:`FaceParser(device="cuda")`,worker 有 GPU。 - ---- - -## 3. 算法管线(整体) - -``` -输入: 用户正面照 + gender - │ - ▼ -[A] head3d 管线(复用 hairline.extract_hairline 的步骤) - - MediaPipe 468 点(face_landmarker.task) - - SegFormer 人脸分割 → parse_map - - 17 锚点射线检测发际线 → 17 个 2D 点 → 平滑 - - 矢状-arc 提升 → 502 点(归一化 x,y,z) - │ 失败处理:无人脸→1001 - ▼ -[B] 投影到图像像素 - - 502 点的 (x,y) × (W,H) → 502 个 2D 图像坐标 - - 读 face_ext.obj:UV(502) + 扩展三角面(涉及顶点 ≥468 的 64 个三角形) - ▼ -[C] 逐张贴图渲染(新写的服务端渲染器,本方案核心) - for 每个该性别的发际线贴图 t: - - 对每个扩展三角形:src=UV→贴图像素, dst=投影 2D 坐标 → cv2 仿射 warp - - 累积成一张 RGBA 曲线层(贴图 alpha 控制曲线/透明) - - 把曲线层 alpha 合成到原照片上 → 预览图 - ▼ -[D] 输出 - results[] = N 个 {image(预览图), hairline_type(key), order=1..N} - worker 侧每张图以 base64 返回(见 §6) -``` - ---- - -## 4. 渲染器(新代码,本期重点)★ - -head3d 把贴图渲染到照片是**浏览器 Three.js** 做的(`/preview` ortho overlay),**没有服务端实现**。本期新写一个 **OpenCV 逐三角形仿射 warp** 渲染器,无需 OpenGL 离屏上下文,确定性好、部署简单。 - -### 4.1 原理 - -face_ext.obj 的 502 顶点里: -- `[0..467]` MediaPipe 点,其中 17 个 `MP_TOP_ANCHORS` 是发际线 ribbon 的**下边沿**; -- `[468..484]` 中间行、`[485..501]` 发际线行,是 ribbon 的中、上两行。 - -这 34 个新点 + 17 个锚点之间连成 64 个三角形(ribbon),它们的 UV 落在贴图**顶部条带**(V_raw≈0.67..0.94,正是发际线曲线所在)。所以只要把**这 64 个三角形**按 UV→图像坐标 warp,就能把贴图里的发际线曲线贴到照片的额头/发际线区域。 - -### 4.2 步骤 - -```python -# 伪代码 -def render_hairline_overlay(photo_bgr, points502_norm, ext_faces, uv502, texture_rgba): - H, W = photo_bgr.shape[:2] - # 502 点投影到图像像素 - img_xy = points502_norm[:, :2] * [W, H] # (502, 2) - TW, TH = texture_rgba.shape[1], texture_rgba.shape[0] # 512, 512 - - overlay = np.zeros((H, W, 4), np.float32) # 累积曲线层 RGBA - for (i, j, k) in ext_faces: # 仅扩展三角形(顶点含 ≥468) - dst = img_xy[[i, j, k]].astype(np.float32) # 图像坐标 - # UV → 贴图像素。注意 flipY:贴图 y = (1 - v_raw) * TH - src = np.array([[uv502[v][0]*TW, (1-uv502[v][1])*TH] for v in (i,j,k)], np.float32) - M = cv2.getAffineTransform(src, dst) - warped = cv2.warpAffine(texture_rgba, M, (W, H), flags=cv2.INTER_LINEAR, - borderMode=cv2.BORDER_CONSTANT, borderValue=(0,0,0,0)) - # 三角形掩码,避免覆盖整张 warp 结果 - tri_mask = np.zeros((H, W), np.uint8) - cv2.fillConvexPoly(tri_mask, dst.astype(np.int32), 255) - sel = tri_mask > 0 - overlay[sel] = warped[sel] # 逐三角形写入(相邻共享边,覆盖等价) - - # alpha 合成到原照片 - a = overlay[:, :, 3:4] / 255.0 - out = photo_bgr.astype(np.float32) - out = out * (1 - a) + overlay[:, :, :3][..., ::-1] * a # RGBA→BGR 注意通道序 - return out.astype(np.uint8) -``` - -### 4.3 注意点 - -- **通道序**:贴图是 RGBA,照片 OpenCV 是 BGR,合成时注意 R/B 调换。 -- **flipY**:face_ext.obj 的 UV 是 V_raw(V=1 对应贴图顶部),转贴图像素 y 要 `(1 - v)`,与 head3d Three.js `texture.flipY=true` 一致。 -- **只 warp 扩展三角形**:从 face_ext.obj 筛出顶点索引含 ≥468 的面(约 64 个)。不要 warp 整脸。 -- **抗锯齿/接缝**:逐三角形 `fillConvexPoly` 掩码可能在共享边留 1px 缝。可对 `tri_mask` 略膨胀,或最后对 overlay alpha 做轻微羽化。先跑通看效果再优化。 -- **裁剪到额头**:曲线层只在 ribbon 区域有内容(贴图其余透明),天然不会画到脸下半部。 - ---- - -## 5. 依赖 - -worker 已有(接口 1):`opencv-python`、`numpy`、`Pillow`、torch(CUDA)。接口 2 **新增**: - -``` -mediapipe>=0.10 # Tasks Vision FaceLandmarker(注意与接口1的 solutions API 可共存) -transformers>=4.40 # SegFormer 人脸分割 -# torch/torchvision 已由接口1引入(worker CUDA 版) -``` - -> ⚠️ **两套人脸分割模型**:接口 1 用 BiSeNet(`79999_iter.pth`),接口 2 用 head3d 的 SegFormer(`jonathandinu/face-parsing`)。两者并存,显存/内存够(worker 32G+GPU)。后续可评估是否统一为一个分割模型,本期先各用各的,**不强行合并**。 -> -> ⚠️ **MediaPipe API 差异**:接口 1 用 `mp.solutions.face_mesh`(468 点 + 虹膜 refine),接口 2 用 `mp.tasks.vision.FaceLandmarker`(读 `.task` 文件)。同一个 mediapipe 包都支持,但版本需兼容两者(建议先用一个版本把两接口都跑通)。 - ---- - -## 6. worker 集成(接口 2 handler) - -在 `app.py` 替换 `/api/v1/hair/grow` 的 Mock: - -``` -1. 解析图片(三选一)+ 读 gender(必填,male/female;非法→1004 或 1008 参数错误) -2. 校验(大小/解码/分辨率,同接口1) -3. 跑 hairline.extract_hairline 的步骤拿 502 点(无人脸→1001) -4. 按 gender 取贴图集合(启动时扫描 hairline_texture/ 建映射) -5. for 每张贴图: render_hairline_overlay → PNG -6. results[] = [{image_base64, hairline_type, order}], 逐张 base64 -7. return ok({"results": results}) -``` - -- **拆分架构**:worker 返回 `results[].image_base64`,**不落盘不拼 URL**。网关把每个 `image_base64` 落盘改写成 `image_url`(架构文档 §9 的映射表需支持**数组里的图片字段** `results[].image`)。 -- 模型单例:`FaceLandmarker` 和 `FaceParser` 在模块加载时初始化一次,避免每请求重建。face_ext.obj 的 UV/faces 也只解析一次缓存。 - ---- - -## 7. 离线资产(内网部署) - -接口 2 新增需要随项目带入内网的模型(已下载,登记到 `OFFLINE_ASSETS.md`): -- `hairline/models/face_landmarker.task`(MediaPipe,~3.7MB) -- `hairline/models/face-parsing/`(SegFormer:config + preprocessor + model.safetensors) - -> SegFormer 加载方式改本地路径后,内网无需联网(见 §2.1)。 - ---- - -## 8. 开发步骤与验证(agent 执行) - -| 阶段 | 内容 | 验证 | -|------|------|------| -| **M0 跑通管线** | 修好集成点(§2.1),用一张人像跑 `hairline.extract_hairline.run()` 得 502 点 JSON | 502 点、valid_hairline 有 true | -| **M1 解析 mesh** | 读 face_ext.obj 拿 UV + 扩展三角面(顶点≥468 的面),缓存 | 打印扩展面数(~64)、502 个 UV | -| **M2 渲染器** | 实现 `render_hairline_overlay`,对 1 张贴图渲染 | 输出预览图,**目视**:发际线曲线贴在额头正确位置、跟随脸 | -| **M3 全量 + 性别** | 扫描 `hairline_texture/` 建 gender→贴图映射,按性别渲染 N 张 | female 出 5 张、male 出 4 张,hairline_type 对 | -| **M4 接 app.py** | handler + gender 必填 + base64 返回 | curl 验证 results 数量/字段;无人脸→1001;缺 gender→报错 | -| **M5 网关映射** | 网关支持 `results[].image_base64`→`image_url`(网关任务书侧) | 端到端经网关返回 image_url,公网可访问 | - -**M2 是关键里程碑**:渲染器对齐效果好不好,决定整个接口可用性,先用几张测试人像目视确认贴合。 - ---- - -## 9. 风险与待办 - -1. **新贴图 UV 是否与 texture0 完全一致**:本方案假设 9 张贴图沿用 head3d 的顶部条带 UV 布局(已肉眼确认曲线在顶部)。M2 渲染若位置偏移,核对贴图内容所在的 V 区间与 `UV_MIDDLE_DV/UV_HAIRLINE_DV`。 -2. **接缝/锯齿**:逐三角形 warp 的共享边接缝,M2 跑通后按 §4.3 优化。 -3. **歪头/非正面**:head3d 矢状-arc 假设近正脸,大角度发际线贴合差。可复用接口 1 的 solvePnP 做前置姿态校验(可选)。 -4. **秃头/高发际线/刘海**:SegFormer 找不到头发时射线回退几何外推,曲线可能偏高;valid_hairline 标记可用于提示。 -5. **排序**:本期 order=1..N。后续排序需定义依据(脸型/额型匹配度)。 -6. **真正的生发(文生图)**:**已实现**(§10)——把划线图 + 遮罩送 ComfyUI(add_hair.json, Flux-2) - 出生发图,`results[].grown_image_base64` 返回。后续可优化:发际线目标位置下移以增强"植发填充"效果、 - 同步 N 张较慢可改异步。 - ---- - -## 10. 第二步:生发图生成(ComfyUI + Flux inpaint)★ 新增 - -> 在「发际线预览」基础上,**新增真实生发后图片**:把发际线划线 + 遮罩送入本机 -> ComfyUI(Flux-2 Klein 9b,端口 **8182**)跑 `add_hair.json` 工作流,得到「植发 3 -> 个月」效果图。**worker 不跑 Flux**,只做图像准备 + 调 ComfyUI HTTP API + 取回结果。 - -### 10.1 需求与决策(已与需求方确认) - -| 项 | 决策 | -|----|------| -| 生成数量 | **一次请求生成该性别全部 N 种**(female 5 / male 4),与预览一一对应 | -| 遮罩区域 | **头发区域 ∪ 新发际线以下** —— SegFormer 头部(头发)区域,下边界拓到新发际线曲线 | -| 返回方式 | **同步阻塞**到 ComfyUI 出图再返回(N 张串行,单请求耗时可达数分钟,网关需调大超时) | -| ComfyUI 接入 | 标准 HTTP API(`/upload/image` + `/prompt` + `/history` + `/view`),8182 无鉴权,自定义节点已装齐,`noise_seed` 每次随机 | - -### 10.2 `add_hair.json` 工作流解读 - -Flux-2 Klein 9b 的参考式局部重绘(denoise=1 + ReferenceLatent): -- **节点 26 `LoadImage`** 是唯一外部输入,同时给出 **图像**([0])和 **遮罩**([1],从 PNG 的 - alpha 通道取,ComfyUI 约定 `mask = 1 − alpha`,即 **alpha 透明处 = 要重绘的区域**)。 -- 提示词(节点 60):保留原图一切,**先清除画面内所有黑色标注划线**,仅在划线范围内生成 - 「植发 3 个月」头发,发际线边界刚好止于划线处,与原生发自然衔接。 -- 节点 32/37/39/44 做遮罩填洞、缩放到 1024、ImageAndMaskPreview 组装 → VAEEncode 参考。 -- 节点 10 VAEDecode → 节点 62 ColorMatch(与原图调色一致)→ 节点 17 SaveImage = 生发图。 - -> **关键**:worker 要做的就是**程序化复现「手绘 painted-masked」的输入**——给节点 26 一张 -> RGBA:**RGB = 画了黑色发际线划线的照片,alpha = 遮罩(重绘区透明)**。工作流其余不动。 - -### 10.3 遮罩算法(参考 `/home/xsl/headmark`,用黑贴图简化) - -headmark(发际线蒙板工具)的 5 步法:① MediaPipe 取**额头上半区域** → ② **整个头部分割** → -③ 两者**交集** = ROI → ④ 在 ROI 内**找发际线**(手绘划线)→ ⑤ 发际线 + 头型**围成闭合区域**填充 = 遮罩。 - -> **我们的简化**:发际线不是手绘、需要检测的;而是用 `hairline_texture_black/` 的黑曲线 -> **程序化渲染**出来——位置已知,**省掉 headmark 第 4 步的检测**,直接拿渲染出的曲线当边界。 - -``` -已有:502 点、SegFormer parse_map -build_inpaint_mask(photo, parse_map, landmarks, hairline_texture_black/t): - [1] upper_region = MediaPipe 额头边界关键点 - [21,68,104,69,108,151,337,299,333,298,251] 连线,向上+两侧补到图像边缘,填充 - (= headmark step1:发际线以上的"上部区域") - [2] head_mask = SegFormer 头部轮廓(hair ∪ skin ∪ 其余面部类,排除 bg/neck/cloth) - (= headmark step2;headmark 用 head-segmentation 包/Selfie,本项目复用已加载的 SegFormer) - [3] roi = upper_region ∩ head_mask (= headmark step3) - [4] 划线图 marked + curve_mask = render(photo, 502点, 黑贴图 t) # 烧黑线 + 得到曲线像素 - [5] mask = roi 中"在发际线曲线以上(更小 y)"的部分 → 闭运算去洞 + 取最大连通域填充 + 轻羽化 - (= headmark step5:发际线曲线 + ROI 上边界围成的闭合区域) - return marked(划线图), mask -``` - -for 每种发际线贴图 t(该性别全部): -- `marked, mask = build_inpaint_mask(...)` -- `comfy_input = RGBA(rgb=marked, alpha=255*(1−mask))` # **透明=重绘区**,对齐 ComfyUI `mask=1−alpha` -- `grown = comfyui_run(comfy_input)`(§10.4) -- `results[t] = { preview(白线预览,已有), grown(生发图,新增) }` - -- `hairline_texture_black/`:与 `hairline_texture/` 同 9 张曲线,但**黑色**,烧划线 + 当遮罩下边界。 -- **头部分割来源**:先复用已加载的 SegFormer(零新增依赖);若头型轮廓不够干净,可改用 - headmark 同款 `head-segmentation` 包(子进程,避免与 MediaPipe GPU 冲突)。 -- 遮罩边界精度 **M5 必须 dump 可视化核验**(划线图 / ROI / 最终 mask 三张叠图)。 - -### 10.4 ComfyUI 客户端(`hairline/comfyui.py`,新增) - -``` -COMFYUI_URL = env COMFYUI_URL (默认 http://127.0.0.1:8182) -WORKFLOW = add_hair.json(启动时加载一次) -run(comfy_input_png_bytes): - 1. POST /upload/image (multipart) → {name, subfolder, type:"input"} - 2. wf = deepcopy(WORKFLOW); wf["26"]["inputs"]["image"] = name - wf["6"]["inputs"]["noise_seed"] = 随机 - 3. POST /prompt {prompt: wf, client_id} → prompt_id - 4. 轮询 GET /history/{prompt_id} 直到完成(带超时) - 5. node "17".images[0] → GET /view?filename&subfolder&type=output → PNG bytes - 返回 PNG bytes -``` - -### 10.5 接口契约变更(同步更新 `接口文档.md`) - -`/api/v1/hair/grow` 的 `results[]` 每项**新增生发图字段**(worker 返回 base64,网关落盘改 URL): - -| 字段 | 说明 | -|------|------| -| `image_base64` | (已有)发际线**预览图**(白线叠加) | -| `grown_image_base64` | (新增)**生发后图片**(ComfyUI 出图) | -| `hairline_type` / `order` | 同前 | - -> ⚠️ **同步 + N 张 Flux** → 单请求很慢。网关/前端超时要放大;worker 自身并发=1。 -> 失败处理:某张 ComfyUI 失败时该项 `grown_image_base64` 置空并标记,不整请求失败(待定,实现期确认)。 - -### 10.6 开发步骤(M5+) - -| 阶段 | 内容 | 验证 | -|------|------|------| -| **M5 遮罩** | `build_inpaint_mask` + 黑线渲染 + 合成 RGBA | 目视:划线图正确、遮罩=头发∪发际线以下,透明区对 | -| **M6 ComfyUI 客户端** | `comfyui.py` 跑通一张(8182 起服务后) | 上传→prompt→取回 PNG,得到生发图 | -| **M7 接 service/app** | generate 时每种附带 grown,handler 返回新字段 | curl:results 含 grown_image_base64(合法 PNG) | -| **M8 文档/测试** | 更新接口文档;mock ComfyUI 的单测 + 真机冒烟 | pytest 绿;真机端到端出生发图 | - -### 10.7 新增风险 - -1. **耗时**:同步 N 张 Flux,单请求数分钟级;需评估是否后续改异步/队列。 -2. **ComfyUI 依赖外部进程**:8182 未起/模型未加载/节点缺失 → 该接口失败;worker `/health` 不体现 ComfyUI 状态(可加可选探测)。 -3. **遮罩精度**:直接决定生发位置与自然度;M5 必须可视化核验,必要时拿手绘样本标定。 -4. **GPU 共享**:ComfyUI 与接口1/2 的 CPU 推理同机;显存/算力调度需观察(ComfyUI 自带 torch,支持 5090)。 - ---- - -> **文档版本**: v1.1 | **创建日期**: 2026-06-14(v1.1:新增 §10 生发图生成 ComfyUI 管线) -> 算法来源: head3d(502 点 mesh + UV)+ Flux-2 Klein 9b(ComfyUI add_hair.json)| 运行位置: worker(GPU) + 本机 ComfyUI(8182) -> **产出**: ① 发际线曲线叠加预览图 ② 生发后图片(植发 3 个月效果) diff --git a/docs/接口3-B端生发-技术实现方案.md b/docs/接口3-B端生发-技术实现方案.md deleted file mode 100644 index 9aac56c..0000000 --- a/docs/接口3-B端生发-技术实现方案.md +++ /dev/null @@ -1,91 +0,0 @@ -# 接口 3:B 端生发 — 技术实现方案(马克笔发际线检测 + 生发) - -> 在 **高性能 worker(GPU 机)** 实现,与接口 1/2 同机。对外经网关代理。 -> B 端:医生在患者额头**用马克笔画出规划的发际线**,拍照上传。系统**检测这条手绘线**, -> 据此生成生发图。检测算法移植自 `/home/xsl/headmark` 的调研结论(黑帽 + Dijkstra)。 - ---- - -## 0. 契约(对齐 `接口文档.md` 接口3,不变) - -`POST /api/v1/hair/grow-b` - -| 输入 | 说明 | -|------|------| -| `marked_image_*` | 已用马克笔标注发际线的图,三选一,必填。**只需这一张**(划线图即用户照片+手绘线,不需要原图) | - -| 输出 data | 决策 | -|-----------|------| -| `hair_growth_image_url` | **生发后图片**(ComfyUI,worker 返回 `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+1;ROI 外设 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 - 本来就是"画了线的照片",提示词会清除黑线再生发)。**不需要原图、不重画线**。 -- 合成 RGBA:RGB=marked 划线图,alpha=255−mask(透明=重绘区)。复用 `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 | curl:grown 合法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) diff --git a/docs/接口4-用户特征-网关实现方案.md b/docs/接口4-用户特征-网关实现方案.md deleted file mode 100644 index 3957cd0..0000000 --- a/docs/接口4-用户特征-网关实现方案.md +++ /dev/null @@ -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 | 文件 > 1MB(file/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 0;data.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` diff --git a/docs/接口文档.md b/docs/接口文档.md index 6927b73..cebcca7 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -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**;本接口已改为必填入参,不再自动判别性别。 --- diff --git a/docs/系统架构-网关与高性能后端.md b/docs/系统架构-网关与高性能后端.md deleted file mode 100644 index 0704efe..0000000 --- a/docs/系统架构-网关与高性能后端.md +++ /dev/null @@ -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: `。 -- **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. 错误处理 - -| 场景 | 返回 | 由谁 | -|------|------|------| -| 业务错误(无人脸/分辨率/非正面/超大/格式…) | 1001–1008(原样) | worker 产生,网关透传 | -| **无可用 worker / 全忙排队超时** | **1007 系统错误** | 网关 | -| worker 转发失败(连接/超时/5xx/401) | 按配置换 worker 重试;重试耗尽 → 1007 | 网关 | - -- 复用 **1007**(系统错误)表示「基础设施层不可用」,**不新增错误码**,接口文档不动。 -- 局限:调用方无法从错误码区分"算法失败"与"后端全挂"(都是 1007)。如需区分,可在 `message` 文案上体现(如"后端服务暂不可用,请稍后重试"),但 `code` 保持 1007。 - ---- - -## 11. worker 侧相对单机方案的变化 - -worker 跑的就是「接口1 技术实现方案」里描述的完整逻辑,但有几处因拆分/硬件而变: - -1. **GPU 加速**:worker 有独立 GPU,BiSeNet 改用 **CUDA** 推理(torch GPU 版),比 CPU 快很多;MediaPipe 仍 CPU(Python 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 管理网关进程。 - -### worker(GPU 机,: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 也套一层 TLS(worker 前置 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 diff --git a/docs/网关-开发任务书.md b/docs/网关-开发任务书.md deleted file mode 100644 index f505438..0000000 --- a/docs/网关-开发任务书.md +++ /dev/null @@ -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: ` 头。 - - `request_timeout_seconds` 超时;连接失败/超时/5xx/401 视为该 worker 异常 → 标记不健康,按 `max_retries` 换 worker 重试;耗尽 → 1007。 - - worker 返回的 1001–1008 业务响应**原样透传**给客户端(不要改 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/.png # 200 -``` -- 对外响应字段/URL 与 `docs/接口文档.md` **逐一一致**。 -- 响应里**不应**出现 `*_base64`(已被网关消化)。 -- 直接打 worker 不带 token → 401(worker 侧行为);经网关正常。 - -**完成标准**:5 接口代理通;base64 落盘改 URL 正确;对外契约零变化;失败重试与 1007 生效。 - ---- - -## 6. 阶段四:静态托管 + 清理 - -**开发步骤** -1. 网关 `app.mount("/static", StaticFiles(directory="static"), ...)` 托管落盘的标注图。 -2. 加**定期清理**:`static/annotations/` 会持续增长,按时间(如保留 N 天)或容量清理。可用进程内定时任务或外部 cron(任选,记录方案)。 - -**验证** -- 生成的图能经 `https://hair.xiangsilian.com/static/annotations/.png` 访问(200)。 -- 清理任务按规则删除过期文件,不误删近期文件。 - -**完成标准**:静态图可公网访问;清理任务可控。 - ---- - -## 7. 阶段五:部署 - -**开发步骤** -1. nginx:443 → 网关进程(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;无后端→1007;base64→落盘→URL diff --git a/gateway/app.py b/gateway/app.py index 7da2d43..076365b 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -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, }) diff --git a/requirements.txt b/requirements.txt index 3969a90..bd27046 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 Ark;API Key 走配置不入 git +# 接口4:用户特征(火山方舟 豆包视觉模型)—— 已迁到**网关**实现,worker 不需要。 +# 网关机装:volcengine-python-sdk[ark](from volcenginesdkarkruntime import Ark);API Key 走配置不入 git # 测试 pytest==8.3.3 diff --git a/tests/test_api.py b/tests/test_api.py index 933af15..ff11a9d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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):