fix(接口4): worker 移除脸型 Mock + face_shape 改本机 MediaPipe 计算
- worker /api/v1/face/features 不再返回假成功数据,直接告知仅网关实现, 避免本机误打 :8187 被 Mock 结果误导。 - 网关 ark_api_key 加载优先级改为 gateway/config.json 优先(原先误读 worker_config.json 里的失效 key)。 - 接口4 face_shape 不再采信豆包结果,改用本机 face/face_shape_classifier.py (MediaPipe 7 类)计算覆盖;其余 5 项特征仍走豆包。 - 修复 face_shape_classifier 共享 FaceMesh 实例的线程安全问题(加锁), 避免网关侧接口4 并发请求时崩溃/结果错乱。 - 新增 /api/v1/debug/face-shape 调试接口 + static/test_face_shape.html 单图调试页(worker 侧)。 - 更新文档:网关机现在也需要 mediapipe/opencv-python/numpy<2。 ⚠️ 部署前提醒:网关机需先安装 mediapipe==0.10.14 / opencv-python==4.10.0.84 / numpy==1.26.4,否则接口4 会返回 1007「分析服务异常」。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"""旷视五接口 — worker 侧(高性能 GPU 后端)。
|
||||
|
||||
接口 1(四庭七眼测量)已为**真实算法实现**(见 face_analysis 包);接口 2~5 仍为 Mock。
|
||||
接口 1 等算法在 worker;接口4(用户特征)已迁到网关本机(调豆包),worker 同路径只返回明确错误、无 Mock。
|
||||
拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)。
|
||||
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token`,`/health` 供网关探测不校验。
|
||||
"""
|
||||
@@ -885,56 +885,28 @@ async def hair_grow_b(
|
||||
|
||||
@app.post(
|
||||
"/api/v1/face/features",
|
||||
summary="接口4 用户特征分析",
|
||||
summary="接口4 用户特征分析(仅网关)",
|
||||
tags=["人脸分析"],
|
||||
description=f"""
|
||||
输入用户照片,返回 N 个用户面部特征字段。
|
||||
description="""
|
||||
**本接口不在 worker 实现。** 请调用网关(本机默认 `http://127.0.0.1:8080`)的同路径;
|
||||
网关本机调火山方舟豆包视觉模型,不转发到 worker。
|
||||
|
||||
{_image_fields_desc}
|
||||
|
||||
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
|
||||
|
||||
---
|
||||
|
||||
由**火山方舟 豆包视觉模型**分析,返回**固定 6 个英文字段**。
|
||||
|
||||
**返回格式**:`data.features` 为一个 **JSON 字符串**(不是对象),需要在客户端 `JSON.parse()` 后使用。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| face_shape | 脸形(如"鹅蛋脸") |
|
||||
| eyebrow_shape | 眉形(如"平眉") |
|
||||
| facial_age | 面部年龄(区间,如"18-25岁") |
|
||||
| dynamic_static_type | 动静类型("静态型"/"动态型") |
|
||||
| gender | 性别("男"/"女") |
|
||||
| gene_style | 基因风格(如"自然型") |
|
||||
|
||||
> 无人脸返回 `1001`。
|
||||
直接打 worker(如 `:8187`)会返回错误,避免误用假数据。
|
||||
""",
|
||||
responses={
|
||||
200: {
|
||||
"description": "成功",
|
||||
"description": "worker 不提供本接口",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"code": 1007,
|
||||
"message": "接口4 仅在网关实现,请访问网关(本机默认 :8080),worker 不提供本接口",
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"features": '{"face_shape":"鹅蛋脸","eyebrow_shape":"平眉","facial_age":"18-25岁","dynamic_static_type":"静态型","gender":"女","gene_style":"少年型"}',
|
||||
},
|
||||
"data": None,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
400: {
|
||||
"description": "参数错误 / 图片识别失败",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"code": 1001, "message": "无法识别人像", "request_id": "x", "data": None}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
async def face_features(
|
||||
@@ -942,16 +914,12 @@ async def face_features(
|
||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||
):
|
||||
# ⚠️ 接口4 已迁到**网关本机**实现(直接调豆包视觉模型,见 gateway/app.py)。
|
||||
# 网关不会把本接口转发到 worker,故此处仅留 Mock 占位、保持 worker 无外网依赖。
|
||||
features = json.dumps(
|
||||
{
|
||||
"face_shape": "鹅蛋脸", "eyebrow_shape": "平眉", "facial_age": "18-25岁",
|
||||
"dynamic_static_type": "静态型", "gender": "女", "gene_style": "少年型",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
# 接口4 只在网关实现(gateway/app.py → face_features.analyze_features)。
|
||||
# 不再返回 Mock 成功数据,避免本机打 :8187 时被假结果误导。
|
||||
return err(
|
||||
1007,
|
||||
"接口4 仅在网关实现,请访问网关(本机默认 :8080),worker 不提供本接口",
|
||||
)
|
||||
return ok({"features": features})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1662,6 +1630,84 @@ async def download_hairline_log(rid: Optional[str] = None, tail: int = 500):
|
||||
return PlainTextResponse("".join(lines), media_type="text/plain; charset=utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 调试:MediaPipe 脸型分类(face/face_shape_classifier.py,非接口4 豆包)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post(
|
||||
"/api/v1/debug/face-shape",
|
||||
summary="调试 单张脸型分类(MediaPipe)",
|
||||
tags=["调试"],
|
||||
description="""
|
||||
离线脸型分类调试接口(`face/face_shape_classifier.py`),**不是**接口4 的豆包视觉分析。
|
||||
|
||||
上传正面照 → MediaPipe 468 点 → 7 类脸型评分 + 特征标注图。
|
||||
""",
|
||||
include_in_schema=True,
|
||||
)
|
||||
async def debug_face_shape(
|
||||
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"),
|
||||
):
|
||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||
if e:
|
||||
return e
|
||||
try:
|
||||
nparr = np.frombuffer(raw, np.uint8)
|
||||
bgr = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
if bgr is None:
|
||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
except Exception: # noqa: BLE001
|
||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
|
||||
def _jsonable(obj):
|
||||
if isinstance(obj, dict):
|
||||
return {k: _jsonable(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [_jsonable(v) for v in obj]
|
||||
if hasattr(obj, "item"):
|
||||
return obj.item()
|
||||
if isinstance(obj, (float, int, str, bool)) or obj is None:
|
||||
return obj
|
||||
return obj
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
try:
|
||||
from face.face_shape_classifier import classify_from_image
|
||||
|
||||
result = await run_in_threadpool(
|
||||
classify_from_image, bgr, True, True,
|
||||
)
|
||||
except ValueError as ex:
|
||||
return err(1001, str(ex) or "无法识别人像")
|
||||
except Exception as ex: # noqa: BLE001
|
||||
return err(1007, f"脸型分类失败:{ex}")
|
||||
|
||||
details = result.get("details") or {}
|
||||
ranked = [
|
||||
{"shape": name, "score": round(float(score), 2)}
|
||||
for name, score in (details.get("ranked") or [])
|
||||
]
|
||||
annotated = result.pop("annotated", None)
|
||||
h, w = bgr.shape[:2]
|
||||
data = {
|
||||
"face_shape": result["face_shape"],
|
||||
"display": result["display"],
|
||||
"confidence": round(float(result["confidence"]), 4),
|
||||
"is_mixed": bool(details.get("is_mixed")),
|
||||
"second_shape": details.get("second_shape"),
|
||||
"score_gap": round(float(details["score_gap"]), 2) if details.get("score_gap") is not None else None,
|
||||
"ranked": ranked,
|
||||
"features": _jsonable(result.get("features") or {}),
|
||||
"zscores": _jsonable(details.get("zscores") or {}),
|
||||
"image_size": {"width": w, "height": h},
|
||||
"annotated_image_base64": _jpg_b64(annotated) if annotated is not None else None,
|
||||
}
|
||||
return ok(data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 健康检查
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user