save code
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 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.
|
||||
|
||||
- **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
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development server
|
||||
./venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 --reload
|
||||
|
||||
# Or via the start script
|
||||
./start.sh
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| 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) |
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
### Unified response structure
|
||||
|
||||
```json
|
||||
{"code": 0, "message": "success", "request_id": "mock-request-id", "data": {...}}
|
||||
```
|
||||
|
||||
Error codes: 1001–1008 (see `app.py` docstring or `docs/接口文档.md`).
|
||||
|
||||
### Key code patterns
|
||||
|
||||
- `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
|
||||
|
||||
- **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`
|
||||
|
||||
### Documentation
|
||||
|
||||
- `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
|
||||
@@ -0,0 +1,683 @@
|
||||
# 接口 1:四庭七眼测量 — 技术实现方案
|
||||
|
||||
> 基于 MediaPipe Face Mesh(468 关键点)+ 人脸比例先验知识
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
**结论:目前没有开源模型能直接检测「头顶」和「真实发际线」关键点。** 所有模型在向上(额头以上)方向均存在覆盖盲区。因此采用 **MediaPipe Face Mesh (468 点) + 方案 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/`),安装后直接可用,无需额外下载。
|
||||
|
||||
---
|
||||
|
||||
## 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**,精度略低。
|
||||
|
||||
### 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. 方案 A:人脸比例推算头顶 & 发际线
|
||||
|
||||
### 4.1 核心思路
|
||||
|
||||
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,降级为系统默认中文字体)
|
||||
try:
|
||||
font = ImageFont.truetype("PingFangSC-Regular", 10)
|
||||
except:
|
||||
font = ImageFont.load_default() # 降级方案
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### 渐变线实现
|
||||
|
||||
```python
|
||||
def draw_gradient_horizontal_line(draw, cx, cy, img_width, color, line_width):
|
||||
"""以 (cx, cy) 为中心,向两侧绘制渐变消失的水平线"""
|
||||
max_alpha = color[3] # 255
|
||||
half_length = img_width // 3 # 渐变线长度
|
||||
|
||||
for side in [-1, 1]: # 左 (-1) / 右 (+1)
|
||||
for i in range(half_length):
|
||||
# 线性衰减 alpha
|
||||
alpha = int(max_alpha * (1 - i / half_length))
|
||||
x = cx + side * i
|
||||
if 0 <= x < img_width:
|
||||
draw.point((x, cy), fill=(color[0], color[1], color[2], alpha))
|
||||
```
|
||||
|
||||
### 虚线带箭头
|
||||
|
||||
```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) │
|
||||
│ - 校验分辨率 (1080×1920 ~ 4000×5000)
|
||||
│ - 校验文件大小 (≤ 1MB) │
|
||||
│ - 校验人脸数量 (仅单人) │
|
||||
└──────────────┬──────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ 2. MediaPipe 推理 │
|
||||
│ - FaceMesh(static_image_mode=True,
|
||||
│ max_num_faces=1,
|
||||
│ refine_landmarks=True) │
|
||||
│ - 输出: 468+10 关键点 │
|
||||
└──────────────┬──────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ 3. 关键点提取 │
|
||||
│ - 纵向: 头顶*/发际线*/眉心/鼻翼/下巴 │
|
||||
│ - 横向: 眼宽/脸宽/两眼间距 │
|
||||
│ * 推算值 │
|
||||
└──────────────┬──────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ 4. 尺度校准 │
|
||||
│ - 虹膜直径法: px_per_cm 估算 │
|
||||
└──────────────┬──────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ 5. 计算与生成 │
|
||||
│ - 像素 → 厘米 │
|
||||
│ - 计算占比 │
|
||||
│ - 生成标注图层 PNG │
|
||||
└──────────────┬──────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ 6. 输出 │
|
||||
│ - annotated_image_url (标注PNG) │
|
||||
│ - face_total_height_cm │
|
||||
│ - four_courts (含cm & ratios) │
|
||||
│ - seven_eyes (含cm & ratios) │
|
||||
│ - landmarks (5个点原图像素坐标) │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 关键代码骨架
|
||||
|
||||
### 8.1 目录结构建议
|
||||
|
||||
```
|
||||
hair/
|
||||
├── app.py # 现有 FastAPI 应用
|
||||
├── face_analysis/
|
||||
│ ├── __init__.py
|
||||
│ ├── detector.py # MediaPipe 封装
|
||||
│ ├── measure.py # 四庭七眼测量逻辑
|
||||
│ ├── calibration.py # px→cm 尺度校准
|
||||
│ ├── annotation.py # 标注图片生成
|
||||
│ └── face_mesh_landmarks.py # 关键点索引常量
|
||||
├── static/
|
||||
│ └── annotations/ # 生成的标注 PNG 存放目录
|
||||
└── 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]
|
||||
if h < 1080 or w < 1920:
|
||||
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")
|
||||
# ... 保存并返回 URL
|
||||
|
||||
return ok(result.to_response())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 误差分析与局限
|
||||
|
||||
| 误差来源 | 影响范围 | 估算误差 | 缓解措施 |
|
||||
|----------|----------|----------|----------|
|
||||
| 头顶/发际线推算 | 顶庭、上庭 cm 值 | ±15% | 基于实测中庭下庭比例自适应 |
|
||||
| 虹膜直径个体差异 | 所有 cm 值 | ±5% | 左右眼平均;未来可接性别/年龄修正 |
|
||||
| 非正面照 | 所有横向测量 | ±20% | 前置校验偏航角(yaw),过大则返回 1003 |
|
||||
| 相机畸变 | 边缘区域坐标 | ±3% | 假设普通手机拍照,畸变可控 |
|
||||
| 人脸比例个体差异 | 推算的发际线/头顶 | ±10% | 无完美解决方案,方案 A 的自然局限 |
|
||||
|
||||
**前置姿态校验**(检测是否为正面照):
|
||||
|
||||
```python
|
||||
def check_frontal_face(landmarks):
|
||||
"""简单正面照判定:基于左右眼关键点的 y 坐标对称性 + 鼻尖偏移"""
|
||||
left_eye = landmarks[33]
|
||||
right_eye = landmarks[263]
|
||||
nose_tip = landmarks[4]
|
||||
|
||||
# 双眼高度差(偏航判定)
|
||||
y_diff = abs(left_eye.y - right_eye.y)
|
||||
|
||||
# 鼻尖偏离双眼中点(偏航/滚转判定)
|
||||
eye_center_x = (left_eye.x + right_eye.x) / 2
|
||||
nose_offset = abs(nose_tip.x - eye_center_x)
|
||||
|
||||
# 阈值根据实际测试调整
|
||||
if y_diff > 0.03 or nose_offset > 0.08:
|
||||
return False # 非正面照
|
||||
return True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 依赖与版本
|
||||
|
||||
```
|
||||
# requirements.txt 新增
|
||||
mediapipe==0.10.14 # 经典 Solutions API(模型内置,无需额外下载)
|
||||
opencv-python==4.10.0 # 图片读取与处理
|
||||
Pillow==11.0.0 # 标注图生成(PNG 透明图层)
|
||||
numpy==2.1.0
|
||||
```
|
||||
|
||||
> MediaPipe 0.10.x 的经典 Solutions API (`mp.solutions.face_mesh`) 仍稳定可用。如需迁移到 Tasks API,后续可平滑升级。
|
||||
|
||||
---
|
||||
|
||||
## 11. 待确认事项
|
||||
|
||||
1. **标注图片设计稿**:需求文档提到需要设计稿确认,当前 UI 规范(字体/颜色/线宽)按文档实现,后续可能需要根据设计师反馈微调
|
||||
2. **男女比例差异**:是否需要在 cm 换算中区分性别(男女脸宽均值不同)?当前使用虹膜直径法天然与性别无关
|
||||
3. **顶庭占比**:22% 为 Mock 数据值,实际部署后是否根据用户反馈调整比例参数
|
||||
4. **非正面照角度阈值**:具体多少度算「角度过大」?建议前期收集测试数据后定阈值
|
||||
|
||||
---
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **创建日期**: 2026-06-13
|
||||
> **依赖模型**: MediaPipe Face Mesh (468 landmarks)
|
||||
> **测量策略**: 实测关键点 + 方案A(人脸比例推算未覆盖区域)
|
||||
Reference in New Issue
Block a user