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
+2 -1
View File
@@ -82,7 +82,8 @@ model.safetensors https://huggingface.co/jonathandinu/face-parsing/resol
模型已就位,但**内网机还需要 Python 依赖的离线 wheel 包**,否则 `pip install` 在内网无法联网安装。这部分**与目标机的操作系统、Python 版本、CUDA 版本强相关**,需确认后单独打包:
- **workerGPU 机)**`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,网关保持轻量。
+93 -47
View File
@@ -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)
# ---------------------------------------------------------------------------
# 健康检查
# ---------------------------------------------------------------------------
+8 -4
View File
@@ -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/`(落盘的图),定期清理。
+6
View File
@@ -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,6 +506,7 @@ def annotate_face_features(
if landmarks is None:
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
with _face_mesh_lock:
results = _get_face_mesh().process(rgb)
if not results.multi_face_landmarks:
raise ValueError("未检测到人脸关键点")
@@ -741,6 +746,7 @@ def classify_from_image(
bgr = _load_image(image)
h, w = bgr.shape[:2]
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
with _face_mesh_lock:
results = _get_face_mesh().process(rgb)
if not results.multi_face_landmarks:
+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:
+1 -1
View File
@@ -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
# 三选一校验
+4 -1
View File
@@ -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
+267
View File
@@ -0,0 +1,267 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>脸型分类调试 — MediaPipe</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 6px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
.subtitle code { background: #eef2ff; color: #3730a3; padding: 1px 6px; border-radius: 4px; font-size: 12px; }
.card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
.card-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
.card-body { padding: 18px; }
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.file-input { flex: 1; min-width: 200px; }
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
.btn-outline:hover { background: #f9fafb; }
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
.status.info { background: #dbeafe; color: #1e40af; display: block; }
.status.error { background: #fee2e2; color: #991b1b; display: block; }
.status.success { background: #d1fae5; color: #065f46; display: block; }
.results-layout { display: flex; gap: 24px; }
.col-main { flex: 1.4; min-width: 0; }
.col-side { flex: 1; min-width: 0; }
.verdict { display: flex; gap: 16px; flex-wrap: wrap; align-items: stretch; }
.verdict-item { flex: 1; min-width: 140px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 14px 16px; }
.verdict-item .label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
.verdict-item .value { font-size: 20px; font-weight: 700; color: #0f172a; }
.verdict-item .value.accent { color: #2563eb; }
.badge-mixed { display: inline-block; margin-left: 8px; background: #fef3c7; color: #92400e; font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 700; vertical-align: middle; }
.img-preview { text-align: center; background: #222; border-radius: 8px; overflow: hidden; min-height: 200px; display: flex; align-items: center; justify-content: center; }
.img-preview img { max-width: 100%; max-height: 560px; object-fit: contain; display: block; }
.img-preview .placeholder { color: #9ca3af; padding: 40px; font-size: 14px; }
.bar-list { display: flex; flex-direction: column; gap: 10px; }
.bar-row { display: grid; grid-template-columns: 72px 1fr 52px; gap: 10px; align-items: center; font-size: 13px; }
.bar-row .name { font-weight: 600; color: #334155; }
.bar-row .track { height: 10px; background: #e2e8f0; border-radius: 999px; overflow: hidden; }
.bar-row .fill { height: 100%; background: #94a3b8; border-radius: 999px; }
.bar-row.top .fill { background: #2563eb; }
.bar-row.second .fill { background: #60a5fa; }
.bar-row .score { text-align: right; font-variant-numeric: tabular-nums; color: #475569; }
.feat-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.feat-table th, .feat-table td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #f1f5f9; }
.feat-table th { background: #f8fafc; font-weight: 700; color: #475569; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
.feat-table td:first-child { font-weight: 600; color: #1e293b; width: 180px; }
.feat-table tr:hover td { background: #f8fafc; }
.json-panel { max-height: 520px; overflow: auto; }
.json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.hidden { display: none !important; }
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
</style>
<script src="/static/img_downscale.js"></script>
</head>
<body>
<div class="container">
<h1>脸型分类调试(MediaPipe</h1>
<p class="subtitle">
POST <code>/api/v1/debug/face-shape</code>
&nbsp;|&nbsp; 本地 <code>face/face_shape_classifier.py</code>7 类)
&nbsp;|&nbsp; 非接口4 豆包分析
</p>
<div class="card">
<div class="card-body">
<div class="upload-row">
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">分析脸型</button>
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
</div>
<div class="hint">JPG/PNG 正面照 &nbsp;|&nbsp; 走本机 workerMediaPipe),约 1s 内</div>
<div id="statusBar" class="status hidden"></div>
</div>
</div>
<div class="results-layout hidden" id="resultsArea">
<div class="col-main">
<div class="card">
<div class="card-header"><span>判定结果</span></div>
<div class="card-body">
<div class="verdict" id="verdictBox"></div>
</div>
</div>
<div class="card">
<div class="card-header"><span>特征标注图</span></div>
<div class="card-body">
<div class="img-preview" id="imgPreview"><span class="placeholder"></span></div>
</div>
</div>
<div class="card">
<div class="card-header"><span>各脸型得分</span></div>
<div class="card-body">
<div class="bar-list" id="scoreBars"></div>
</div>
</div>
</div>
<div class="col-side">
<div class="card">
<div class="card-header"><span>几何特征</span></div>
<div class="card-body" style="padding:0;max-height:360px;overflow:auto">
<table class="feat-table" id="featTable"></table>
</div>
</div>
<div class="card">
<div class="card-header"><span>原始 JSON</span><button class="btn btn-outline btn-sm" onclick="copyJson()">复制</button></div>
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
</div>
</div>
</div>
</div>
<script>
const API_BASE = window.location.origin;
const TOKEN = 'dev-shared-secret-2026';
const FEAT_LABELS = {
face_height: '脸高 (px)',
face_width: '脸宽 (px)',
forehead_width: '额宽 (px)',
cheekbone_width: '颧宽 (px)',
jaw_width: '下颌宽 (px)',
chin_width: '下巴宽 (px)',
aspect_ratio: '长宽比 (宽/高)',
forehead_ratio: '额宽/面宽',
cheekbone_ratio: '颧宽/面宽',
jaw_ratio: '下颌宽/面宽',
chin_ratio: '下巴宽/面宽',
chin_sharpness: '下巴尖锐度',
taper_ratio: '额头→下巴收窄',
jaw_angle: '下颌角 (°)',
width_uniformity: '宽度均匀度',
face_curve_score: '面部曲线分',
};
function $(id) { return document.getElementById(id); }
function setStatus(t, type) {
const b = $('statusBar');
b.textContent = t;
b.className = 'status ' + type;
}
function clearResults() {
$('resultsArea').classList.add('hidden');
$('statusBar').className = 'status hidden';
$('imageFile').value = '';
$('jsonContent').textContent = '';
$('imgPreview').innerHTML = '<span class="placeholder">—</span>';
}
async function submitTest() {
let f = $('imageFile').files[0];
if (!f) { setStatus('请选择图片', 'error'); return; }
if (window.downscaleImageFile) f = await window.downscaleImageFile(f);
$('submitBtn').disabled = true;
$('submitBtn').textContent = '分析中...';
setStatus('调用 MediaPipe 脸型分类...', 'info');
$('resultsArea').classList.add('hidden');
const fd = new FormData();
fd.append('image_file', f);
const t0 = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/debug/face-shape', {
method: 'POST',
headers: { 'X-Internal-Token': TOKEN },
body: fd,
});
const json = await r.json();
const elapsed = ((performance.now() - t0) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
setStatus('完成 (' + elapsed + 's)', 'success');
renderResult(json.data);
} else {
setStatus('(' + elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
}
} catch (e) {
setStatus('请求失败: ' + e.message, 'error');
} finally {
$('submitBtn').disabled = false;
$('submitBtn').textContent = '分析脸型';
}
}
function renderResult(data) {
const mixed = data.is_mixed
? '<span class="badge-mixed">混合 · 次选 ' + (data.second_shape || '—') + '</span>'
: '';
$('verdictBox').innerHTML =
'<div class="verdict-item"><div class="label">脸型</div><div class="value accent">' +
esc(data.display || data.face_shape) + mixed + '</div></div>' +
'<div class="verdict-item"><div class="label">置信度</div><div class="value">' +
(data.confidence * 100).toFixed(1) + '%</div></div>' +
'<div class="verdict-item"><div class="label">分差 score_gap</div><div class="value">' +
(data.score_gap == null ? '—' : data.score_gap) + '</div></div>' +
'<div class="verdict-item"><div class="label">尺寸</div><div class="value" style="font-size:16px">' +
(data.image_size ? data.image_size.width + '×' + data.image_size.height : '—') + '</div></div>';
if (data.annotated_image_base64) {
$('imgPreview').innerHTML =
'<img src="data:image/jpeg;base64,' + data.annotated_image_base64 + '" alt="annotated">';
} else {
$('imgPreview').innerHTML = '<span class="placeholder">无标注图</span>';
}
const ranked = data.ranked || [];
const maxScore = ranked.length ? Math.max.apply(null, ranked.map(function (x) { return x.score; })) : 100;
$('scoreBars').innerHTML = ranked.map(function (row, i) {
const cls = i === 0 ? ' top' : (i === 1 ? ' second' : '');
const pct = maxScore > 0 ? (100 * row.score / maxScore) : 0;
return '<div class="bar-row' + cls + '">' +
'<div class="name">' + esc(row.shape) + '</div>' +
'<div class="track"><div class="fill" style="width:' + pct.toFixed(1) + '%"></div></div>' +
'<div class="score">' + Number(row.score).toFixed(1) + '</div>' +
'</div>';
}).join('');
const feats = data.features || {};
const keys = Object.keys(feats);
let html = '<tr><th>特征</th><th>值</th></tr>';
keys.forEach(function (k) {
const label = FEAT_LABELS[k] || k;
const v = feats[k];
const text = typeof v === 'number' ? (Number.isInteger(v) ? v : v.toFixed(4)) : String(v);
html += '<tr><td>' + esc(label) + '</td><td>' + esc(String(text)) + '</td></tr>';
});
$('featTable').innerHTML = html;
}
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
function copyJson() {
const t = $('jsonContent').textContent;
if (!t) return;
navigator.clipboard.writeText(t).then(function () {
setStatus('已复制 JSON', 'success');
});
}
</script>
</body>
</html>