2 Commits
Author SHA1 Message Date
xsl 85f1ca521d 修改画线 2026-06-23 22:29:41 +08:00
xslandClaude 8cd44848d6 feat(接口6): 复刻接口1,新增 /api/v1/face/measure-v2
- 抽取 _face_measure_impl() 共用实现,接口1/6 零逻辑差异
- 接口6 路径 POST /api/v1/face/measure-v2
- 入参/出参与接口1 完全一致
- 文档同步更新(接口文档、实现说明、网关待改动)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 20:27:51 +08:00
6 changed files with 402 additions and 134 deletions
+154 -49
View File
@@ -316,9 +316,71 @@ class ImageJsonBody(BaseModel):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 接口 1:四庭七眼测量标注 # 接口 1/6 共用实现
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _face_measure_impl(image_file, image_url, image_base64):
"""接口1/6 共用实现:四庭七眼测量 + 标注图生成。返回 (ok_dict, err_dict)。"""
# 1. 三选一取图
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return None, e
# 2. 大小校验(≤ 1MB
if len(raw) > MAX_FILE_BYTES:
return None, err(1006, "文件超出 1 MB 限制")
# 3. 解码
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if image is None:
return None, err(1008, "图片格式不支持(仅 JPG / PNG)")
# 4. 分辨率(短边/长边,方向无关,可配置门槛)
h, w = image.shape[:2]
short_side, long_side = min(w, h), max(w, h)
if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE:
return None, err(1002, "人像分辨率过低")
try:
from face_analysis.detector import detector
from face_analysis.pose import estimate_head_pose, check_frontal_face
from face_analysis.measure import measure_face
from face_analysis.annotation import create_annotated_image
# 5. 人脸检测
landmarks = detector.detect(image)
if landmarks is None:
return None, err(1001, "无法识别人像")
# 6. 姿态校验
if not check_frontal_face(landmarks, w, h):
return None, err(1003, "角度问题,请上传正面照")
head_pose = estimate_head_pose(landmarks, w, h)
# 7. 头发分割(方案 B),失败传 None 由 measure 内部回退方案 A
hair_mask = None
try:
from face_analysis.hair_segmenter import get_segmenter
hair_mask = get_segmenter().segment_hair(image)
except Exception as seg_e: # noqa: BLE001
logger.warning("头发分割失败,回退方案A%s", seg_e)
# 8. 测量 + 标注图
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
annotated = create_annotated_image(image, result, hair_mask=hair_mask)
buf = BytesIO()
annotated.save(buf, format="PNG")
# 9. 拆分架构:返回 base64,不落盘不拼 URL(落盘改 URL 由网关完成)
data = result.to_response()
data["annotated_image_base64"] = base64.b64encode(buf.getvalue()).decode()
return ok(data), None
except Exception as ex: # noqa: BLE001
logger.exception("接口1/6 处理异常")
return None, err(1007, f"处理失败:{ex}")
@app.post( @app.post(
"/api/v1/face/measure", "/api/v1/face/measure",
summary="接口1 四庭七眼测量标注", summary="接口1 四庭七眼测量标注",
@@ -405,63 +467,106 @@ async def face_measure(
image_url: Optional[str] = Form(default=None, description="图片 URL"), image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"), image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
): ):
# 1. 三选一取图 """接口1:四庭七眼测量标注"""
raw, e = await resolve_image_bytes(image_file, image_url, image_base64) ok_data, err_data = await _face_measure_impl(image_file, image_url, image_base64)
if e is not None: return ok_data if ok_data is not None else err_data
return e
# 2. 大小校验(≤ 1MB
if len(raw) > MAX_FILE_BYTES:
return err(1006, "文件超出 1 MB 限制")
# 3. 解码 # ---------------------------------------------------------------------------
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) # 接口 6:四庭七眼测量标注 v2(复刻接口1)
if image is None: # ---------------------------------------------------------------------------
return err(1008, "图片格式不支持(仅 JPG / PNG)")
# 4. 分辨率(短边/长边,方向无关,可配置门槛) @app.post(
h, w = image.shape[:2] "/api/v1/face/measure-v2",
short_side, long_side = min(w, h), max(w, h) summary="接口6 四庭七眼测量标注 v2",
if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE: tags=["人脸分析"],
return err(1002, "人像分辨率过低") description=f"""
输入用户正面照,返回:
- 标注好四庭七眼数据的 **PNG 图片**(仅标注图层,不含人物)
- 四庭(顶庭/上庭/中庭/下庭)各段**厘米数值及占比**
- 七眼(眼宽/脸宽/两眼间距)**厘米数值及占比**
- 五个关键分界点的**原图像素坐标**(头顶/发际线/眉心/鼻翼下缘/下巴尖)
try: 功能与接口1 完全一致,复刻实现。
from face_analysis.detector import detector
from face_analysis.pose import estimate_head_pose, check_frontal_face
from face_analysis.measure import measure_face
from face_analysis.annotation import create_annotated_image
# 5. 人脸检测 {_image_fields_desc}
landmarks = detector.detect(image)
if landmarks is None:
return err(1001, "无法识别人像")
# 6. 姿态校验 图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`),或 JSON Body 传 `image_url` / `image_base64`。
if not check_frontal_face(landmarks, w, h):
return err(1003, "角度问题,请上传正面照")
head_pose = estimate_head_pose(landmarks, w, h)
# 7. 头发分割(方案 B),失败传 None 由 measure 内部回退方案 A ---
hair_mask = None
try:
from face_analysis.hair_segmenter import get_segmenter
hair_mask = get_segmenter().segment_hair(image)
except Exception as seg_e: # noqa: BLE001
logger.warning("头发分割失败,回退方案A%s", seg_e)
# 8. 测量 + 标注图 **坐标说明**:所有坐标以原图像素为基准,原点为图片左上角,x 向右,y 向下。
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
annotated = create_annotated_image(image, result)
buf = BytesIO()
annotated.save(buf, format="PNG")
# 9. 拆分架构:返回 base64,不落盘不拼 URL(落盘改 URL 由网关完成) **标注图片 UI 规范**(真实版本生效):
data = result.to_response() - 字体/线条颜色:`#FFFFFF 100%`
data["annotated_image_base64"] = base64.b64encode(buf.getvalue()).decode() - 四庭数值在图片**左侧**呈现,七眼间距**上下穿插**展示
return ok(data) - 字体:PingFangSC-Regular 10pt,线宽 1pt
except Exception as ex: # noqa: BLE001 - 横线/竖线渐变消失,虚线两侧带箭头
logger.exception("接口1 处理异常") """,
return err(1007, f"处理失败:{ex}") responses={
200: {
"description": "成功",
"content": {
"application/json": {
"example": {
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"annotated_image_url": SAMPLE_IMAGE_URL,
"face_total_height_cm": 13.76,
"four_courts": {
"top_court_cm": 3.44,
"upper_court_cm": 3.44,
"middle_court_cm": 3.44,
"lower_court_cm": 3.44,
"ratios": {
"top_court": 0.25,
"upper_court": 0.25,
"middle_court": 0.25,
"lower_court": 0.25,
},
},
"seven_eyes": {
"eye_width_cm": 3.44,
"face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44,
"ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143},
},
"landmarks": {
"hair_top": {"x": 540, "y": 120},
"hairline": {"x": 540, "y": 430},
"brow_center": {"x": 540, "y": 740},
"nose_bottom": {"x": 540, "y": 1050},
"chin_tip": {"x": 540, "y": 1360},
},
},
}
}
},
},
400: {
"description": "参数错误 / 图片识别失败",
"content": {
"application/json": {
"examples": {
"图片参数错误": {"value": {"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个", "request_id": "x", "data": None}},
"无法识别人像": {"value": {"code": 1001, "message": "无法识别人像", "request_id": "x", "data": None}},
"多张人脸": {"value": {"code": 1005, "message": "检测到多张人脸,仅支持单人照片", "request_id": "x", "data": None}},
}
}
},
},
},
)
async def face_measure_v2(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
):
"""接口6:四庭七眼测量标注 v2(复刻接口1)"""
ok_data, err_data = await _face_measure_impl(image_file, image_url, image_base64)
return ok_data if ok_data is not None else err_data
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+7 -1
View File
@@ -13,7 +13,7 @@
``` ```
客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py) 客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py)
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI) │ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI)
└ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/7 └ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/6/7
``` ```
- **worker**`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,**纯本地、无外网依赖**。 - **worker**`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,**纯本地、无外网依赖**。
@@ -29,6 +29,7 @@
| 接口 | worker 字段(内部) | 对外字段 | | 接口 | worker 字段(内部) | 对外字段 |
|------|--------------------|----------| |------|--------------------|----------|
| 1 | `annotated_image_base64` | `annotated_image_url` | | 1 | `annotated_image_base64` | `annotated_image_url` |
| 6 | `annotated_image_base64` | `annotated_image_url` |
| 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_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` | | 3 | `hair_growth_image_base64`(可空) | `hair_growth_image_url` |
| 5 | `hairline_images[].image_base64` | `hairline_images[].image_url` | | 5 | `hairline_images[].image_base64` | `hairline_images[].image_url` |
@@ -55,6 +56,11 @@
失败回退比例推算(方案A`hairline_source` 透出)。标注图 numpy 向量化渐变线 + 思源黑体。返回 `annotated_image_base64` 失败回退比例推算(方案A`hairline_source` 透出)。标注图 numpy 向量化渐变线 + 思源黑体。返回 `annotated_image_base64`
- 门槛可配:`MIN_SHORT_SIDE`/`MIN_LONG_SIDE`(默认600/800)、姿态阈值 `FRONTAL_*_THR`(默认30°)。 - 门槛可配:`MIN_SHORT_SIDE`/`MIN_LONG_SIDE`(默认600/800)、姿态阈值 `FRONTAL_*_THR`(默认30°)。
### 接口6 四庭七眼测量 v2 `/api/v1/face/measure-v2`worker)—— 复刻接口1
- **做什么**:与接口 1 完全一致(正面照 → 四庭七眼 cm 与占比 + 5 个关键点 + 标注 PNG)。
- **怎么实现**:与接口 1 共用 `_face_measure_impl()`,零额外逻辑。对外路径 `/api/v1/face/measure-v2`
- **网关改动**:新增路由 `POST /api/v1/face/measure-v2`,转发到 worker 同路径;base64→URL 改写无需改动。
### 接口2 C端生发 `/api/v1/hair/grow`worker)—— 预览 + 生发图 ### 接口2 C端生发 `/api/v1/hair/grow`worker)—— 预览 + 生发图
- **做什么**:正面照 + `gender`(必填) + `hair_style`(必填,逗号分隔多选,如 `1,2,3`) → 指定发际线类型 **N 组**:**预览图**(发际线叠在照片上) + **生发后图**(植发3个月效果)。 - **做什么**:正面照 + `gender`(必填) + `hair_style`(必填,逗号分隔多选,如 `1,2,3`) → 指定发际线类型 **N 组**:**预览图**(发际线叠在照片上) + **生发后图**(植发3个月效果)。
- **怎么实现**`hairline/`):移植 head3d——MediaPipe(Tasks) + SegFormer 分割 + 17 锚点射线检测 → 502 点 mesh, - **怎么实现**`hairline/`):移植 head3d——MediaPipe(Tasks) + SegFormer 分割 + 17 锚点射线检测 → 502 点 mesh,
+54 -1
View File
@@ -15,6 +15,7 @@
| 接口 | 方法 | 路径 | | 接口 | 方法 | 路径 |
|------|------|------| |------|------|------|
| 1 四庭七眼测量 | POST | `/api/v1/face/measure` | | 1 四庭七眼测量 | POST | `/api/v1/face/measure` |
| 6 四庭七眼测量 v2 | POST | `/api/v1/face/measure-v2` |
| 2 C 端生发 | POST | `/api/v1/hair/grow` | | 2 C 端生发 | POST | `/api/v1/hair/grow` |
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` | | 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
| 4 用户特征 | POST | `/api/v1/face/features` | | 4 用户特征 | POST | `/api/v1/face/features` |
@@ -176,6 +177,57 @@
--- ---
## 接口 6:四庭七眼测量 v2 接口
**说明**:功能与[接口 1](#接口-1四庭七眼测量标注接口)完全一致,复刻实现。输入用户正面照,返回四庭七眼测量数据和标注 PNG。
**请求**`POST /api/v1/face/measure-v2`
### 输入
与接口 1 完全相同。图片参数见「通用约定 → 图片传参字段」(`image_file` / `image_url` / `image_base64` 三选一)。本接口无其他专属参数。
### 输出(data
与接口 1 完全相同。详见[接口 1 输出](#接口-1四庭七眼测量标注接口)。
| 字段 | 类型 | 说明 |
|------|------|------|
| annotated_image_url | string | 标注图层 PNG URL(透明底,仅标注线/文字,不含人物) |
| face_total_height_cm | number | 面部总高度(cm |
| four_courts | object | 四庭数据(顶庭/上庭/中庭/下庭,各含 cm 与 ratio |
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距,各含 cm 与 ratio) |
| landmarks | object | 五个关键点像素坐标 |
### 响应示例
```json
{
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"annotated_image_url": "https://hair.xiangsilian.com/static/sample.jpg",
"face_total_height_cm": 13.76,
"four_courts": {
"top_court_cm": 3.44, "upper_court_cm": 3.44, "middle_court_cm": 3.44, "lower_court_cm": 3.44,
"ratios": { "top_court": 0.25, "upper_court": 0.25, "middle_court": 0.25, "lower_court": 0.25 }
},
"seven_eyes": {
"eye_width_cm": 3.44, "face_width_cm": 24.08, "inter_eye_distance_cm": 3.44,
"ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 }
},
"landmarks": {
"hair_top": { "x": 540, "y": 120 }, "hairline": { "x": 540, "y": 430 },
"brow_center": { "x": 540, "y": 740 }, "nose_bottom": { "x": 540, "y": 1050 },
"chin_tip": { "x": 540, "y": 1360 }
}
}
}
```
---
## 接口 2C 端生发接口 ## 接口 2C 端生发接口
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染预览图 + 生发图。 **说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染预览图 + 生发图。
@@ -421,7 +473,8 @@
| 接口 | 输入 | 主要输出 | | 接口 | 输入 | 主要输出 |
|------|------|----------| |------|------|----------|
| 1 四庭七眼测量 | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 | | 1 四庭七眼测量 | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 |
| 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单张) | | 6 四庭七眼测量 v2 | 用户照片 | 同接口1,复刻实现 |
| 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单/多张) |
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 | | 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) | | 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
| 5 发际线 PNG | 用户照片 | N 张发际线 PNG + 最合适发际线面部中间点坐标 | | 5 发际线 PNG | 用户照片 | N 张发际线 PNG + 最合适发际线面部中间点坐标 |
+24
View File
@@ -109,6 +109,30 @@ async def hair_grow_v2(request: Request):
若想让网关 `/docs` 展示准确,在 `gateway/app.py` 新增 `_GROW_V2_FORMS`(或复用 `_GROW_FORMS` 并补充 `gender`/`hair_style` 字段),然后将路由函数签名改为显式声明 Form 参数(参考接口 4 的写法)。不改也不影响实际转发。 若想让网关 `/docs` 展示准确,在 `gateway/app.py` 新增 `_GROW_V2_FORMS`(或复用 `_GROW_FORMS` 并补充 `gender`/`hair_style` 字段),然后将路由函数签名改为显式声明 Form 参数(参考接口 4 的写法)。不改也不影响实际转发。
---
## 6. 🔲【新增】接口6 四庭七眼测量 v2`/api/v1/face/measure-v2`
**背景**:worker 侧已新增接口 6,功能与接口 1 完全一致(复刻),共用同一实现。
**网关需新增一个路由**
```python
# gateway/app.py
@app.post("/api/v1/face/measure-v2", tags=["人脸分析"])
async def face_measure_v2(request: Request):
"""接口6:四庭七眼测量 v2(复刻接口1)"""
return await _proxy(request, "/api/v1/face/measure-v2")
```
**无需额外改动**
- 入参:与接口 1 完全相同(image_file/url/base64 三选一)
- 出参:`annotated_image_base64` → 经现有 `rewrite_base64_to_url` 自动改写为 `annotated_image_url`
- worker 侧与接口 1 共用 `_face_measure_impl()`,逻辑零差异
--- ---
## 已经做好、无需再动的 ## 已经做好、无需再动的
+163 -83
View File
@@ -1,9 +1,13 @@
"""标注图层生成(透明底 RGBA PNG,仅标注、不含人物)。 """标注图层生成(透明底 RGBA PNG,仅标注、不含人物)。
规格(技术方案 §6):线/字色 #FFFFFF、字体 10pt、线宽 1pt、透明底。 规格(技术方案 §6 + img1.png 版本5):线/字色 #FFFFFF、透明底。
- 字号/线宽/虚线/箭头尺寸全部按图片尺寸自适应缩放(大图也清晰)。
- 四庭水平分界线:numpy 向量化渐变消失(中间亮、两侧渐隐)。 - 四庭水平分界线:numpy 向量化渐变消失(中间亮、两侧渐隐)。
- 四庭 cm 数值:图片左侧。 - 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右,
- 七眼标注:眼宽/两眼间距/脸宽,虚线箭头,标签上下穿插 把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线箭头。
- 四庭:图片左侧,「名」上「数值」下两行换行(不带 cm),带竖向虚线双箭头。
- 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。
- 单位 cm 统一标在底部「单位cm」。
中文字体用打包的思源黑体绝对路径加载,缺字体直接抛错(不静默降级成方块)。 中文字体用打包的思源黑体绝对路径加载,缺字体直接抛错(不静默降级成方块)。
""" """
import os import os
@@ -12,44 +16,39 @@ import numpy as np
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
FONT_PATH = os.path.join(os.path.dirname(__file__), "fonts", "NotoSansCJKsc-Regular.otf") FONT_PATH = os.path.join(os.path.dirname(__file__), "fonts", "NotoSansCJKsc-Regular.otf")
FONT_SIZE = 10
LINE_COLOR = (255, 255, 255, 255) # #FFFFFF 100% LINE_COLOR = (255, 255, 255, 255) # #FFFFFF 100%
LINE_WIDTH = 1
def _load_font(): def _load_font(size):
if not os.path.isfile(FONT_PATH): if not os.path.isfile(FONT_PATH):
raise FileNotFoundError(f"中文字体缺失:{FONT_PATH}(请按 OFFLINE_ASSETS.md 放置)") raise FileNotFoundError(f"中文字体缺失:{FONT_PATH}(请按 OFFLINE_ASSETS.md 放置)")
return ImageFont.truetype(FONT_PATH, FONT_SIZE) return ImageFont.truetype(FONT_PATH, size)
def draw_gradient_horizontal_line(buf, cx, cy, color=LINE_COLOR, half_length=None): def draw_gradient_horizontal_line(buf, cx, cy, color=LINE_COLOR, half_length=None, width=1):
"""在 RGBA numpy 缓冲 buf 上,以 (cx,cy) 为中心画向两侧渐变消失的水平线。 """在 RGBA numpy 缓冲 buf 上,以 (cx,cy) 为中心画向两侧渐变消失的水平线。"""
numpy 向量化:一次性算整行 alpha,避免逐像素 draw.point。
"""
h, w = buf.shape[:2] h, w = buf.shape[:2]
cy = int(round(cy)); cx = int(round(cx)) cy = int(round(cy)); cx = int(round(cx))
if not (0 <= cy < h):
return
half = half_length or (w // 3) half = half_length or (w // 3)
xs = np.arange(w) xs = np.arange(w)
dist = np.abs(xs - cx) dist = np.abs(xs - cx)
alpha = np.clip(1.0 - dist / half, 0.0, 1.0) * color[3] alpha = np.clip(1.0 - dist / half, 0.0, 1.0) * color[3]
mask = alpha > 0 mask = alpha > 0
row = buf[cy] for off in range(-(width // 2), width - width // 2):
row[mask, 0] = color[0] y = cy + off
row[mask, 1] = color[1] if not (0 <= y < h):
row[mask, 2] = color[2] continue
row[mask, 3] = np.maximum(row[mask, 3], alpha[mask].astype(np.uint8)) row = buf[y]
row[mask, 0] = color[0]
row[mask, 1] = color[1]
row[mask, 2] = color[2]
row[mask, 3] = np.maximum(row[mask, 3], alpha[mask].astype(np.uint8))
def draw_gradient_vertical_line(buf, cx, y0, y1, color=LINE_COLOR, fade=None): def draw_gradient_vertical_line(buf, cx, y0, y1, color=LINE_COLOR, fade=None, width=1):
"""在 RGBA numpy 缓冲 buf 上画一条竖线,两端渐变消失(中间实、上下淡)。""" """在 RGBA numpy 缓冲 buf 上画一条竖线,两端渐变消失(中间实、上下淡)。"""
h, w = buf.shape[:2] h, w = buf.shape[:2]
cx = int(round(cx)) cx = int(round(cx))
if not (0 <= cx < w):
return
y0, y1 = int(round(y0)), int(round(y1)) y0, y1 = int(round(y0)), int(round(y1))
y0, y1 = max(0, min(y0, y1)), min(h - 1, max(y0, y1)) y0, y1 = max(0, min(y0, y1)), min(h - 1, max(y0, y1))
if y1 <= y0: if y1 <= y0:
@@ -59,37 +58,48 @@ def draw_gradient_vertical_line(buf, cx, y0, y1, color=LINE_COLOR, fade=None):
fade = fade or max(1, span // 5) # 仅两端 ~1/5 段渐隐 fade = fade or max(1, span // 5) # 仅两端 ~1/5 段渐隐
d = np.minimum(ys - y0, y1 - ys) # 到最近端点的距离 d = np.minimum(ys - y0, y1 - ys) # 到最近端点的距离
alpha = np.clip(d / fade, 0.0, 1.0) * color[3] alpha = np.clip(d / fade, 0.0, 1.0) * color[3]
col = buf[y0:y1 + 1, cx]
m = alpha > 0 m = alpha > 0
col[m, 0] = color[0] for off in range(-(width // 2), width - width // 2):
col[m, 1] = color[1] x = cx + off
col[m, 2] = color[2] if not (0 <= x < w):
col[m, 3] = np.maximum(col[m, 3], alpha[m].astype(np.uint8)) continue
col = buf[y0:y1 + 1, x]
col[m, 0] = color[0]
col[m, 1] = color[1]
col[m, 2] = color[2]
col[m, 3] = np.maximum(col[m, 3], alpha[m].astype(np.uint8))
def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color=LINE_COLOR, def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color=LINE_COLOR,
dash_len=6, gap_len=4, arrow_size=5): dash_len=6, gap_len=4, arrow_size=5, width=1):
"""两点间画虚线,两端带箭头(等腰三角)。""" """两点间画稀疏虚线主干,两端用实心三角箭头(尖端精确落在端点,便于对齐)。
虚线只画到「端点向内 arrow_len」处,箭头三角填补剩余,避免虚线穿出箭头。
"""
total = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5 total = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
if total == 0: if total == 0:
return return
dx = (x2 - x1) / total dx = (x2 - x1) / total
dy = (y2 - y1) / total dy = (y2 - y1) / total
pos = 0.0 nx, ny = -dy, dx # 法向量
while pos < total: arrow_len = arrow_size * 2.0 # 三角沿线方向长度
seg_end = min(pos + dash_len, total) arrow_half = arrow_size # 三角底边半宽
# 主干虚线:两端各留出 arrow_len 给箭头
pos = arrow_len
main_end = max(arrow_len, total - arrow_len)
while pos < main_end:
seg_end = min(pos + dash_len, main_end)
draw.line([(x1 + dx * pos, y1 + dy * pos), draw.line([(x1 + dx * pos, y1 + dy * pos),
(x1 + dx * seg_end, y1 + dy * seg_end)], fill=color, width=LINE_WIDTH) (x1 + dx * seg_end, y1 + dy * seg_end)], fill=color, width=width)
pos += dash_len + gap_len pos += dash_len + gap_len
# 法向量(用于箭头两翼张开)
nx, ny = -dy, dx # 两端实心三角箭头:尖端=端点,底边在向内 arrow_len 处展开 ±arrow_half
for (ex, ey, sdx, sdy) in [(x1, y1, dx, dy), (x2, y2, -dx, -dy)]: for (tipx, tipy, ix, iy) in [(x1, y1, dx, dy), (x2, y2, -dx, -dy)]:
p1 = (ex + sdx * arrow_size + nx * arrow_size * 0.6, bx, by = tipx + ix * arrow_len, tipy + iy * arrow_len
ey + sdy * arrow_size + ny * arrow_size * 0.6) draw.polygon([(tipx, tipy),
p2 = (ex + sdx * arrow_size - nx * arrow_size * 0.6, (bx + nx * arrow_half, by + ny * arrow_half),
ey + sdy * arrow_size - ny * arrow_size * 0.6) (bx - nx * arrow_half, by - ny * arrow_half)], fill=color)
draw.line([p1, (ex, ey)], fill=color, width=LINE_WIDTH)
draw.line([p2, (ex, ey)], fill=color, width=LINE_WIDTH)
def _text_size(draw, text, font): def _text_size(draw, text, font):
@@ -106,20 +116,57 @@ _LINE_NAMES = {
} }
def create_annotated_image(image_bgr, measure_result): def _head_edges_from_mask(hair_mask, y0, y1, left_cheek_x, right_cheek_x, min_gap):
"""从头发分割掩膜取人头最左/最右 x(仅在脸纵向范围 [y0,y1] 内统计)。
返回 (head_left_x, head_right_x);某侧无掩膜/向内噪声,或离脸颊线过近
(间距 < min_gap)则该侧为 None —— 即与脸颊线太近时只保留脸颊线。
"""
if hair_mask is None:
return None, None
m = np.asarray(hair_mask)
if m.ndim == 3:
m = m[..., 0]
h = m.shape[0]
y0 = max(0, int(y0)); y1 = min(h - 1, int(y1))
if y1 <= y0:
return None, None
band = m[y0:y1 + 1] > 0
cols = np.where(band.any(axis=0))[0]
if cols.size == 0:
return None, None
hl, hr = float(cols.min()), float(cols.max())
# 仅当确实在脸颊外侧、且与脸颊线间距足够大时才采用
return (hl if (left_cheek_x - hl) >= min_gap else None,
hr if (hr - right_cheek_x) >= min_gap else None)
def create_annotated_image(image_bgr, measure_result, hair_mask=None):
"""生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。 """生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。
布局: 布局(对齐 img1.png 版本5
- 线条只覆盖人脸范围(横线=脸宽,竖线=脸高),渐变消失。 - 纵向竖线:人头最左 + 七眼 6 点 + 人头最右,切 7 段(七眼)。人头最左/最右
- 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,**线名在右侧**; 取自头发分割掩膜的轮廓(方案 B);无掩膜(方案 A 兜底)时省略头部端线,
四庭 cm 数值(顶庭/上庭/中庭/下庭)在左侧各段中点 只画 6 点 5 段
- 6线:左脸颊/左眼外角/左眼内角/右眼内角/右眼外角/右脸颊,把脸宽切 5 段; - 5分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。
每段宽度在脸的**上端和下端**各标一次(只标 `X.XXcm`,不写名) - 四庭(顶/上/中/下庭)在左侧:名 + 数值两行换行(无 cm),竖向虚线双箭头
- 七眼段宽数值上下交替(上 3 / 下 4,无 cm),横向虚线双箭头。
- 底部统一标「单位cm」。
""" """
h, w = image_bgr.shape[:2] h, w = image_bgr.shape[:2]
v = measure_result.vertical v = measure_result.vertical
pc = measure_result.px_per_cm pc = measure_result.px_per_cm
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
s = min(w, h)
font_size = max(11, round(s * 0.026)) # 字体更小
line_w = max(1, round(s * 0.0022))
dash_len = max(4, round(s * 0.013))
gap_len = max(4, round(dash_len * 1.2)) # 虚线更稀疏(间隙>划线)
arrow_size = max(2, round(s * 0.007)) # 箭头更小
pad = max(4, round(s * 0.012)) # 文字与线的间距
line_h = font_size + max(2, round(font_size * 0.18))
buf = np.zeros((h, w, 4), dtype=np.uint8) buf = np.zeros((h, w, 4), dtype=np.uint8)
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"] order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
@@ -128,60 +175,93 @@ def create_annotated_image(image_bgr, measure_result):
pts = measure_result.eyes["points"] pts = measure_result.eyes["points"]
seven_keys = ["left_cheek", "left_outer", "left_inner", seven_keys = ["left_cheek", "left_outer", "left_inner",
"right_inner", "right_outer", "right_cheek"] "right_inner", "right_outer", "right_cheek"]
xs = sorted(pts[k][0] for k in seven_keys) # 自左向右 base_xs = [pts[k][0] for k in seven_keys]
# 人头最左/最右:取自头发分割掩膜(方案B),脸纵向范围内统计;无掩膜则省略。
# 与脸颊线间距 < 脸宽×8% 视为太近,只保留脸颊线(不画头部端线)。
face_w = pts["right_cheek"][0] - pts["left_cheek"][0]
min_gap = max(1.0, face_w * 0.08)
head_l, head_r = _head_edges_from_mask(
hair_mask, ys[0], ys[-1], pts["left_cheek"][0], pts["right_cheek"][0], min_gap)
head_xs = [x for x in (head_l, head_r) if x is not None]
xs = sorted(base_xs + head_xs) # 自左向右(6 或 7/8 点)
# 人脸包围盒:x 为脸宽(左右脸颊),y 为脸高(头顶→下巴) # 人脸/人头包围盒
fx0, fx1 = xs[0], xs[-1] fx0, fx1 = xs[0], xs[-1]
fy0, fy1 = ys[0], ys[-1] fy0, fy1 = ys[0], ys[-1]
face_cx = (fx0 + fx1) / 2 face_cx = (fx0 + fx1) / 2
face_half = (fx1 - fx0) / 2 * 1.08 # 略放大确保横线覆盖到脸颊 over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
# --- 1. 横向 5 条分界线(渐变,覆盖脸宽 --- # --- 1. 横向 5 条分界线(渐变,覆盖头宽并超出一点 ---
for cy in ys: for cy in ys:
draw_gradient_horizontal_line(buf, face_cx, cy, half_length=face_half) draw_gradient_horizontal_line(buf, face_cx, cy, half_length=face_half, width=line_w)
# --- 2. 纵向 6 条线(渐变,覆盖脸高 头顶下巴) --- # --- 2. 纵向线(渐变,超出头顶/下巴一点 ---
for vx in xs: for vx in xs:
draw_gradient_vertical_line(buf, vx, fy0, fy1) draw_gradient_vertical_line(buf, vx, fy0 - over, fy1 + over, width=line_w)
canvas = Image.fromarray(buf, mode="RGBA") canvas = Image.fromarray(buf, mode="RGBA")
draw = ImageDraw.Draw(canvas) draw = ImageDraw.Draw(canvas)
font = _load_font() font = _load_font(font_size)
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖) --- # --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字在线上方 ---
name_x = fx1 + 8 name_x = fx1 + pad
name_gap = max(2, round(pad * 0.5)) # 文字底部到线的间距
for i, name in enumerate(order): for i, name in enumerate(order):
text = _LINE_NAMES[name] text = _LINE_NAMES[name]
tw, _ = _text_size(draw, text, font) tw, th = _text_size(draw, text, font)
x = min(name_x, w - 2 - tw) # 右侧越界时回收 x = min(name_x, w - 2 - tw) # 右侧越界时回收
draw.text((x, ys[i] - FONT_SIZE / 2), text, fill=LINE_COLOR, font=font) draw.text((x, max(2, ys[i] - th - name_gap)), text, fill=LINE_COLOR, font=font)
# --- 3b. 横线左侧四庭 cm 数值(各段中点,右对齐到脸盒左缘) --- # --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
court_cm = [measure_result.top_cm, measure_result.upper_cm, court_cm = [measure_result.top_cm, measure_result.upper_cm,
measure_result.middle_cm, measure_result.lower_cm] measure_result.middle_cm, measure_result.lower_cm]
court_name = ["顶庭", "上庭", "中庭", "下庭"] court_name = ["顶庭", "上庭", "中庭", "下庭"]
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
for i in range(4): for i in range(4):
text = f"{court_name[i]} {court_cm[i]:.2f}cm" y_a, y_b = ys[i], ys[i + 1]
tw, _ = _text_size(draw, text, font) # 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
x = max(2, fx0 - 8 - tw) # 贴脸盒左缘,右对齐 inset = min(arrow_size, (y_b - y_a) * 0.12)
y_mid = (ys[i] + ys[i + 1]) / 2 - FONT_SIZE / 2 draw_dashed_line_with_arrows(
draw.text((x, y_mid), text, fill=LINE_COLOR, font=font) draw, arrow_x, y_a + inset, arrow_x, y_b - inset,
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
# 名 + 数值两行,右对齐到箭头左侧
name = court_name[i]
val = f"{court_cm[i]:.2f}"
nw, _ = _text_size(draw, name, font)
vw, _ = _text_size(draw, val, font)
label_right = arrow_x - pad
y_mid = (y_a + y_b) / 2
y_top = y_mid - line_h
draw.text((max(2, label_right - nw), y_top), name, fill=LINE_COLOR, font=font)
draw.text((max(2, label_right - vw), y_top + line_h), val, fill=LINE_COLOR, font=font)
# --- 4. 七眼每段宽度:脸的上端 + 下端各标一次(相邻段上下错行防重叠 --- # --- 4. 七眼每段宽度:上下交替(上 3 / 下 4),横向虚线双箭头 + 数值(无 cm ---
row_h = FONT_SIZE + 2 # 文字与箭头间留出「箭头高度 + pad」,避免文字压住箭头
y_top_a = max(1, fy0 - row_h - 2) # 上端:头顶线上方两行 txt_off = arrow_size + pad
y_top_b = max(1, fy0 - 2 * row_h - 2) y_arrow_top = max(txt_off + font_size + 2, fy0 - pad - arrow_size)
y_bot_a = min(h - FONT_SIZE - 1, fy1 + 2) # 下端:下巴线下方两行 y_arrow_bot = min(h - txt_off - font_size - 2, fy1 + pad + arrow_size)
y_bot_b = min(h - FONT_SIZE - 1, fy1 + row_h + 2)
for i in range(len(xs) - 1): for i in range(len(xs) - 1):
seg_cm = (xs[i + 1] - xs[i]) / pc x_a, x_b = xs[i], xs[i + 1]
cx_seg = (xs[i] + xs[i + 1]) / 2 if x_b - x_a < 1:
text = f"{seg_cm:.2f}cm" continue
tw, _ = _text_size(draw, text, font) seg_cm = (x_b - x_a) / pc
ty_top = y_top_a if i % 2 == 0 else y_top_b cx_seg = (x_a + x_b) / 2
ty_bot = y_bot_a if i % 2 == 0 else y_bot_b text = f"{seg_cm:.2f}"
draw.text((cx_seg - tw / 2, ty_top), text, fill=LINE_COLOR, font=font) tw, th = _text_size(draw, text, font)
draw.text((cx_seg - tw / 2, ty_bot), text, fill=LINE_COLOR, font=font) inset = min(arrow_size, (x_b - x_a) * 0.12)
on_top = (i % 2 == 1) # 奇数段在上 → 上 3 / 下 4
y_arrow = y_arrow_top if on_top else y_arrow_bot
draw_dashed_line_with_arrows(
draw, x_a + inset, y_arrow, x_b - inset, y_arrow,
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
ty = (y_arrow - th - txt_off) if on_top else (y_arrow + txt_off)
draw.text((cx_seg - tw / 2, ty), text, fill=LINE_COLOR, font=font)
# --- 5. 底部统一单位 ---
unit = "单位cm"
uw, uh = _text_size(draw, unit, font)
draw.text(((w - uw) / 2, h - uh - max(2, pad)), unit, fill=LINE_COLOR, font=font)
return canvas return canvas
@@ -213,7 +293,7 @@ if __name__ == "__main__":
result = measure_face(lms, mask, w, h) result = measure_face(lms, mask, w, h)
t0 = time.time() t0 = time.time()
canvas = create_annotated_image(img, result) canvas = create_annotated_image(img, result, hair_mask=mask)
dt = time.time() - t0 dt = time.time() - t0
os.makedirs(os.path.dirname(out), exist_ok=True) os.makedirs(os.path.dirname(out), exist_ok=True)
canvas.save(out) canvas.save(out)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB