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:
@@ -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 解码失败")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 通用图片请求 Body(JSON 方式,用于 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"}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user