diff --git a/OFFLINE_ASSETS.md b/OFFLINE_ASSETS.md index fdf2924..1602586 100644 --- a/OFFLINE_ASSETS.md +++ b/OFFLINE_ASSETS.md @@ -82,7 +82,8 @@ model.safetensors https://huggingface.co/jonathandinu/face-parsing/resol 模型已就位,但**内网机还需要 Python 依赖的离线 wheel 包**,否则 `pip install` 在内网无法联网安装。这部分**与目标机的操作系统、Python 版本、CUDA 版本强相关**,需确认后单独打包: - **worker(GPU 机)**:`mediapipe` / `opencv-python` / `numpy<2` / `Pillow` / **`torch`+`torchvision` 的 CUDA 版**(按 GPU 的 CUDA 版本选 cu118/cu121 等)/ `transformers`(接口2 SegFormer)+ FastAPI/uvicorn 全家桶。 -- **网关机**:很轻,只需 FastAPI/uvicorn/httpx 等代理依赖,**不需要 torch/mediapipe**。 +- **网关机**:FastAPI/uvicorn/httpx 等代理依赖 + **接口4 现需 `mediapipe`/`opencv-python`/`numpy<2`**(脸型本机计算), + 仍**不需要 torch**(无 GPU 推理需求)。 > 架构已拆分(见 `docs/实现说明.md`):算法依赖只装在 worker,网关保持轻量。 diff --git a/app.py b/app.py index 6113cb5..9228b19 100644 --- a/app.py +++ b/app.py @@ -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) + + # --------------------------------------------------------------------------- # 健康检查 # --------------------------------------------------------------------------- diff --git a/docs/实现说明.md b/docs/实现说明.md index 9aab141..f60aed2 100644 --- a/docs/实现说明.md +++ b/docs/实现说明.md @@ -85,8 +85,10 @@ ### 接口4 用户特征 `/api/v1/face/features`(**网关本机**) - **做什么**:照片 → 几十项面部特征(脸型/眉形/肤色/三庭五眼/四季色彩季型/量感/基因风格/性别…)。`data.features` 是 JSON 字符串。 - **怎么实现**(`gateway/`,逻辑参考 worker `face_features.py` / `/home/xsl/fuyan`):调**火山方舟 豆包视觉模型** - `doubao-seed-1-6-vision`(OpenAI 兼容,base64 data URI 喂图),解析 JSON + 映射 6 个英文优先字段并保留全部中文。 - 无人脸→1001。**唯一调外网的接口**:网关需可达 `ark.cn-beijing.volces.com`,API Key 走网关配置(不入 git)。 + `doubao-seed-1-6-vision`(OpenAI 兼容,base64 data URI 喂图),解析眉形/年龄/动静/性别/基因风格 5 项。 + **`face_shape`(脸型)改为本机 `face/face_shape_classifier.py`(MediaPipe)计算并覆盖豆包结果**。 + 无人脸→1001。网关需可达 `ark.cn-beijing.volces.com`,API Key 走网关配置(不入 git)。 + ⚠️ 因此**网关机不再是纯轻量代理**,需额外安装 `mediapipe`/`opencv-python`/`numpy<2`(见 `requirements.txt`)。 ### 接口5 发际线PNG生成 `/api/v1/hairline/generate`(worker) - **做什么**:入参同接口2(`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。 @@ -108,8 +110,10 @@ - `worker_config.json`(不入 git):`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token`。 **网关机** -- 很轻:FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。 -- 不装 torch/mediapipe/opencv。配置 `gateway/config.json`(不入 git):`workers` 列表、`shared_password`、 +- FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。 +- ⚠️ 接口4 `face_shape` 改本机 MediaPipe 计算后,网关机**也需要装** `mediapipe`/`opencv-python`/`numpy<2` + (不再是"网关不装 torch/mediapipe/opencv",只是仍不需要 torch/transformers/scikit-image 等重依赖)。 +- 配置 `gateway/config.json`(不入 git):`workers` 列表、`shared_password`、 `ark` 的 api_key/base_url/model、`public_base_url`、超时(**生发接口慢,`request_timeout_seconds` 调大 ≥120s**)。 - 托管 `/static/annotations/`(落盘的图),定期清理。 diff --git a/face/face_shape_classifier.py b/face/face_shape_classifier.py index 273b62a..de10185 100644 --- a/face/face_shape_classifier.py +++ b/face/face_shape_classifier.py @@ -14,6 +14,7 @@ face_shape_classifier.py from __future__ import annotations import math +import threading from pathlib import Path from typing import Dict, List, Optional, Tuple, Union @@ -343,6 +344,9 @@ def get_mixed_description(details: Dict) -> str: _face_mesh = None +# mediapipe Solutions API 的单个 FaceMesh 实例不是线程安全的;被 web 服务用 +# run_in_threadpool 并发调用时(如网关接口4、worker 调试接口)必须加锁串行化。 +_face_mesh_lock = threading.Lock() def _get_face_mesh(): @@ -502,7 +506,8 @@ def annotate_face_features( if landmarks is None: rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) - results = _get_face_mesh().process(rgb) + with _face_mesh_lock: + results = _get_face_mesh().process(rgb) if not results.multi_face_landmarks: raise ValueError("未检测到人脸关键点") landmarks = results.multi_face_landmarks[0].landmark @@ -741,7 +746,8 @@ def classify_from_image( bgr = _load_image(image) h, w = bgr.shape[:2] rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) - results = _get_face_mesh().process(rgb) + with _face_mesh_lock: + results = _get_face_mesh().process(rgb) if not results.multi_face_landmarks: raise ValueError("未检测到人脸关键点") diff --git a/face_features.py b/face_features.py index 9ab463b..e8a35f3 100644 --- a/face_features.py +++ b/face_features.py @@ -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: diff --git a/gateway/app.py b/gateway/app.py index fb34f13..c74e4f3 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -552,7 +552,7 @@ async def face_features( image_url: Optional[str] = Form(default=None, description="图片 URL"), image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"), ): - """接口4:用户特征分析 — 本机直接调豆包视觉模型,不经过 worker。""" + """接口4:用户特征分析 — 豆包 5 项 + 本机 MediaPipe 脸型(face/),不经过 worker。""" import uuid as _uuid # 三选一校验 diff --git a/requirements.txt b/requirements.txt index f5ca68f..1b3c40f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,8 +26,11 @@ transformers==4.45.2 # SegFormer 人脸分割(jonathandinu/face-parsing, # ⚠️ 必须 0.24.x —— 0.25+ 强依赖 numpy>=2,会顶掉 mediapipe 需要的 numpy<2 scikit-image==0.24.0 # route_through_array(黑帽响应图上的 Dijkstra 最小路径) -# 接口4:用户特征(火山方舟 豆包视觉模型)—— 已迁到**网关**实现,worker 不需要。 +# 接口4:用户特征(火山方舟 豆包视觉模型 + 本机 MediaPipe 脸型覆盖)—— 已迁到**网关**实现,worker 不需要。 # 网关机装:volcengine-python-sdk[ark](from volcenginesdkarkruntime import Ark);API Key 走配置不入 git +# ⚠️ face_shape 字段改为本机 face/face_shape_classifier.py 计算(覆盖豆包结果), +# 因此网关机现在也需要 mediapipe==0.10.14 + opencv-python==4.10.0.84 + numpy==1.26.4 +# (不再是"网关不需要 mediapipe",见 docs/实现说明.md 需同步更新) # 测试 pytest==8.3.3 diff --git a/static/test_face_shape.html b/static/test_face_shape.html new file mode 100644 index 0000000..9954556 --- /dev/null +++ b/static/test_face_shape.html @@ -0,0 +1,267 @@ + + +
+ + +
+ POST /api/v1/debug/face-shape
+ | 本地 face/face_shape_classifier.py(7 类)
+ | 非接口4 豆包分析
+