feat(worker): 接口1 四庭七眼测量真实实现(替换 Mock)

worker 侧从 Mock 替换为真实算法:
- face_analysis 包:detector(MediaPipe 478点) / pose(solvePnP 姿态) /
  calibration(虹膜直径法) / hair_segmenter+bisenet_model(方案B 头发分割) /
  measure(方案A兜底+B/A决策+七眼+换算) / annotation(numpy渐变线+中文标注)
- app.py:/api/v1/face/measure 接真实实现,返回 annotated_image_base64
  (不落盘不拼URL,落盘由网关做);加 X-Internal-Token 鉴权、/health 就绪态、
  可配置分辨率门槛、异常兜底
- 部署:start.sh/run_worker.sh/hair-worker.service 监听 8187;worker_config 示例
- 测试 tests/:Tier1合成真值<1e-6 + Tier2缩放不变 + Tier3叠加 + 错误码集成 +
  数值回归,pytest 24 项全绿
- 文档补实测基线表 + RTX5090/torch 说明

注:worker 为 RTX 5090(sm_120),pinned torch 2.2.2(cu121) 只到 sm_90,
BiSeNet 已自动回退 CPU(方案B 正常);要用 GPU 需换 torch cu128(≥2.7)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
xsl
2026-06-14 16:07:28 +08:00
co-authored by Claude Opus 4.8
parent 3b706ec0ef
commit 8d3b145111
25 changed files with 1806 additions and 35 deletions
+180 -34
View File
@@ -1,15 +1,26 @@
"""旷视五接口 - 第一版(Mock 实现)
"""旷视五接口 — worker 侧(高性能 GPU 后端)。
当前为 Mock 服务,所有接口返回固定示例值,不做真实算法计算
图片字段统一返回 https://hair.xiangsilian.com/static/sample.jpg
接口 1(四庭七眼测量)已为**真实算法实现**(见 face_analysis 包);接口 2~5 仍为 Mock
拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token``/health` 供网关探测不校验。
"""
import base64
import json
import logging
import os
from contextlib import asynccontextmanager
from io import BytesIO
from typing import Any, List, Optional
from fastapi import FastAPI, File, Form, UploadFile
import cv2
import numpy as np
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
logger = logging.getLogger("hair.worker")
# ---------------------------------------------------------------------------
# App & 全局常量
# ---------------------------------------------------------------------------
@@ -17,7 +28,52 @@ from pydantic import BaseModel, Field
BASE_URL = "https://hair.xiangsilian.com"
SAMPLE_IMAGE_URL = f"{BASE_URL}/static/sample.jpg"
# 分辨率门槛(短边/长边,方向无关),可由环境变量覆盖(技术方案 §8.3)
MIN_SHORT_SIDE = int(os.getenv("MIN_SHORT_SIDE", "600"))
MIN_LONG_SIDE = int(os.getenv("MIN_LONG_SIDE", "800"))
MAX_FILE_BYTES = 1_000_000 # 1 MB
# 运行期状态:模型就绪标志(/health 据此返回 200/503
_STATE = {"ready": False}
def _load_accept_passwords() -> List[str]:
"""worker 鉴权密码来源:环境变量 WORKER_ACCEPT_PASSWORDS(逗号分隔)优先,
否则读 worker_config.json 的 accept_passwords 列表。"""
env = os.getenv("WORKER_ACCEPT_PASSWORDS")
if env:
return [p.strip() for p in env.split(",") if p.strip()]
cfg_path = os.path.join(os.path.dirname(__file__), "worker_config.json")
if os.path.isfile(cfg_path):
try:
with open(cfg_path, encoding="utf-8") as f:
return list(json.load(f).get("accept_passwords", []))
except Exception as e: # noqa: BLE001
logger.warning("读取 worker_config.json 失败:%s", e)
return []
ACCEPT_PASSWORDS = _load_accept_passwords()
@asynccontextmanager
async def lifespan(_app: FastAPI):
"""启动时加载模型单例(detector 必需,segmenter 尽力而为),就绪后才放行 /health。"""
from face_analysis.detector import detector # 触发 MediaPipe 单例初始化
_ = detector
try:
from face_analysis.hair_segmenter import get_segmenter
seg = get_segmenter()
logger.info("BiSeNet 头发分割就绪,device=%s", seg.device)
except Exception as e: # noqa: BLE001
# 方案 B 不可用(如 torch 缺失):降级为方案 A only,不阻塞服务
logger.warning("头发分割不可用,接口1 将走方案A兜底:%s", e)
_STATE["ready"] = True
yield
app = FastAPI(
lifespan=lifespan,
title="旷视五接口",
version="0.1.0",
description="""
@@ -77,6 +133,31 @@ app = FastAPI(
app.mount("/static", StaticFiles(directory="static"), name="static")
# 不校验鉴权的路径前缀(供网关探测 / 文档 / 静态)
_AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static")
@app.middleware("http")
async def internal_token_auth(request: Request, call_next):
"""worker 内网鉴权:/api/* 必须带正确的 X-Internal-Token,否则 401。
密码列表来自 worker 配置(accept_passwords)。未配置任何密码时放行(仅便于
本地起步),生产务必通过 worker_config.json / 环境变量配置密码。
"""
path = request.url.path
if path == "/" or path.startswith(_AUTH_EXEMPT):
return await call_next(request)
if path.startswith("/api/") and ACCEPT_PASSWORDS:
token = request.headers.get("X-Internal-Token")
if token not in ACCEPT_PASSWORDS:
return JSONResponse(status_code=401, content={
"code": 1009,
"message": "未授权:缺少或错误的 X-Internal-Token",
"request_id": "worker",
"data": None,
})
return await call_next(request)
# ---------------------------------------------------------------------------
# 通用响应模型
@@ -100,6 +181,41 @@ def err(code: int, message: str) -> dict:
}
# ---------------------------------------------------------------------------
# 图片输入处理(三选一:file / url / base64
# ---------------------------------------------------------------------------
async def resolve_image_bytes(image_file, image_url, image_base64):
"""三选一取图,返回 (raw_bytes, error_response)。
严格互斥:传 0 个或多个 → 1007;URL 下载失败/base64 解码失败 → 1008。
大小校验放到上层(统一 1006),此处只负责取到字节。
"""
provided = [x for x in (image_file, image_url, image_base64) if x]
if len(provided) != 1:
return None, err(1007, "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个")
if image_file is not None:
return await image_file.read(), None
if image_url:
try:
import httpx
with httpx.Client(timeout=10.0, follow_redirects=True) as client:
resp = client.get(image_url)
resp.raise_for_status()
return resp.content, None
except Exception: # noqa: BLE001
return None, err(1008, "图片下载失败或格式不支持")
# base64:去掉 data:image/...;base64, 前缀
try:
b64 = image_base64.split(",", 1)[1] if image_base64.startswith("data:") else image_base64
return base64.b64decode(b64), None
except Exception: # noqa: BLE001
return None, err(1008, "base64 解码失败")
# ---------------------------------------------------------------------------
# 通用图片请求 BodyJSON 方式,用于 url / base64
# ---------------------------------------------------------------------------
@@ -231,36 +347,63 @@ async def face_measure(
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
):
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},
},
}
return ok(data)
# 1. 三选一取图
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
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)
if image is None:
return 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 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 err(1001, "无法识别人像")
# 6. 姿态校验
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. 测量 + 标注图
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 由网关完成)
data = result.to_response()
data["annotated_image_base64"] = base64.b64encode(buf.getvalue()).decode()
return ok(data)
except Exception as ex: # noqa: BLE001
logger.exception("接口1 处理异常")
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
@@ -546,6 +689,9 @@ async def hairline_generate(
@app.get("/health", include_in_schema=False)
async def health():
# 模型就绪后才返回 200,供网关探测;未就绪返回 503。
if not _STATE["ready"]:
return JSONResponse(status_code=503, content={"status": "starting"})
return {"status": "ok"}