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:
xsl
2026-07-29 14:41:49 +08:00
co-authored by Cursor
parent a748dfd1e5
commit 9fb5b486c0
8 changed files with 446 additions and 72 deletions
+63 -16
View File
@@ -1,10 +1,11 @@
"""接口4:用户面部特征分析(调用火山方舟 豆包视觉模型 doubao-seed-1-6-vision
"""接口4:用户面部特征分析。
算法来源:/home/xsl/fuyanFaceArk.py)。worker 把图片以 base64 data URI 传给方舟
多模态模型,模型返回一大堆人脸特征 JSON;本模块解析后映射出接口4 的英文优先字段
face_shape 等),并保留 doubao 返回的全部中文字段。
- 眉形 / 面部年龄 / 动静类型 / 性别 / 基因风格:火山方舟豆包视觉模型
- face_shape(脸型):本机 MediaPipe 分类(face/face_shape_classifier.py)覆盖,
不用豆包结果
⚠️ 这是**唯一调外网云模型**的接口(其余接口全本地)。API Key 走配置/环境变量,不入 git。
⚠️ 仍依赖外网豆包(其余 5 项)。API Key 走配置/环境变量,不入 git。
网关本机需可 import face 包(opencv + mediapipe)。
"""
from __future__ import annotations
@@ -18,9 +19,8 @@ logger = logging.getLogger("hair.worker")
ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-6-vision-250815")
# doubao 中文键 → 接口4 英文优先字段(仅保留这 6 项
# doubao 中文键 → 接口4 英文字段(脸型不走豆包,见 _local_face_shape
_KEY_MAP = {
"脸型": "face_shape",
"眉形": "eyebrow_shape",
"面部年龄": "facial_age",
"动静类型": "dynamic_static_type",
@@ -28,11 +28,11 @@ _KEY_MAP = {
"基因风格": "gene_style",
}
# 仅请求接口4 需要的 6 个字段(+「图片是否有人脸」用于 1001 判定,不进最终输出)
# 豆包只问 5 项 + 是否有人脸;脸型由本地分类器给出
_PROMPT = (
"分析一下图片告诉我以下特征,只要答案,格式为json字符串,"
"图片是否有人脸(有人/没人) "
"脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 眉形 "
"眉形 "
"面部年龄(给出区间年龄) 动静类型(静态型/动态型) 性别(男/女) "
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
)
@@ -42,12 +42,13 @@ _client_key: str | None = None # _client 构建时使用的 api_key,用
def _load_api_key() -> str | None:
"""ARK_API_KEY 环境变量优先否则读 worker_config.json / gateway/config.json 的 ark_api_key。"""
"""ARK_API_KEY 环境变量优先否则优先 gateway/config.json(接口4 已迁网关),
再回退 worker_config.json(兼容旧配置)。"""
key = os.getenv("ARK_API_KEY")
if key:
return key
base = os.path.dirname(__file__)
for cfg_name in ("worker_config.json", "gateway/config.json"):
for cfg_name in ("gateway/config.json", "worker_config.json"):
cfg = os.path.join(base, cfg_name)
if os.path.isfile(cfg):
try:
@@ -97,10 +98,47 @@ def _image_to_url(image_bytes: bytes = None, image_url: str = None) -> str:
return f"data:image/{fmt};base64," + base64.b64encode(image_bytes).decode()
def analyze_features(image_bytes: bytes = None, image_url: str = None):
"""调 doubao 视觉模型分析人脸特征。
def _resolve_image_bytes(image_bytes: bytes = None, image_url: str = None) -> bytes:
"""本地分类器用:优先已有字节;仅有 URL 时下载。"""
if image_bytes:
return image_bytes
if not image_url:
raise ValueError("缺少图片数据")
if image_url.startswith("data:"):
# data URI
b64 = image_url.split(",", 1)[1] if "," in image_url else image_url
return base64.b64decode(b64)
import httpx
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
r = client.get(image_url)
r.raise_for_status()
return r.content
Returns: dict —— 仅含接口4 的 6 个英文字段(face_shape/eyebrow_shape/facial_age/
def _local_face_shape(image_bytes: bytes = None, image_url: str = None) -> str:
"""MediaPipe 脸型分类,返回 display(含混合脸型描述)或主脸型。"""
import cv2
import numpy as np
from face.face_shape_classifier import classify_from_image
raw = _resolve_image_bytes(image_bytes, image_url)
bgr = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if bgr is None:
raise ValueError("图片格式不支持,无法解码")
result = classify_from_image(bgr, return_details=True, return_annotated=False)
shape = result.get("display") or result["face_shape"]
logger.info(
"local face_shape=%s conf=%.3f",
shape,
float(result.get("confidence") or 0),
)
return shape
def analyze_features(image_bytes: bytes = None, image_url: str = None):
"""豆包分析 5 项特征 + 本机 MediaPipe 覆盖 face_shape。
Returns: dict —— 6 个英文字段(face_shape/eyebrow_shape/facial_age/
dynamic_static_type/gender/gene_style)**无人脸返回 None**(调用方据此判 1001)。
"""
url = _image_to_url(image_bytes, image_url)
@@ -131,8 +169,17 @@ def analyze_features(image_bytes: bytes = None, image_url: str = None):
raise RuntimeError(f"豆包模型返回格式异常,无法解析为 JSON:{text[:200]}") from e
if not has_face(raw):
return None
# 只保留 6 个英文字段(doubao 缺某字段则跳过)
return {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
# 豆包 5 项 + 本地脸型覆盖
feats = {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
try:
feats["face_shape"] = _local_face_shape(image_bytes, image_url)
except ValueError as e:
logger.warning("本地脸型分类未检测到人脸: %s", e)
return None
except Exception as e: # noqa: BLE001
logger.exception("本地脸型分类失败")
raise RuntimeError(f"本地脸型分类失败:{e}") from e
return feats
def has_face(features: dict) -> bool: