feat(接口99): 人脸特殊点检测 + 网关转发路由;文档同步至当前代码
- worker: POST /api/v1/face/special-points,60 个固定编号关键点(6组) + 5 个发际线特殊点(沿指定关键点方向射线步进,与 BiSeNet 头发 mask 首次相交; 双射线取中点,left_left=mid(333→332, 298→284) 与右侧镜像) - 网关: 同路径盲转发路由(调用频率低,维持转发不占网关依赖; 响应无 *_base64 字段,改写逻辑天然跳过) - 测试页 static/test_interface99.html:分组着色小点 + 点ID标注(可开关) + 发际线点大圆标签 + JSON 展示 - 文档: CLAUDE.md 重写至当前架构;实现说明/接口文档补接口 9~12/99 契约; 网关待改动补 §7(9/10已加) §8(11/12待加) §9(接口99部署步骤)
This commit is contained in:
@@ -4,80 +4,100 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Overview
|
||||
|
||||
"旷视五接口" — a FastAPI facial analysis API service providing 5 endpoints for face measurement, hair growth simulation (C-end and B-end), user feature extraction, and hairline PNG generation. Currently a **Mock v1**: all endpoints return hardcoded sample data; real ML logic is pending.
|
||||
旷视接口服务 — FastAPI 人像分析/生发服务,**一个仓库、两种部署角色**:
|
||||
|
||||
- **Base URL (prod)**: `https://hair.xiangsilian.com`
|
||||
- **Swagger docs**: `https://hair.xiangsilian.com/docs`
|
||||
- **Internal**: uvicorn on `127.0.0.1:8000` behind nginx reverse proxy
|
||||
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真实算法(本地模型 + ComfyUI + change_hair),监听 `0.0.0.0:8187`。**本机(GPU 机)即 worker 角色**,不跑网关。
|
||||
- **网关**(`gateway/`):外网机器上的薄反向代理,负责鉴权/健康检查/派发,并把 worker 返回的 `*_base64` 落盘改写为 `*_url`。接口4(豆包)在网关本机实现,不转发 worker。
|
||||
|
||||
> 历史包袱:`app.py` 的 FastAPI `description` 和 `docs/接口文档.md` 部分示例仍写「Mock 第一版」字样,实际全部接口已是真实算法实现。**架构以 `docs/实现说明.md` 为准,契约以 `docs/接口文档.md` 为准。**
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development server
|
||||
./venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 --reload
|
||||
# ---- worker(本机)----
|
||||
./run_worker.sh # 后台启动(0.0.0.0:8187,日志 worker.log)
|
||||
FOREGROUND=1 ./run_worker.sh # 前台调试
|
||||
./stop_worker.sh # 停止
|
||||
sudo systemctl restart hair-worker # systemd 方式(生产)
|
||||
journalctl -u hair-worker -f
|
||||
|
||||
# Or via the start script
|
||||
./start.sh
|
||||
# ---- 依赖服务(worker 之前要先起)----
|
||||
sudo systemctl start comfyui # ComfyUI 127.0.0.1:8188(Flux-2 生图)
|
||||
cd /home/ubuntu/change_hair && bash start_all.sh start # 换发型三件套 8801/32678/57860
|
||||
|
||||
# Production (systemd)
|
||||
sudo systemctl restart hair
|
||||
sudo systemctl status hair
|
||||
journalctl -u hair -f
|
||||
|
||||
# Install dependencies (uses Tencent Cloud PyPI mirror)
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
# ---- 测试 ----
|
||||
./venv/bin/pytest tests/ # 单元测试(conftest 会准备 fixtures)
|
||||
./venv/bin/python -m hairline.comfyui tests/output/comfy_input.png # 手动跑一次生发工作流
|
||||
```
|
||||
|
||||
依赖装在 `./venv`(Python 3.10,torch 2.7.1+cu128,RTX 4090 CUDA 可用)。`requirements.txt` 里的 torch 2.2.2/5090 注释是旧机器的记录,本机 venv 实际是 cu128 版。
|
||||
|
||||
## Architecture
|
||||
|
||||
**Single-file app** — `app.py` (~555 lines) contains the entire server: FastAPI app creation, Pydantic models, all 5 endpoint handlers, and health check routes. There are no separate routers, services, or middleware modules.
|
||||
### 接口一览(worker 侧路由,`app.py`)
|
||||
|
||||
### Endpoints
|
||||
| 接口 | 路径 | 实现位置 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| 1 | `POST /api/v1/face/measure` | `face_analysis/` | 四庭七眼测量 + 标注 PNG |
|
||||
| 6 | `POST /api/v1/face/measure-v2` | 同上(`variant="v6"`) | 接口1 去顶庭变体 |
|
||||
| 2 | `POST /api/v1/hair/grow` | `hairline/service.py` | C端生发。**female 1..5** 走 change_hair swapHair + Flux-2 整帧重绘(接口12 final 管线);**female 6/7 与 male 全部**走 ComfyUI `add_hair.json` inpaint |
|
||||
| 7 | `POST /api/v1/hair/grow-v2`(hidden/deprecated) | 同上 | 同接口2,用 `add_hair2.json` 工作流 |
|
||||
| 3 | `POST /api/v1/hair/grow-b` | `hairline/marker_detect.py` | B端马克笔划线生发(黑帽响应 + Dijkstra) |
|
||||
| 4 | `POST /api/v1/face/features` | worker 有实现;生产走**网关本机**(豆包) | 用户特征 |
|
||||
| 5 | `POST /api/v1/hairline/generate` | `hairline/service.py` | 发际线 PNG 三档(middle/high/low)+ 生发 |
|
||||
| 9 | `POST /api/v1/head/mask` | `face_analysis/head_mask.py` | 头发遮罩 + 分步可视化(BiSeNet/SegFormer 双路) |
|
||||
| 10 | `POST /api/v1/head/band` | `face_analysis/head_band.py` | 头部外缘膨胀带遮罩 |
|
||||
| 11 | `POST /api/v1/hairline/grow` | `face_analysis/hairline_grow.py` | 发际线生发(接口9 遮罩 + change_hair + 接缝融合),不含重绘 |
|
||||
| 12 | `POST /api/v1/hairline/grow_v2` | 同上 | 接口11 final + 发际线重绘带 → Flux-2 保色重绘 |
|
||||
| 12f | `POST /api/v1/hairline/grow_v2_final` | 同上(`_V2_FINAL_DEFAULTS`) | 精简版:仅图片 + hairline_id,取整帧重绘 |
|
||||
| 12f2 | `POST /api/v1/hairline/grow_v2_final_v2` | 同上 | 同上,取 B 局部加发+全脸美颜 |
|
||||
| 99 | `POST /api/v1/face/special-points` | `face_analysis/special_points.py` | 固定编号关键点 + 发际线特殊点(射线求交头发 mask)像素坐标 |
|
||||
| 代理 | `POST /api/swapHair/v1`、`/hairColor/v2` | httpx 转发 127.0.0.1:8801 | 解决测试页 CORS |
|
||||
| 工具 | `POST /api/v1/redraw` | `hairline/redraw.py` | 直接 ComfyUI 重绘(图+遮罩+prompt) |
|
||||
| 调试 | `POST /api/v1/debug/grow-timing`、`GET /api/v1/debug/hairline_log` | — | 免鉴权,分步计时/下载日志 |
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| POST | `/api/v1/face/measure` | 四庭七眼 measurement + annotated PNG |
|
||||
| POST | `/api/v1/hair/grow` | C-end hair growth (user-facing) |
|
||||
| POST | `/api/v1/hair/grow-b` | B-end hair growth (doctor/operator, with marked image) |
|
||||
| POST | `/api/v1/face/features` | User facial feature analysis |
|
||||
| POST | `/api/v1/hairline/generate` | Hairline PNG generation |
|
||||
| GET | `/health` | Health check (excluded from OpenAPI schema) |
|
||||
| GET | `/` | Service info (excluded from OpenAPI schema) |
|
||||
配套测试页在 `static/test_interface{1..12}*.html`,经 `/static` 挂载。
|
||||
|
||||
### Image input convention (all endpoints)
|
||||
### 关键模式
|
||||
|
||||
Every endpoint accepts images via **exactly one** of three mutually exclusive fields:
|
||||
- `image_file` — multipart file upload (≤ 1 MB)
|
||||
- `image_url` — HTTP/HTTPS URL string
|
||||
- `image_base64` — base64 string with `data:image/...;base64,` prefix
|
||||
- **统一信封**:`ok(data)` / `err(code, message)` 构造 `{code, message, request_id, data}`;业务错误 HTTP 一律 200。错误码:1001 无人像 / 1002 分辨率低 / 1003 非正面 / 1004 gender 非法 / 1005 多人 / 1006 >1MB / 1007 参数或处理失败 / 1008 格式不支持。
|
||||
- **图片三选一**:每个接口 `image_file`(multipart) / `image_url` / `image_base64`(带 `data:` 前缀)严格互斥,由 `resolve_image_bytes()` 统一处理(0 个或多个 → 1007)。接口3 是 `marked_image_*`。
|
||||
- **阻塞算法进线程池**:所有推理 handler 用 `run_in_threadpool(...)`,避免卡事件循环。
|
||||
- **内网鉴权**:HTTP 中间件校验 `/api/*` 的 `X-Internal-Token` 头,密码来自 `worker_config.json` 的 `accept_passwords`(或环境变量 `WORKER_ACCEPT_PASSWORDS`)。豁免前缀见 `_AUTH_EXEMPT`(health/docs/static/debug/redraw/swapHair/hairColor)。
|
||||
- **发型序号映射**:`hairline/service.py` 的 `_HAIRSTYLE_ORDER` 显式定义 1..7 顺序(不靠文件名字母序);female 1..5 → change_hair `chang_*` 映射在 `_FEMALE_KEY_TO_CHANG`。
|
||||
- **接口12 固化参数**:`_V2_FINAL_DEFAULTS`(app.py)是生产默认值(push=0.8cm / two_stage / color_match=关 等),接口2 female 重绘也复用它。
|
||||
|
||||
Endpoint 3 (B-end) has **two** sets of image params: `marked_image_*` (the marked-up image) and `original_image_*` (the original photo). Each set follows the same three-way exclusive rule independently.
|
||||
### 外部依赖(worker 视角)
|
||||
|
||||
### Unified response structure
|
||||
| 依赖 | 地址 | 用途 | 配置 |
|
||||
|------|------|------|------|
|
||||
| ComfyUI | `http://127.0.0.1:8188`(`COMFYUI_URL`) | Flux-2 生图/重绘,走 HTTP API(`hairline/comfyui.py`) | Basic Auth `admin`,密码:`COMFYUI_PASSWORD` → `worker_config.json.comfyui_password` → `password.txt` |
|
||||
| change_hair | `http://127.0.0.1:8801`(`SWAP_HAIR_URL`/`HAIR_GROW_URL`/`CHANGE_HAIR_BASE`) | 换发型(SD WebUI + LoRA)、hairGrow | 服务目录 `/home/ubuntu/change_hair`,`start_all.sh` 启停 |
|
||||
|
||||
```json
|
||||
{"code": 0, "message": "success", "request_id": "mock-request-id", "data": {...}}
|
||||
```
|
||||
ComfyUI 工作流 JSON 在仓库根:`add_hair.json`(接口2/3/5 生发)、`add_hair2.json`(接口7)、`0716add-hair-api.json`(接口12/2女 重绘)、`hair_repaint.json`。节点约定:**26** 输入图 / **6** seed / **60** 提示词 / **16** unet(.gguf 自动切 UnetLoaderGGUF)/ **61** CLIP(4b↔9b 自动配套)。
|
||||
|
||||
Error codes: 1001–1008 (see `app.py` docstring or `docs/接口文档.md`).
|
||||
### 性能/显存相关的坑
|
||||
|
||||
### Key code patterns
|
||||
- 三个生发接口共用**同一提示词字符串**以命中 ComfyUI 的 CLIP 文本条件缓存——prompt 变了会触发 Flux(7.7G)+qwen CLIP(3.9G) 互相挤显存重载(每次 +4s)。
|
||||
- 接口2 女性重绘管线默认把输入长边压到 `REDRAW_MAX_SIDE`(env,默认 1024)再放大回原尺寸;`redraw_max_side=0` 表示不缩图。
|
||||
- `comfyui.run(front=True)` 可插队(接口2 对时延敏感);超时 `COMFY_TIMEOUT`(默认 600s)。
|
||||
|
||||
- `ok(data)` / `err(code, msg)` — helper functions at module level that build the standard response envelope
|
||||
- `ImageJsonBody` — Pydantic model for JSON-based image params (URL/base64); form-upload endpoints use FastAPI `Form()` / `File()` parameters directly
|
||||
- `SAMPLE_IMAGE_URL` — `https://hair.xiangsilian.com/static/sample.jpg`, returned as the image URL in all mock responses
|
||||
- Static files (`/static/*`) are mounted via `app.mount("/static", StaticFiles(directory="static"), name="static")`
|
||||
- All 5 POST endpoints use `multipart/form-data` with both `File` and `Form` fields — there are no JSON-body POST endpoints despite `ImageJsonBody` being defined (it exists for the OpenAPI schema but the actual handlers use `Form()` for all params)
|
||||
## Deployment(本机 = gpu_worker)
|
||||
|
||||
### Deployment
|
||||
systemd 单元(仓库根有副本,实装在 `/etc/systemd/system/`):
|
||||
|
||||
- **Process manager**: systemd (`hair.service` → `ExecStart` runs uvicorn directly from venv)
|
||||
- **Reverse proxy**: nginx (`nginx/hair.conf`) proxies port 80 → `127.0.0.1:8000` for `hair.xiangsilian.com`
|
||||
- **Python**: venv at `./venv`, dependencies in `requirements.txt`
|
||||
- **PyPI mirror**: Tencent Cloud (`mirrors.cloud.tencent.com`), configured in `pip.conf`
|
||||
| 服务 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| `comfyui.service` | 127.0.0.1:8188 | `ComfyUI/venv` 的 `main.py --cache-classic --fast` |
|
||||
| `hair-worker.service` | 0.0.0.0:8187 | 本仓库 `app:app`(Wants comfyui + change_hair-hair) |
|
||||
| `change_hair-{webui,photo,hair}.service` | 57860 / 32678 / 8801 | conda envs `sdwebui`/`py310`/`my_hair`,依赖顺序 webui→photo→hair |
|
||||
|
||||
### Documentation
|
||||
网关机另行部署 `gateway/`(config:`workers` 列表 + `shared_password` + `ark` key),见 `gateway/config.example.json` 与 `docs/网关待改动.md`。
|
||||
|
||||
- `docs/接口文档.md` — complete API documentation (inputs, outputs, examples, error codes)
|
||||
- `docs/旷视具体需求.md` — original requirements spec (converted from `.docx`)
|
||||
- The FastAPI app's `description` docstring in `app.py` is an additional source of API docs visible in Swagger UI
|
||||
## Documentation
|
||||
|
||||
- `docs/接口文档.md` — 对外 API 契约(唯一权威)
|
||||
- `docs/实现说明.md` — 架构总览 + 各接口实现简述 + 部署要点
|
||||
- `docs/发际线增强算法.md`、`docs/发际线生发遮罩算法_pushed模式.md` — 接口11/12 算法推导
|
||||
- `docs/gpu-worker改动说明.md`、`docs/网关待改动.md` — worker↔网关 协同变更记录
|
||||
- `OFFLINE_ASSETS.md` — 离线模型权重/字体清单
|
||||
|
||||
@@ -1476,6 +1476,70 @@ async def head_band(
|
||||
return err(1007, f"处理失败:{ex}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 接口 99:人脸特殊点检测(固定关键点 + 发际线特殊点)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post(
|
||||
"/api/v1/face/special-points",
|
||||
summary="接口99 人脸特殊点检测",
|
||||
tags=["人脸分析"],
|
||||
description=f"""
|
||||
输入一张人脸照片,返回 MediaPipe Face Mesh 一组**固定编号特殊点**的像素坐标,
|
||||
以及 5 个**发际线特殊点**(射线与 BiSeNet 头发分割发际线的交点,见 hairline_grow 同款分割)。
|
||||
|
||||
**字段命名**:`<组英文名>_<点ID>`,如 `eyebrow_107`、`mouth_17`;发际线点用给定字段名
|
||||
(`hair_line_center` 等)。坐标为原图像素坐标(int),发际线点不可得(无头发/分割失败)时为 null。
|
||||
|
||||
| 组 | 字段前缀 | 点 ID |
|
||||
|----|----------|-------|
|
||||
| 眉毛 | eyebrow | 107 66 105 63 70 336 296 334 293 300 |
|
||||
| 眼睛 | eye | 189 222 224 113 226 413 442 444 342 446 |
|
||||
| 嘴 | mouth | 0 37 40 67 91 84 17 405 375 409 269 |
|
||||
| 中线鼻 | nose_midline | 9 168 197 5 4 194 278 48 437 217 |
|
||||
| 面颊 | cheek | 101 117 123 187 205 50 330 346 352 280 425 |
|
||||
| 边线 | face_edge | 162 234 58 136 149 152 378 365 288 401 366 389 251 |
|
||||
|
||||
**发际线点算法**:从关键点 a 沿 a→b 方向逐像素步进,首次进入头发 mask 处即交点;
|
||||
双射线的点取两交点中点(仅一条命中返回该点,均未命中为 null):
|
||||
- `hair_line_center`:151→10
|
||||
- `hair_line_right`:mid(108→109, 69→67)
|
||||
- `hair_line_right_right`:mid(104→103, 68→54)
|
||||
- `hair_line_left`:mid(337→338, 299→297)
|
||||
- `hair_line_left_left`:mid(333→332, 298→284)
|
||||
|
||||
{_image_fields_desc}
|
||||
""",
|
||||
)
|
||||
async def face_special_points(
|
||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||
):
|
||||
"""接口99:固定关键点 + 发际线特殊点,像素坐标 JSON 返回。"""
|
||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||
if e is not None:
|
||||
return e
|
||||
|
||||
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||
if image is None:
|
||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
|
||||
try:
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from face_analysis.special_points import detect_special_points, NoFaceError, NonFrontalError
|
||||
try:
|
||||
data = await run_in_threadpool(detect_special_points, image)
|
||||
except NoFaceError:
|
||||
return err(1001, "无法识别人像")
|
||||
except NonFrontalError:
|
||||
return err(1003, "角度问题,请上传正面照")
|
||||
return ok(data)
|
||||
except Exception as ex: # noqa: BLE001
|
||||
logger.exception("接口99 处理异常")
|
||||
return err(1007, f"处理失败:{ex}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 接口 11:发际线生发(接口9 遮罩 + change_hair 换发型 + 按遮罩羽化贴回)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+53
-18
@@ -12,13 +12,15 @@
|
||||
|
||||
```
|
||||
客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py)
|
||||
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI)
|
||||
└ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/6/7
|
||||
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI/change_hair)
|
||||
└ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/6/7/9/10/11/12/99 + 工具路由
|
||||
```
|
||||
|
||||
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,**纯本地、无外网依赖**。
|
||||
对 `/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json` 的 `accept_passwords`);
|
||||
`/health` 模型就绪才返回 200。监听 **8187**(`./start.sh` 控制开关,`./run_worker.sh` 热重载)。
|
||||
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,除 ComfyUI/change_hair 走本机
|
||||
回环外无外网依赖。对 `/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json` 的 `accept_passwords`);
|
||||
`/health` 模型就绪才返回 200。监听 **8187**(systemd `hair-worker.service`,也可 `./run_worker.sh` 手动起)。
|
||||
另有 worker 本机的工具路由:`/api/swapHair/v1`、`/hairColor/v2`(CORS 代理转发 change_hair:8801)、
|
||||
`/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`(免鉴权调试)。
|
||||
- **网关**(`gateway/`):薄反向代理,健康检查/派发/鉴权/把 worker 的 `*_base64` 落盘改成 `*_url`。
|
||||
唯一例外是**接口4 在网关本机直接实现**(调外网豆包,不转发 worker)。
|
||||
- **图片三选一**:所有接口图片入参 `image_file`/`image_url`/`image_base64` 严格三选一(接口3 是 `marked_image_*`)。
|
||||
@@ -33,7 +35,10 @@
|
||||
| 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_{middle,high,low}_base64` / `grown_image_base64`(可空) | `hairline_images[].image_{middle,high,low}_url` / `grown_image_url` |
|
||||
| 7 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
|
||||
| 9 | `steps_common.*_base64`、`bisenet|segformer.{hair,closed_region,final_overlay,mask}_mask_base64` 等 | 同名 `*_url` |
|
||||
| 10 | `steps_common.*_base64`、`bisenet|segformer.{base_mask,contour,band_overlay,mask}_base64` 等 | 同名 `*_url` |
|
||||
| 11 | `steps.{input,baseline,hair_mask,mask_overlay,mask,swap_raw,alpha,final}_base64` | `steps.*_url` |
|
||||
| 12 | `steps.{input,final,redraw_band_overlay,redraw_full,redraw_band,redraw_c}_base64` | `steps.*_url` |
|
||||
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
|
||||
|
||||
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
|
||||
@@ -47,7 +52,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 2. 五个接口实现简述
|
||||
## 2. 各接口实现简述
|
||||
|
||||
### 接口1 四庭七眼测量 `/api/v1/face/measure`(worker)
|
||||
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、一张透明底标注 PNG。
|
||||
@@ -65,10 +70,10 @@
|
||||
### 接口2 C端生发 `/api/v1/hair/grow`(worker)—— 预览 + 生发图
|
||||
- **做什么**:正面照 + `gender`(必填) + `hair_style`(必填,逗号分隔多选,如 `1,2,3`) → 指定发际线类型 **N 组**:**预览图**(发际线叠在照片上) + **生发后图**(植发3个月效果)。
|
||||
- **怎么实现**(`hairline/`):移植 head3d——MediaPipe(Tasks) + SegFormer 分割 + 17 锚点射线检测 → 502 点 mesh,
|
||||
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182** 的
|
||||
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。原生生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8188** 的
|
||||
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
|
||||
返回 `results[].image_base64` + `grown_image_base64`。
|
||||
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave 6=bigflower 7=clasicalflower;male 1=ellipse 2=inverse_arc 3=m 4=straight 5=heart 6=Softpetal 7=wave。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线;female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线。
|
||||
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave 6=bigflower 7=clasicalflower;male 1=ellipse 2=inverse_arc 3=m 4=straight 5=heart 6=Softpetal 7=wave。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线(`generate_grow_results_swap`,复用接口12 final 的 `_V2_FINAL_DEFAULTS`,长边默认压到 `REDRAW_MAX_SIDE`=1024 再放大回原尺寸);female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线。可选参数 `flux_model`(切换 Flux unet,.gguf 自动配 UnetLoaderGGUF + 配套 qwen CLIP)、`redraw_max_side`(0=不缩图)。
|
||||
|
||||
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`(worker)—— 接口2同款,add_hair2 工作流
|
||||
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` 逗号分隔多选 → N 组预览+生发图)。
|
||||
@@ -92,20 +97,50 @@
|
||||
- **做什么**:入参同接口2(`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
|
||||
- **怎么实现**:复用接口2 的 502 点渲染管线,三档分别用 `hairline_texture[/_high|/_low]` 同名贴图渲染叠图;生发同接口2(ComfyUI inpaint),**黑模板固定取 `hairline_texture_black/`(middle)**,每发型 1 张生发图。`best_hairline_center_point`=眉心 x × 首个选中发型 middle 档发际线 y。
|
||||
|
||||
### 接口9 头发遮罩 `/api/v1/head/mask`(worker)
|
||||
- **做什么**:人像照 → 头发遮罩 + 全流程分步可视化。
|
||||
- **怎么实现**(`face_analysis/head_mask.py`):MediaPipe 底部分割线(21/68/104/69/108/151/337/299/333/298/251 连线,21/251 水平延伸)→ 线以上为上半区 → BiSeNet 与 SegFormer 双路头发分割、逐列向下填充到分割线得闭合区域(头发+额头)→ 外缘朝 151 内缩 `erode_cm`(默认1.2cm,虹膜标定) → 最终遮罩。返回 `steps_common` + `bisenet`/`segformer` 两组各四张图。
|
||||
|
||||
### 接口10 头部外缘膨胀带 `/api/v1/head/band`(worker)
|
||||
- **做什么**:在接口9 基准遮罩基础上,沿头部外缘生成一条膨胀带遮罩。
|
||||
- **怎么实现**(`face_analysis/head_band.py`):取基准遮罩外轮廓、去掉贴底分割线段 → 膨胀成总宽 `dilate_cm`(默认2cm) 的带子 → 裁到分割线以上。返回 `base_mask`/`contour`/`band_overlay`/`mask` 分步图。
|
||||
|
||||
### 接口11 发际线生发 `/api/v1/hairline/grow`(worker)—— 不含重绘
|
||||
- **做什么**:发际线偏高/头发稀少正脸图 + `hairline_id`(= change_hair `hair_id`,如 `chang_tuoyuan`)→ 压低发际线图 + 全步骤可视化。
|
||||
- **怎么实现**(`face_analysis/hairline_grow.py`,算法见 `发际线增强算法.md`):① 接口9 遮罩(`seg_model` 选 bisenet/segformer)→ ② 调 change_hair 生成该发际线类型图(`swap_mode=ext_mask` 把遮罩传给 webui 精确重绘 / `as_is` 用其内部遮罩;`gen_backend` 可换 hairgrow 区域生发 inpaint)→ ③ 严格按遮罩贴回 → ④ 接缝融合 `blend_method`(multiband 默认 / seamless / two_stage / feather / alpha_gradient,可选 Reinhard `color_match`)。返回 `steps.{input,baseline,hair_mask,mask_overlay,mask,swap_raw,alpha,final}`。
|
||||
|
||||
### 接口12 发际线带重绘 `/api/v1/hairline/grow_v2[_final[_v2]]`(worker)
|
||||
- **做什么**:接口11 的下游重绘阶段——先跑接口11 拿 ④final,再取发际线重绘带(外推 `band_lo_mult×push`~`band_hi_mult×push`,默认 0.5×~1.5×)作遮罩,用 **Flux-2(ComfyUI,`0716add-hair-api.json`,reference-latent 保色)** 重绘,产出两版:`redraw_full`(整帧美颜+整帧重绘)与 `redraw_band`(band 内加发、band 外 final 结构 + `beauty_alpha` 融入全脸美颜)。
|
||||
- **三个变体**:`grow_v2` 全参数暴露(接口11 参数 + `comfyui_prompt`/`beauty_alpha`/`band_lo_mult`/`band_hi_mult`);`grow_v2_final` / `grow_v2_final_v2` 精简生产版——仅图片 + `hairline_id`,其余固化 `_V2_FINAL_DEFAULTS`(push=0.8cm / two_stage / color_match=关 / beauty_alpha=0.6 等),两者后端计算完全一致(同一次输出含 A/B 两版),差异只是测试页展示哪版。接口2 女性 1..5 的生发图即复用此管线。
|
||||
- ⚠️ 依赖 ComfyUI(:8188);重绘失败在 `data.redraw.c_error` 报告。
|
||||
|
||||
### 接口99 人脸特殊点 `/api/v1/face/special-points`(worker)
|
||||
- **做什么**:人脸照 → 60 个固定编号 MediaPipe 关键点(6 组:eyebrow/eye/mouth/nose_midline/cheek/face_edge)+ 5 个发际线特殊点,原图像素坐标 JSON(字段 `<组名>_<点ID>`,如 `eyebrow_107`)。
|
||||
- **怎么实现**(`face_analysis/special_points.py`):MediaPipe 单例取关键点;发际线点=接口1 方案B 的 BiSeNet 头发 mask 上沿指定关键点方向(如 151→10)射线步进取首个头发像素,双射线的(right/right_right/left/left_left)取中点。无图片字段,网关纯盲转发。
|
||||
- 测试页:`static/test_interface99.html`(canvas 可视化 + JSON,worker 本机直连)。
|
||||
|
||||
---
|
||||
|
||||
## 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)。
|
||||
**worker(GPU 机,本机 = gpu_worker 角色)**
|
||||
- Python **3.10.12**(本机 venv 在 `./venv`);`numpy<2`(1.26.4),`scikit-image==0.24.0`
|
||||
(**别升 0.25+,会顶 numpy≥2 顶崩 mediapipe**)。
|
||||
- 本机 **RTX 4090**:venv 实装 `torch 2.7.1+cu128`,CUDA 可用(BiSeNet/SegFormer GPU 推理)。
|
||||
`requirements.txt` 里 pinned 的 `torch 2.2.2(cu121)` 及 5090 注释是旧机(sm_120 无 cu121 kernel、
|
||||
自动回退 CPU)的记录,本机以 venv 实际版本为准。
|
||||
- 模型权重/字体见 [`../OFFLINE_ASSETS.md`](../OFFLINE_ASSETS.md);BiSeNet/SegFormer/face_landmarker.task 本地。
|
||||
- 生发接口依赖本机 **ComfyUI(8188)**(Flux-2,它自带支持 5090 的 torch);worker 只调其 HTTP API,不跑 Flux。
|
||||
ComfyUI 开了 **HTTP Basic Auth**(user `admin` + 密码);密码放 `password.txt`(不入 git) /
|
||||
`worker_config.json.comfyui_password` / 环境变量 `COMFYUI_PASSWORD`,URL 用 `COMFYUI_URL`。
|
||||
- `worker_config.json`(不入 git):`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token`。
|
||||
- 生发接口依赖本机 **ComfyUI(8188)**(Flux-2,systemd `comfyui.service`,`--cache-classic --fast`);
|
||||
worker 只调其 HTTP API,不跑 Flux。ComfyUI 开了 **HTTP Basic Auth**(user `admin` + 密码);
|
||||
密码放 `password.txt`(不入 git) / `worker_config.json.comfyui_password` / 环境变量 `COMFYUI_PASSWORD`,
|
||||
URL 用 `COMFYUI_URL`。默认模型 flux-2-klein-9b Q4_K_M GGUF(接口2 `flux_model` 参数可切)。
|
||||
- 换发型依赖本机 **change_hair**(`/home/ubuntu/change_hair`,三进程 webui:57860(sdwebui) →
|
||||
photo_service:32678(py310/kohya) → hair_service_sd:8801(my_hair),`start_all.sh start|stop|status`,
|
||||
也有 systemd `change_hair-{webui,photo,hair}.service`)。不跑时接口2女1..5、接口11/12、
|
||||
swapHair/hairColor 代理不可用。
|
||||
- `worker_config.json`(不入 git):`accept_passwords`(鉴权) + `comfyui_password`;鉴权头 `X-Internal-Token`。
|
||||
- systemd 单元:`hair-worker.service`(8187) / `comfyui.service`(8188),仓库根有副本;手动启停用
|
||||
`./run_worker.sh` / `./stop_worker.sh`。
|
||||
|
||||
**网关机**
|
||||
- 很轻:FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
|
||||
|
||||
+196
-12
@@ -267,8 +267,10 @@
|
||||
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal, 7=wave。越界/非法返回 `1007` |
|
||||
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比。仅对原生发(male / female 6/7)管线生效 |
|
||||
| prompt | string | 否 | ComfyUI 提示词,默认「填充遮罩区域的头发」,会替换工作流节点 60 的文本。仅对原生发管线生效(female 1..5 换发型分支的提示词内部固定) |
|
||||
| flux_model | string | 否 | Flux 模型文件名(如 `flux-2-klein-9b-Q5_K_M.gguf`)。缺省用工作流默认(9B-Q4_K_M);`.gguf` 自动切 UnetLoaderGGUF 并同步配套 qwen CLIP |
|
||||
| redraw_max_side | int | 否 | female 1..5 重绘管线送模型前的长边上限像素。缺省=服务端默认(`REDRAW_MAX_SIDE`,当前 1024);`0`=不缩图(原图直送,更慢) |
|
||||
|
||||
### 输出(data)
|
||||
|
||||
@@ -281,7 +283,8 @@
|
||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`/`wave`(male) |
|
||||
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
|
||||
|
||||
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
|
||||
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8188)生成,**一次请求生成指定发型的 1 张、同步返回**;
|
||||
> female 1..5 的生发图另依赖 change_hair 换发型服务(:8801)。
|
||||
> worker 侧返回 `image_base64` / `grown_image_base64`,
|
||||
> 网关落盘后改写为上表的 `image_url` / `grown_image_url`。
|
||||
>
|
||||
@@ -515,16 +518,197 @@
|
||||
|
||||
---
|
||||
|
||||
## 接口 9:头发遮罩生成接口
|
||||
|
||||
**说明**:输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试)。
|
||||
|
||||
**请求**:`POST /api/v1/head/mask`
|
||||
|
||||
### 输入
|
||||
|
||||
图片参数见「通用约定 → 图片传参字段」。专属参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| erode_cm | float | 否 | 外缘朝中心(鼻梁 151 点)内缩的距离(厘米),默认 1.2 |
|
||||
|
||||
### 处理流程
|
||||
|
||||
1. MediaPipe 关键点 → 底部分割线(关键点 21,68,104,69,108,151,337,299,333,298,251 的连线,21 水平延伸到最左、251 延伸到最右)。
|
||||
2. 分割线以上为「上半区」。
|
||||
3. 头发分割(**BiSeNet** 与 **SegFormer** 两套);从每列最顶端头发向下填充到分割线,得到**含额头**的闭合区域(头发+额头,不割断)。
|
||||
4. 外缘朝中心点 151 内缩 `erode_cm`(虹膜标定换算像素)、底线不动 → 最终遮罩。
|
||||
|
||||
### 输出(data)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| px_per_cm | float | 虹膜标定出的像素/厘米 |
|
||||
| erode_cm / erode_px | float / int | 内缩距离(厘米 / 像素) |
|
||||
| image_size | object | `{width, height}` 原图尺寸 |
|
||||
| steps_common | object | `landmarks_baseline_*`(关键点/分割线)、`upper_region_*`(上半区) |
|
||||
| bisenet / segformer | object | 两套分割模型各自的结果:`hair_mask_*`(原始头发分割)、`closed_region_*`(含额头闭合区域)、`final_overlay_*`(最终遮罩叠加可视化)、`mask_*`(最终遮罩 PNG)及 `hair_pixels`/`closed_pixels`/`mask_pixels` 像素计数 |
|
||||
|
||||
> worker 返回 `*_base64`(data URI),经网关改写为 `*_url`。
|
||||
|
||||
---
|
||||
|
||||
## 接口 10:头部外缘膨胀带遮罩接口
|
||||
|
||||
**说明**:先和接口9 一样得到内缩后的基准遮罩,再沿头部外缘生成一条膨胀带遮罩。
|
||||
|
||||
**请求**:`POST /api/v1/head/band`
|
||||
|
||||
### 输入
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| erode_cm | float | 否 | 基准遮罩外缘朝 151 内缩距离(厘米,同接口9),默认 1.2 |
|
||||
| dilate_cm | float | 否 | 外轮廓线膨胀后带子总宽(厘米),默认 2.0 |
|
||||
|
||||
### 处理流程
|
||||
|
||||
1. 取基准遮罩的**外轮廓线**,去掉贴着底部分割线的那一段(只留头发/头部外缘弧线)。
|
||||
2. 把外轮廓线膨胀成带子(总宽 `dilate_cm`;半径=总宽/2,虹膜标定换算像素)。
|
||||
3. 裁到分割线以上(不越过底线)。
|
||||
|
||||
### 输出(data)
|
||||
|
||||
结构同接口9,但每组结果为 `base_mask_*`(基准遮罩)/ `contour_*`(外轮廓线)/ `band_overlay_*`(带子叠加可视化)/ `mask_*`(最终带状遮罩)及 `base_pixels`/`band_pixels` 计数,另有 `dilate_cm`/`dilate_radius_px`。
|
||||
|
||||
---
|
||||
|
||||
## 接口 11:发际线生发接口(不含重绘)
|
||||
|
||||
**说明**:输入一张**发际线较高 / 头发稀少**的正脸图 + 发际线类型 ID,输出同一个人、同一发型、按该类型压低发际线后的图片,并返回**每一步可视化**。重绘版见接口12。
|
||||
|
||||
**请求**:`POST /api/v1/hairline/grow`
|
||||
|
||||
### 输入
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| hairline_id | string | **是** | 发际线类型 ID(= change_hair `hair_id`):`chang_tuoyuan` / `chang_huaban` / `chang_xinxing` / `chang_zhixian` / `chang_bolang` |
|
||||
| gen_backend | string | 否 | 生成后端:`swaphair`(换发型 LoRA,默认)|`hairgrow`(区域生发 inpaint) |
|
||||
| hairgrow_strength | float | 否 | 区域生发强度(仅 hairgrow 后端),默认 0.75 |
|
||||
| is_hr | bool | 否 | 高清模式(换发型输出 1152×1536,否则 576×768),默认 false |
|
||||
| seg_model | string | 否 | 头发分割模型:`bisenet` | `segformer`(默认) |
|
||||
| erode_cm | float | 否 | baseline 参考内缩距离(厘米),默认 0.6 |
|
||||
| hairline_push_cm | float | 否 | 发际线外推距离(厘米,往头发方向推进),默认 1.0 |
|
||||
| hairline_edge | string | 否 | 发际线提取方式:`column`(默认)|`contour` |
|
||||
| swap_mode | string | 否 | 换发型取图模式:`ext_mask`(默认,把接口9 遮罩传给 webui 精确重绘)|`as_is`(用内部遮罩,贴回时再裁) |
|
||||
| edge_erode_px | int | 否 | 贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3 |
|
||||
| denoising_strength | float | 否 | 换发型 webui 重绘强度(越大生发越激进),默认 0.6 |
|
||||
| mb_levels | int | 否 | 多频段金字塔层数(2~6),默认 5 |
|
||||
| blend_method | string | 否 | 接缝融合:`multiband`(默认)|`seamless`(泊松)|`two_stage`(泊松→多频段)|`feather`(高斯羽化)|`alpha_gradient`(距离渐变) |
|
||||
| color_match | bool | 否 | 融合前 Reinhard 颜色迁移消除整体色差,默认 true |
|
||||
| color_match_strength | float | 否 | 颜色迁移强度(0~1),默认 1.0 |
|
||||
| mb_feather_px | int | 否 | 多频段最细层掩码轻羽化像素,默认 1 |
|
||||
| transition_band_px | int | 否 | keep-region 过渡带边距(-1=自动按层数 2^n),默认 -1 |
|
||||
| inpainting_fill | int | 否 | change_hair 服务端重绘填充:0=保留原图 | 1=噪声(默认)| 2=纯色 | 3=潜变量噪声 |
|
||||
| mask_blur | int | 否 | change_hair 服务端遮罩边缘模糊像素,默认 11 |
|
||||
| mask_dilate_scale | float | 否 | change_hair 服务端遮罩膨胀缩放(<1 收缩防越界),默认 1.0 |
|
||||
|
||||
### 输出(data)
|
||||
|
||||
`steps`:`input`(原图)/ `baseline`(底部分割线)/ `hair_mask`(头发分割)/ `mask_overlay`(遮罩叠加)/ `mask`(遮罩)/ `swap_raw`(换发型原始输出)/ `alpha`(融合权重)/ `final`(最终结果)各步图(`*_base64`,经网关改写为 `*_url`),另有 `timings_ms` 分步耗时。
|
||||
|
||||
> ⚠️ `swaphair` 后端依赖 change_hair(:8801)在跑;`hairgrow` 后端依赖 ComfyUI(:8188)。
|
||||
|
||||
---
|
||||
|
||||
## 接口 12:发际线带重绘接口
|
||||
|
||||
**说明**:接口11 的**下游重绘阶段**——内部先跑接口11 核心管线拿 ④ 接缝融合最终图(final),再取发际线重绘带(外推 `band_lo_mult×push` ~ `band_hi_mult×push` 之间、经 baseline 截断只留上部的带状区域)作遮罩,用 **Flux-2(ComfyUI,reference-latent 保色重绘)** 重绘。
|
||||
|
||||
**同时产出两版结果**:
|
||||
|
||||
- `redraw_full`:ComfyUI 整帧输出(全脸美颜 + 全脸重绘)。
|
||||
- `redraw_band`:加发只在发际线带内、band 外保留 final 结构并按 `beauty_alpha` 融入全脸美颜。
|
||||
|
||||
**请求**:`POST /api/v1/hairline/grow_v2`
|
||||
|
||||
### 输入
|
||||
|
||||
接口11 的全部参数(见上表)再叠加:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| comfyui_prompt | string | 否 | Flux-2 重绘提示词,缺省「填充遮罩区域的头发」 |
|
||||
| beauty_alpha | float | 否 | redraw_band 版 band 外的全脸美颜融入强度(0=band 外纯用 final,1≈整帧版),默认 0.6 |
|
||||
| band_lo_mult | float | 否 | 重绘带外推倍率下限(相对 hairline_push_cm),默认 0.5 |
|
||||
| band_hi_mult | float | 否 | 重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5 |
|
||||
|
||||
### 输出(data)
|
||||
|
||||
`steps`:`input` / `final`(重绘输入基底)/ `redraw_band_overlay`(重绘带可视化)/ `redraw_full`(A 整帧)/ `redraw_band`(B 局部+美颜)/ `redraw_c`(兼容旧字段,=redraw_full);重绘异常在 `redraw.c_error` 报告。
|
||||
|
||||
### 精简变体(生产推荐)
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `POST /api/v1/hairline/grow_v2_final` | 仅需图片 + `hairline_id`,其余参数固化为调优默认值(push=0.8cm / two_stage / color_match=关 等),**最终取整帧重绘 `redraw_full`** |
|
||||
| `POST /api/v1/hairline/grow_v2_final_v2` | 参数同上,**最终取 B 局部加发+全脸美颜 `redraw_band`** |
|
||||
|
||||
> ⚠️ 依赖 ComfyUI(:8188)与 change_hair(:8801)均在跑。接口2 女性 1..5 的生发图即复用 `grow_v2_final` 管线。
|
||||
|
||||
---
|
||||
|
||||
## 接口 99:人脸特殊点检测接口
|
||||
|
||||
**说明**:输入一张人脸照片,返回一组**固定编号 MediaPipe Face Mesh 关键点**的像素坐标,外加 5 个**发际线特殊点**(射线与头发分割发际线的交点)。
|
||||
|
||||
**请求**:`POST /api/v1/face/special-points`
|
||||
|
||||
### 输入
|
||||
|
||||
图片参数见「通用约定 → 图片传参字段」,无专属参数。要求单人正面照(非正面返回 `1003`)。
|
||||
|
||||
### 输出(data)
|
||||
|
||||
`points`:字段名 = `<组英文名>_<点ID>`,值为原图像素坐标 `{x, y}`(int);发际线点不可得(秃顶/分割失败)时为 null。
|
||||
|
||||
| 组 | 字段前缀 | 点 ID |
|
||||
|----|----------|-------|
|
||||
| 眉毛 | `eyebrow` | 107 66 105 63 70 336 296 334 293 300 |
|
||||
| 眼睛 | `eye` | 189 222 224 113 226 413 442 444 342 446 |
|
||||
| 嘴 | `mouth` | 0 37 40 67 91 84 17 405 375 409 269 |
|
||||
| 中线鼻 | `nose_midline` | 9 168 197 5 4 194 278 48 437 217 |
|
||||
| 面颊 | `cheek` | 101 117 123 187 205 50 330 346 352 280 425 |
|
||||
| 边线 | `face_edge` | 162 234 58 136 149 152 378 365 288 401 366 389 251 |
|
||||
|
||||
**发际线特殊点**(沿关键点 a→b 方向射线步进,首次进入 BiSeNet 头发 mask 处为交点;双射线取中点,仅一条命中返回该点):
|
||||
|
||||
| 字段 | 计算 |
|
||||
|------|------|
|
||||
| `hair_line_center` | 151→10 方向交点 |
|
||||
| `hair_line_right` | mid(108→109 交点A, 69→67 交点B) |
|
||||
| `hair_line_right_right` | mid(104→103 交点A, 68→54 交点B) |
|
||||
| `hair_line_left` | mid(337→338 交点A, 299→297 交点B) |
|
||||
| `hair_line_left_left` | mid(333→332 交点A, 298→284 交点B) |
|
||||
|
||||
另有 `image_size`(宽高)与 `hairline_source`(`segmentation`=发际线点已算出 / `unavailable`=分割不可用,发际线点为 null)。
|
||||
|
||||
---
|
||||
|
||||
## 汇总:输入输出一览
|
||||
|
||||
| 接口 | 输入 | 主要输出 |
|
||||
|------|------|----------|
|
||||
| 1 四庭七眼测量 | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 |
|
||||
| 6 四庭七眼测量 v2 | 用户照片 | 同接口1,复刻实现 |
|
||||
| 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单/多张) |
|
||||
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
|
||||
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
|
||||
| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
|
||||
| 接口 | 路径 | 输入 | 主要输出 |
|
||||
|------|------|------|----------|
|
||||
| 1 四庭七眼测量 | `/api/v1/face/measure` | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 |
|
||||
| 6 四庭七眼测量 v2 | `/api/v1/face/measure-v2` | 用户照片 | 同接口1,去顶庭变体 |
|
||||
| 2 C 端生发 | `/api/v1/hair/grow` | 用户照片 + gender + hair_style(多选) | 每发型预览 PNG + 生发图(female 1..5=换发型+重绘,其余=原生生发) |
|
||||
| 7 C 端生发 v2 | `/api/v1/hair/grow-v2` | 同接口2 | 同接口2(add_hair2 工作流,已弃用隐藏) |
|
||||
| 3 B 端生发 | `/api/v1/hair/grow-b` | 划线图片 | 检测到的发际线 + 生发后图片 |
|
||||
| 4 用户特征 | `/api/v1/face/features` | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格),网关本机实现 |
|
||||
| 5 发际线 PNG | `/api/v1/hairline/generate` | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
|
||||
| 9 头发遮罩 | `/api/v1/head/mask` | 用户照片 | 头发遮罩 + 分步可视化(BiSeNet/SegFormer 双路) |
|
||||
| 10 外缘膨胀带 | `/api/v1/head/band` | 用户照片 | 头部外缘带状遮罩 + 分步可视化 |
|
||||
| 11 发际线生发 | `/api/v1/hairline/grow` | 用户照片 + hairline_id | 压低发际线图 + 全步骤可视化(不含重绘) |
|
||||
| 12 发际线带重绘 | `/api/v1/hairline/grow_v2` | 同接口11 + 重绘带参数 | final 基底 + Flux-2 保色重绘(整帧/局部两版);精简版 `grow_v2_final[_v2]` 仅需图片+hairline_id |
|
||||
| 99 人脸特殊点 | `/api/v1/face/special-points` | 用户照片 | 60 个固定编号关键点 + 5 个发际线特殊点像素坐标 JSON |
|
||||
|
||||
> 另有 worker 本机工具路由(不在对外契约内):`/api/swapHair/v1`、`/hairColor/v2`(CORS 代理 change_hair)、`/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`。
|
||||
|
||||
---
|
||||
|
||||
@@ -534,7 +718,7 @@
|
||||
- 默认单人;检测到 2~3 人报错提示客户端;单文件 **≤ 1 MB**;格式仅 **JPG / PNG**。
|
||||
- 所有坐标基准:**原图像素**。
|
||||
- 接口 2:提供「美颜」开关 `beauty_enabled`。
|
||||
- 接口 3:需同时上传原图,需返回发际线类型。
|
||||
- 接口 3:仅传**划线图**(`marked_image_*` 三选一),需返回发际线类型(`custom`)。
|
||||
- 接口 4:`data` 返回**一个 JSON 字符串**,字段不固定,可随时调整。
|
||||
- 接口 5:发际线 PNG 数量 **N 不固定**,需按合适度**排序**。
|
||||
- 英文字段命名由本文档约定(见各接口表格)。
|
||||
|
||||
+77
-1
@@ -70,7 +70,7 @@ async def hair_grow_v2(request: Request):
|
||||
|------|------|------|------|
|
||||
| image_file / image_url / image_base64 | — | **三选一** | 用户正面照 |
|
||||
| gender | string | **是** | `male` / `female` |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female: 1–5,male: 1–4 |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female / male 均为 1–7 |
|
||||
| beauty_enabled | bool | 否 | 美颜开关(本期不生效) |
|
||||
| use_mask | bool | 否 | 默认 `true`,`false` 跳过遮罩 |
|
||||
| prompt | string | 否 | ComfyUI 提示词 |
|
||||
@@ -135,6 +135,82 @@ async def face_measure_v2(request: Request):
|
||||
|
||||
---
|
||||
|
||||
## 7. ✅【已完成】接口9/10 头部遮罩(`/api/v1/head/mask`、`/api/v1/head/band`)
|
||||
|
||||
`gateway/app.py` 已新增两条盲转发路由(同接口6 的写法):
|
||||
|
||||
```python
|
||||
@app.post("/api/v1/head/mask", tags=["人脸分析"])
|
||||
@app.post("/api/v1/head/band", tags=["人脸分析"])
|
||||
```
|
||||
|
||||
- 入参:图片三选一 + `erode_cm`(接口10 另有 `dilate_cm`)。
|
||||
- 出参:`steps_common.*_base64` 与 `bisenet`/`segformer` 组内的 `*_base64` 字段——现有递归
|
||||
`rewrite_base64_to_url` 已覆盖(嵌套 dict/数组),无需额外改动。
|
||||
- 两个接口响应较慢(双分割模型跑两遍),注意 `request_timeout_seconds`。
|
||||
|
||||
---
|
||||
|
||||
## 8. 🔲【新增】接口11/12 发际线生发/带重绘(`/api/v1/hairline/grow*`)
|
||||
|
||||
worker 侧已上线 4 条路由,**网关尚无对应转发路由**(当前仅测试页直连 worker)。如需对外提供,参考接口9 加法:
|
||||
|
||||
```python
|
||||
@app.post("/api/v1/hairline/grow", tags=["生发"]) # 接口11
|
||||
@app.post("/api/v1/hairline/grow_v2", tags=["生发"]) # 接口12 全参数
|
||||
@app.post("/api/v1/hairline/grow_v2_final", tags=["生发"]) # 接口12 final 精简版
|
||||
@app.post("/api/v1/hairline/grow_v2_final_v2", tags=["生发"])
|
||||
```
|
||||
|
||||
- 入参:图片三选一 + `hairline_id`(接口12 final 两变体仅需这两个)。接口11/12 全参数版还有
|
||||
20+ 个调优 Form 参数,网关盲转发即可,无需逐个声明。
|
||||
- 出参:`data.steps.*_base64`(input/final/redraw_full/redraw_band 等各步图)→ 现有递归改写覆盖。
|
||||
- ⚠️ 依赖 worker 机的 change_hair(:8801) 与 ComfyUI(:8188) 都在跑;单请求 10s+,超时建议 ≥120s。
|
||||
- 契约细节见 [`接口文档.md`](接口文档.md) 接口11/12 章节。
|
||||
|
||||
---
|
||||
|
||||
## 9. ✅【新增】接口99 人脸特殊点(`/api/v1/face/special-points`)
|
||||
|
||||
**背景**:worker 侧新增接口99——固定编号 MediaPipe 关键点(6 组 60 点)+ 5 个发际线特殊点,
|
||||
返回原图像素坐标 JSON(无图片字段)。
|
||||
|
||||
**网关代码已在本仓库 `gateway/app.py` 改好**(同接口1 的盲转发写法,pull 后重启即生效):
|
||||
|
||||
```python
|
||||
@app.post("/api/v1/face/special-points", tags=["人脸分析"])
|
||||
async def face_special_points(request: Request):
|
||||
"""接口99:人脸特殊点检测(固定关键点 + 发际线特殊点像素坐标)"""
|
||||
return await _proxy(request, "/api/v1/face/special-points")
|
||||
```
|
||||
|
||||
**网关机部署/调试步骤**:
|
||||
|
||||
```bash
|
||||
cd /path/to/hair && git pull # 拉取本分支
|
||||
sudo systemctl restart hair-gateway # 或项目实际的网关服务名(uvicorn gateway.app:app)
|
||||
# 验证路由已注册(应能在输出里看到 /api/v1/face/special-points)
|
||||
curl -s http://127.0.0.1:8080/openapi.json | python3 -m json.tool | grep special-points
|
||||
# 冒烟:经网关调一次(multipart 传图,无需 X-Internal-Token,网关会注入)
|
||||
curl -s -X POST http://127.0.0.1:8080/api/v1/face/special-points \
|
||||
-F "image_file=@/tmp/face.jpg" | python3 -m json.tool | head -30
|
||||
```
|
||||
|
||||
**无需额外改动**:
|
||||
- 请求:multipart/form-data,仅图片三选一参数,盲转发即可。
|
||||
- 响应:`data.points` 全是坐标(`{"x":..,"y":..}`),**没有任何 `*_base64` 字段**,
|
||||
现有 `rewrite_base64_to_url` 递归改写天然跳过,原样返回。
|
||||
- 性能:纯本地推理(MediaPipe + BiSeNet,秒级),不依赖 ComfyUI/change_hair,
|
||||
`request_timeout_seconds` 无需调大。
|
||||
|
||||
**注意**:
|
||||
- worker 侧测试页 `static/test_interface99.html` 是 worker 本机直连的(带 token 头),
|
||||
经网关调试请用 curl 或网关 `/docs`(网关只盲转发 `/api/*`,不代理 worker 的 `/static`)。
|
||||
- 契约详见 [`接口文档.md`](接口文档.md) 接口99 章节;worker 错误码 1001(无人脸)/1003(非正面)
|
||||
/1007/1008 原样透传。
|
||||
|
||||
---
|
||||
|
||||
## 已经做好、无需再动的
|
||||
|
||||
- **接口4 在网关本机实现**(调豆包,不转发 worker)——已完成;config 里配 `ark`。
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""接口99:人脸特殊点检测。
|
||||
|
||||
返回 MediaPipe Face Mesh 关键点中一组固定编号的像素坐标(英文组名_点ID 为字段名),
|
||||
外加 5 个发际线特殊点:从指定关键点沿指定方向射线步进,与 BiSeNet 头发分割 mask
|
||||
首次相交处即发际线点(参考接口1 方案B 的发际线定位方式,见 hair_segmenter.py)。
|
||||
|
||||
组与编号(需求方给定):
|
||||
- eyebrow 107 66 105 63 70 336 296 334 293 300
|
||||
- eye 189 222 224 113 226 413 442 444 342 446
|
||||
- mouth 0 37 40 67 91 84 17 405 375 409 269
|
||||
- nose_midline 9 168 197 5 4 194 278 48 437 217
|
||||
- cheek 101 117 123 187 205 50 330 346 352 280 425
|
||||
- face_edge 162 234 58 136 149 152 378 365 288 401 366 389 251
|
||||
|
||||
发际线点(字段名需求方给定;A/B 两射线时取中点,仅一条命中则返回该点,均未命中为 null):
|
||||
- hair_line_center 151 → 10 方向与发际线交点
|
||||
- hair_line_right mid(108→109 交点A, 69→67 交点B)
|
||||
- hair_line_right_right mid(104→103 交点A, 68→54 交点B)
|
||||
- hair_line_left mid(337→338 交点A, 299→297 交点B)
|
||||
- hair_line_left_left mid(333→332 交点A, 298→284 交点B) # 与 right_right 镜像对称
|
||||
"""
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
# 固定关键点组:组英文名 → MediaPipe 点 ID 列表(顺序即需求方给定顺序)
|
||||
GROUPS = {
|
||||
"eyebrow": [107, 66, 105, 63, 70, 336, 296, 334, 293, 300],
|
||||
"eye": [189, 222, 224, 113, 226, 413, 442, 444, 342, 446],
|
||||
"mouth": [0, 37, 40, 67, 91, 84, 17, 405, 375, 409, 269],
|
||||
"nose_midline": [9, 168, 197, 5, 4, 194, 278, 48, 437, 217],
|
||||
"cheek": [101, 117, 123, 187, 205, 50, 330, 346, 352, 280, 425],
|
||||
"face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251],
|
||||
}
|
||||
|
||||
# 发际线点定义:字段名 → 参与计算的 (起点ID, 方向点ID) 射线组;单射线直接取交点,
|
||||
# 双射线取两交点中点。
|
||||
HAIRLINE_RAYS = {
|
||||
"hair_line_center": [(151, 10)],
|
||||
"hair_line_right": [(108, 109), (69, 67)],
|
||||
"hair_line_right_right": [(104, 103), (68, 54)],
|
||||
"hair_line_left": [(337, 338), (299, 297)],
|
||||
"hair_line_left_left": [(333, 332), (298, 284)],
|
||||
}
|
||||
|
||||
|
||||
class NoFaceError(Exception):
|
||||
"""未检出人脸。"""
|
||||
|
||||
|
||||
class NonFrontalError(Exception):
|
||||
"""非正面照(复用接口1 姿态阈值)。"""
|
||||
|
||||
|
||||
def ray_hairline_intersection(p1, p2, hair_mask):
|
||||
"""从 p1 沿 p1→p2 方向逐像素步进,返回首次进入头发 mask 的整数像素坐标。
|
||||
|
||||
p1/p2: (x, y) 像素坐标(float)。起点本身已在头发内时直接返回起点(已到发际线)。
|
||||
步进直到出图或步数上限(3×图对角线,防极端死循环);未命中返回 None。
|
||||
"""
|
||||
mask = np.asarray(hair_mask).astype(bool)
|
||||
if mask.size == 0:
|
||||
return None
|
||||
h, w = mask.shape
|
||||
x0, y0 = float(p1[0]), float(p1[1])
|
||||
dx, dy = float(p2[0]) - x0, float(p2[1]) - y0
|
||||
length = math.hypot(dx, dy)
|
||||
if length < 1e-6:
|
||||
return None
|
||||
ux, uy = dx / length, dy / length
|
||||
max_steps = int(3 * math.hypot(h, w))
|
||||
|
||||
def _hit(x, y):
|
||||
xi, yi = int(round(x)), int(round(y))
|
||||
if 0 <= xi < w and 0 <= yi < h:
|
||||
return mask[yi, xi]
|
||||
return False
|
||||
|
||||
x, y = x0, y0
|
||||
for _ in range(max_steps):
|
||||
if _hit(x, y):
|
||||
return int(round(x)), int(round(y))
|
||||
x += ux
|
||||
y += uy
|
||||
if not (0 <= int(round(x)) < w and 0 <= int(round(y)) < h):
|
||||
return None # 已步出图像,方向上无头发
|
||||
return None
|
||||
|
||||
|
||||
def detect_special_points(image_bgr):
|
||||
"""主入口:图片 → {字段名: {"x","y"} 或 None} + 元信息。
|
||||
|
||||
Returns: {"points": {...}, "image_size": {"width","height"},
|
||||
"hairline_source": "segmentation"|"unavailable"}
|
||||
无人脸抛 NoFaceError;非正面抛 NonFrontalError。
|
||||
"""
|
||||
from face_analysis.detector import detector
|
||||
from face_analysis.calibration import normalized_to_pixel
|
||||
from face_analysis.pose import check_frontal_face
|
||||
|
||||
h, w = image_bgr.shape[:2]
|
||||
landmarks = detector.detect(image_bgr)
|
||||
if landmarks is None:
|
||||
raise NoFaceError("未检出人脸")
|
||||
if not check_frontal_face(landmarks, w, h):
|
||||
raise NonFrontalError("非正面照")
|
||||
|
||||
lm = landmarks.landmark
|
||||
px = {i: normalized_to_pixel(lm[i], w, h) for i in range(len(lm))}
|
||||
|
||||
points = {}
|
||||
for group, ids in GROUPS.items():
|
||||
for idx in ids:
|
||||
x, y = px[idx]
|
||||
points[f"{group}_{idx}"] = {"x": int(round(x)), "y": int(round(y))}
|
||||
|
||||
# 头发分割(与接口1 相同:按人脸框裁剪后再分割,脸外背景不干扰)
|
||||
hair_mask = None
|
||||
try:
|
||||
from face_analysis.hair_segmenter import get_segmenter
|
||||
face_box = (min(p[0] for p in px.values()), min(p[1] for p in px.values()),
|
||||
max(p[0] for p in px.values()), max(p[1] for p in px.values()))
|
||||
hair_mask, _ = get_segmenter().segment_hair_and_ears(
|
||||
image_bgr, face_box=face_box)
|
||||
except Exception: # noqa: BLE001 分割不可用 → 发际线点置 null,不阻塞关键点
|
||||
hair_mask = None
|
||||
|
||||
for name, rays in HAIRLINE_RAYS.items():
|
||||
hits = []
|
||||
if hair_mask is not None:
|
||||
for a, b in rays:
|
||||
hits.append(ray_hairline_intersection(px[a], px[b], hair_mask))
|
||||
found = [p for p in hits if p is not None]
|
||||
if not found:
|
||||
points[name] = None
|
||||
elif len(found) == 1:
|
||||
points[name] = {"x": found[0][0], "y": found[0][1]}
|
||||
else:
|
||||
points[name] = {
|
||||
"x": int(round(sum(p[0] for p in found) / len(found))),
|
||||
"y": int(round(sum(p[1] for p in found) / len(found))),
|
||||
}
|
||||
|
||||
return {
|
||||
"points": points,
|
||||
"image_size": {"width": w, "height": h},
|
||||
"hairline_source": "segmentation" if hair_mask is not None else "unavailable",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import cv2
|
||||
import json
|
||||
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
|
||||
img = cv2.imread(path)
|
||||
if img is None:
|
||||
print(f"无法读取图片: {path}")
|
||||
sys.exit(1)
|
||||
try:
|
||||
out = detect_special_points(img)
|
||||
except (NoFaceError, NonFrontalError) as e:
|
||||
print(f"检测失败: {e}")
|
||||
sys.exit(1)
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
@@ -607,3 +607,9 @@ async def head_mask(request: Request):
|
||||
async def head_band(request: Request):
|
||||
"""接口10:头部外缘膨胀带遮罩 + 分步可视化"""
|
||||
return await _proxy(request, "/api/v1/head/band")
|
||||
|
||||
|
||||
@app.post("/api/v1/face/special-points", tags=["人脸分析"])
|
||||
async def face_special_points(request: Request):
|
||||
"""接口99:人脸特殊点检测(固定关键点 + 发际线特殊点像素坐标)"""
|
||||
return await _proxy(request, "/api/v1/face/special-points")
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>接口99 — 人脸特殊点检测 测试页</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
h1 { font-size: 22px; margin-bottom: 8px; }
|
||||
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; }
|
||||
h2 { font-size: 16px; margin: 24px 0 12px; }
|
||||
|
||||
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
|
||||
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
input[type=file] { flex: 1; min-width: 200px; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
|
||||
.btn { padding: 10px 24px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; }
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
|
||||
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
|
||||
|
||||
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
|
||||
.status.info { background: #dbeafe; color: #1e40af; display: block; }
|
||||
.status.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
||||
|
||||
.hidden { display: none; }
|
||||
|
||||
.canvas-wrap { position: relative; display: inline-block; max-width: 100%; border-radius: 8px; overflow: hidden; }
|
||||
canvas { max-width: 100%; display: block; }
|
||||
.legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 12px; font-size: 13px; }
|
||||
.legend .item { display: flex; align-items: center; gap: 6px; }
|
||||
.legend .dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
|
||||
|
||||
.json-panel { background: #1e293b; color: #e2e8f0; font-family: "SF Mono", Consolas, monospace; font-size: 12px; line-height: 1.6; padding: 16px; border-radius: 8px; max-height: 480px; overflow: auto; white-space: pre; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>接口99 — 人脸特殊点检测</h1>
|
||||
<div class="subtitle">POST /api/v1/face/special-points · 固定编号 MediaPipe 关键点 + 5 个发际线特殊点(射线与头发分割 mask 交点),返回原图像素坐标</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="upload-row">
|
||||
<input type="file" id="imageFile" accept="image/jpeg,image/png">
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交测试</button>
|
||||
<button class="btn btn-outline" onclick="clearResults()">清空</button>
|
||||
</div>
|
||||
<div class="hint">支持 JPG / PNG;单人正面照效果最佳。发际线点依赖头发分割,秃顶/遮挡时对应字段为 null。</div>
|
||||
<div class="status" id="statusBar"></div>
|
||||
</div>
|
||||
|
||||
<div id="resultsArea" class="hidden">
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0; display:flex; justify-content:space-between; align-items:center">
|
||||
<span>可视化结果(点击图片可放大)</span>
|
||||
<label style="font-size:13px; font-weight:400; display:flex; align-items:center; gap:6px; cursor:pointer">
|
||||
<input type="checkbox" id="showIds" checked onchange="redraw()"> 显示点ID
|
||||
</label>
|
||||
</h2>
|
||||
<div class="canvas-wrap"><canvas id="resultCanvas"></canvas></div>
|
||||
<div class="legend" id="legendBar"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0; display:flex; justify-content:space-between; align-items:center">
|
||||
<span>原始 JSON</span>
|
||||
<button class="btn btn-outline" style="padding:6px 14px; font-size:13px" onclick="copyJson()">复制</button>
|
||||
</h2>
|
||||
<div class="json-panel" id="jsonContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"
|
||||
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.85); z-index:99; cursor:zoom-out; align-items:center; justify-content:center">
|
||||
<img id="lightboxImg" style="max-width:96vw; max-height:96vh" alt="">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
const ENDPOINT = '/api/v1/face/special-points';
|
||||
|
||||
// 组 → 颜色(与下方 legend 一致)
|
||||
const GROUP_COLORS = {
|
||||
eyebrow: '#ef4444', // 红
|
||||
eye: '#3b82f6', // 蓝
|
||||
mouth: '#22c55e', // 绿
|
||||
nose_midline: '#a855f7', // 紫
|
||||
cheek: '#f97316', // 橙
|
||||
face_edge: '#06b6d4', // 青
|
||||
hairline: '#e11d48', // 玫红(发际线特殊点,大圆+标签)
|
||||
};
|
||||
const GROUP_LABELS = {
|
||||
eyebrow: '眉毛', eye: '眼睛', mouth: '嘴', nose_midline: '中线鼻',
|
||||
cheek: '面颊', face_edge: '边线', hairline: '发际线特殊点',
|
||||
};
|
||||
const HAIRLINE_KEYS = ['hair_line_center', 'hair_line_right', 'hair_line_right_right',
|
||||
'hair_line_left', 'hair_line_left_left'];
|
||||
|
||||
let lastImage = null; // 提交的原图(用于画布底图)
|
||||
let lastJson = null;
|
||||
let lastData = null; // 最近一次成功的 data(供「显示点ID」切换时重画)
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function setStatus(text, type) {
|
||||
const bar = $('statusBar');
|
||||
bar.textContent = text;
|
||||
bar.className = 'status ' + type;
|
||||
}
|
||||
|
||||
async function submitTest() {
|
||||
let file = $('imageFile').files[0];
|
||||
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
|
||||
|
||||
const t0 = performance.now();
|
||||
const btn = $('submitBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 请求中...';
|
||||
setStatus('正在请求 ' + API_BASE + ENDPOINT + ' ...', 'info');
|
||||
$('resultsArea').classList.add('hidden');
|
||||
|
||||
// 本地预览原图(原图直送,不缩放,坐标即原图像素)
|
||||
lastImage = await loadImage(await file.arrayBuffer());
|
||||
|
||||
const form = new FormData();
|
||||
form.append('image_file', file);
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
|
||||
const json = await resp.json();
|
||||
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||
lastJson = json;
|
||||
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
|
||||
if (json.code === 0) {
|
||||
const n = Object.values(json.data.points || {}).filter(Boolean).length;
|
||||
setStatus('✅ 请求成功 (' + dt + 's) — request_id: ' + json.request_id
|
||||
+ ',有效点 ' + n + '/' + Object.keys(json.data.points || {}).length
|
||||
+ ',hairline_source=' + json.data.hairline_source, 'success');
|
||||
lastData = json.data;
|
||||
renderCanvas(json.data);
|
||||
renderLegend();
|
||||
} else {
|
||||
setStatus('❌ 业务错误 (' + dt + 's) — code: ' + json.code + ' message: ' + json.message, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('❌ 网络错误: ' + err.message, 'error');
|
||||
$('jsonContent').textContent = 'Error: ' + err.message;
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '🚀 提交测试';
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(buf) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(new Blob([buf]));
|
||||
});
|
||||
}
|
||||
|
||||
function renderCanvas(data) {
|
||||
const canvas = $('resultCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = lastImage;
|
||||
const scale = Math.min(1, 900 / img.naturalWidth); // 画布显示尺寸(坐标按比例缩放)
|
||||
canvas.width = Math.round(img.naturalWidth * scale);
|
||||
canvas.height = Math.round(img.naturalHeight * scale);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const r = Math.max(1.5, 1.6 * scale * 2); // 点半径随图缩小适度收缩
|
||||
const font = Math.max(9, Math.round(9 * scale * 2)); // 发际线标签字号
|
||||
const idFont = Math.max(8, Math.round(6.5 * scale * 2)); // 关键点ID字号
|
||||
const showIds = $('showIds').checked;
|
||||
|
||||
// 1) 固定关键点:小实心圆 + 白描边 + 点ID
|
||||
for (const [key, pt] of Object.entries(data.points || {})) {
|
||||
if (!pt || HAIRLINE_KEYS.includes(key)) continue;
|
||||
const group = key.split('_')[0];
|
||||
const color = GROUP_COLORS[group] || '#888';
|
||||
const x = pt.x * scale, y = pt.y * scale;
|
||||
drawDot(ctx, x, y, r, color);
|
||||
if (showIds) {
|
||||
const id = key.split('_').pop(); // 如 eyebrow_107 → 107
|
||||
ctx.font = idFont + 'px sans-serif';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,.75)'; // 黑描边保证任意底色可读
|
||||
ctx.strokeText(id, x + r + 1, y + 3);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillText(id, x + r + 1, y + 3);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 发际线特殊点:大空心圆 + 字段名标签
|
||||
for (const key of HAIRLINE_KEYS) {
|
||||
const pt = (data.points || {})[key];
|
||||
if (!pt) continue;
|
||||
const x = pt.x * scale, y = pt.y * scale;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r * 2, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = GROUP_COLORS.hairline;
|
||||
ctx.lineWidth = Math.max(1.5, r * 0.55);
|
||||
ctx.stroke();
|
||||
drawDot(ctx, x, y, r, GROUP_COLORS.hairline);
|
||||
ctx.font = '600 ' + font + 'px sans-serif';
|
||||
ctx.textBaseline = 'bottom';
|
||||
const label = key.replace('hair_line_', 'HL ');
|
||||
const tw = ctx.measureText(label).width;
|
||||
ctx.fillStyle = 'rgba(0,0,0,.55)';
|
||||
ctx.fillRect(x + r * 2.4, y - font - 4, tw + 8, font + 6);
|
||||
ctx.fillStyle = '#fecdd3';
|
||||
ctx.fillText(label, x + r * 2.6 + 4, y - 2);
|
||||
}
|
||||
}
|
||||
|
||||
function drawDot(ctx, x, y, r, color) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.lineWidth = Math.max(1, r * 0.35);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.9)';
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function renderLegend() {
|
||||
const bar = $('legendBar');
|
||||
bar.innerHTML = Object.entries(GROUP_LABELS)
|
||||
.map(([g, label]) => `<span class="item"><span class="dot" style="background:${GROUP_COLORS[g]}"></span>${label}</span>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
$('resultsArea').classList.add('hidden');
|
||||
$('statusBar').className = 'status';
|
||||
$('imageFile').value = '';
|
||||
lastImage = null; lastJson = null; lastData = null;
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
if (lastData && lastImage) renderCanvas(lastData);
|
||||
}
|
||||
|
||||
function copyJson() {
|
||||
if (!lastJson) return;
|
||||
navigator.clipboard.writeText(JSON.stringify(lastJson, null, 2))
|
||||
.then(() => setStatus('✅ JSON 已复制到剪贴板', 'success'));
|
||||
}
|
||||
|
||||
// 点击画布放大
|
||||
$('resultCanvas').addEventListener('click', () => {
|
||||
const lb = $('lightbox');
|
||||
$('lightboxImg').src = $('resultCanvas').toDataURL('image/png');
|
||||
lb.style.display = 'flex';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user