接口9:头发遮罩生成 + 分步可视化

新增 POST /api/v1/head/mask(worker + 网关代理)与测试页 test_interface9.html:
- MediaPipe 关键点连成额头分割线(21,68,104,69,108,151,337,299,333,298,251,
  左端21/右端251 水平延伸到图片边缘),分割线以上为上半区。
- 头发分割 BiSeNet 与 SegFormer 两套并排对比;每列从最顶端头发向下填充到分割线,
  得到含额头的闭合区域(不从发际线割断)。
- 外缘朝中心点151内缩 erode_cm(默认1.2cm,页面可调,虹膜标定换算像素)、底线不动。
- 复用现有 detector/hair_segmenter/SegFormer 单例(只读推理),无新依赖;纯新增,
  不改动既有接口。

顺带修复接口2 遗留测试 test_grow_female_returns_5:hair_style 自 cb1989c 起必填,
补上 hair_style=1,2,3,4,5。全套 42 passed。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
xsl
2026-07-07 23:09:29 +08:00
co-authored by Claude Opus 4.8
parent aa981229c0
commit 9774997035
5 changed files with 595 additions and 1 deletions
+85
View File
@@ -1061,6 +1061,91 @@ async def hairline_generate(
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 接口 9:头发遮罩生成
# ---------------------------------------------------------------------------
@app.post(
"/api/v1/head/mask",
summary="接口9 头发遮罩生成",
tags=["人脸分析"],
description=f"""
输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试):
1. MediaPipe 关键点 → 底部分割线(关键点 21,68,104,69,108,151,337,299,333,298,251 的连线,
21 水平延伸到最左边、251 延伸到最右边)。
2. 分割线以上为「上半区」。
3. 头发分割(**BiSeNet** 与 **SegFormer** 两套);从每列最顶端头发向下填充到分割线,
得到**含额头**的闭合区域(头发+额头,不割断)。
4. 外缘朝中心点 151 内缩 **erode_cm(默认 1.2cm,可调)**(虹膜标定换算像素)、底线不动 → 最终遮罩。
{_image_fields_desc}
返回 `data` 内含 `steps_common`(关键点/分割线、上半区)与 `bisenet` / `segformer`
两组结果(各含 `hair_mask` / `closed_region` / `final_overlay` / `mask`)。经网关时所有
`*_base64` 图片字段会被落盘改写为 `*_url`。
""",
responses={
200: {
"description": "成功",
"content": {
"application/json": {
"example": {
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"px_per_cm": 48.12,
"erode_cm": 1.2,
"erode_px": 58,
"image_size": {"width": 1080, "height": 1440},
"steps_common": {"landmarks_baseline_url": SAMPLE_IMAGE_URL,
"upper_region_url": SAMPLE_IMAGE_URL},
"bisenet": {"hair_pixels": 123456, "closed_pixels": 110000, "mask_pixels": 98765,
"hair_mask_url": SAMPLE_IMAGE_URL,
"closed_region_url": SAMPLE_IMAGE_URL,
"final_overlay_url": SAMPLE_IMAGE_URL,
"mask_url": SAMPLE_IMAGE_URL},
"segformer": {"hair_pixels": 130000, "closed_pixels": 112000, "mask_pixels": 99000,
"hair_mask_url": SAMPLE_IMAGE_URL,
"closed_region_url": SAMPLE_IMAGE_URL,
"final_overlay_url": SAMPLE_IMAGE_URL,
"mask_url": SAMPLE_IMAGE_URL},
},
}
}
},
},
},
)
async def head_mask(
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, 前缀)"),
erode_cm: float = Form(default=1.2, description="外缘朝中心151内缩的距离(厘米),默认 1.2"),
):
"""接口9:头发遮罩生成 + 分步可视化"""
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.head_mask import generate_head_mask, NoFaceError
try:
data = await run_in_threadpool(generate_head_mask, image, erode_cm)
except NoFaceError:
return err(1001, "无法识别人像")
return ok(data)
except Exception as ex: # noqa: BLE001
logger.exception("接口9 处理异常")
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 健康检查
# ---------------------------------------------------------------------------