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:
+63
-16
@@ -1,10 +1,11 @@
|
||||
"""接口4:用户面部特征分析(调用火山方舟 豆包视觉模型 doubao-seed-1-6-vision)。
|
||||
"""接口4:用户面部特征分析。
|
||||
|
||||
算法来源:/home/xsl/fuyan(FaceArk.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:
|
||||
|
||||
Reference in New Issue
Block a user