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:
+2
-1
@@ -82,7 +82,8 @@ model.safetensors https://huggingface.co/jonathandinu/face-parsing/resol
|
|||||||
模型已就位,但**内网机还需要 Python 依赖的离线 wheel 包**,否则 `pip install` 在内网无法联网安装。这部分**与目标机的操作系统、Python 版本、CUDA 版本强相关**,需确认后单独打包:
|
模型已就位,但**内网机还需要 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 全家桶。
|
- **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,网关保持轻量。
|
> 架构已拆分(见 `docs/实现说明.md`):算法依赖只装在 worker,网关保持轻量。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""旷视五接口 — worker 侧(高性能 GPU 后端)。
|
"""旷视五接口 — worker 侧(高性能 GPU 后端)。
|
||||||
|
|
||||||
接口 1(四庭七眼测量)已为**真实算法实现**(见 face_analysis 包);接口 2~5 仍为 Mock。
|
接口 1 等算法在 worker;接口4(用户特征)已迁到网关本机(调豆包),worker 同路径只返回明确错误、无 Mock。
|
||||||
拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)。
|
拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)。
|
||||||
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token`,`/health` 供网关探测不校验。
|
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token`,`/health` 供网关探测不校验。
|
||||||
"""
|
"""
|
||||||
@@ -885,56 +885,28 @@ async def hair_grow_b(
|
|||||||
|
|
||||||
@app.post(
|
@app.post(
|
||||||
"/api/v1/face/features",
|
"/api/v1/face/features",
|
||||||
summary="接口4 用户特征分析",
|
summary="接口4 用户特征分析(仅网关)",
|
||||||
tags=["人脸分析"],
|
tags=["人脸分析"],
|
||||||
description=f"""
|
description="""
|
||||||
输入用户照片,返回 N 个用户面部特征字段。
|
**本接口不在 worker 实现。** 请调用网关(本机默认 `http://127.0.0.1:8080`)的同路径;
|
||||||
|
网关本机调火山方舟豆包视觉模型,不转发到 worker。
|
||||||
|
|
||||||
{_image_fields_desc}
|
直接打 worker(如 `:8187`)会返回错误,避免误用假数据。
|
||||||
|
|
||||||
图片同时支持 `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`。
|
|
||||||
""",
|
""",
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
"description": "成功",
|
"description": "worker 不提供本接口",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"example": {
|
"example": {
|
||||||
"code": 0,
|
"code": 1007,
|
||||||
"message": "success",
|
"message": "接口4 仅在网关实现,请访问网关(本机默认 :8080),worker 不提供本接口",
|
||||||
"request_id": "mock-request-id",
|
"request_id": "mock-request-id",
|
||||||
"data": {
|
"data": None,
|
||||||
"features": '{"face_shape":"鹅蛋脸","eyebrow_shape":"平眉","facial_age":"18-25岁","dynamic_static_type":"静态型","gender":"女","gene_style":"少年型"}',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
400: {
|
|
||||||
"description": "参数错误 / 图片识别失败",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"example": {"code": 1001, "message": "无法识别人像", "request_id": "x", "data": None}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def face_features(
|
async def face_features(
|
||||||
@@ -942,16 +914,12 @@ async def face_features(
|
|||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||||
):
|
):
|
||||||
# ⚠️ 接口4 已迁到**网关本机**实现(直接调豆包视觉模型,见 gateway/app.py)。
|
# 接口4 只在网关实现(gateway/app.py → face_features.analyze_features)。
|
||||||
# 网关不会把本接口转发到 worker,故此处仅留 Mock 占位、保持 worker 无外网依赖。
|
# 不再返回 Mock 成功数据,避免本机打 :8187 时被假结果误导。
|
||||||
features = json.dumps(
|
return err(
|
||||||
{
|
1007,
|
||||||
"face_shape": "鹅蛋脸", "eyebrow_shape": "平眉", "facial_age": "18-25岁",
|
"接口4 仅在网关实现,请访问网关(本机默认 :8080),worker 不提供本接口",
|
||||||
"dynamic_static_type": "静态型", "gender": "女", "gene_style": "少年型",
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
)
|
||||||
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")
|
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
@@ -85,8 +85,10 @@
|
|||||||
### 接口4 用户特征 `/api/v1/face/features`(**网关本机**)
|
### 接口4 用户特征 `/api/v1/face/features`(**网关本机**)
|
||||||
- **做什么**:照片 → 几十项面部特征(脸型/眉形/肤色/三庭五眼/四季色彩季型/量感/基因风格/性别…)。`data.features` 是 JSON 字符串。
|
- **做什么**:照片 → 几十项面部特征(脸型/眉形/肤色/三庭五眼/四季色彩季型/量感/基因风格/性别…)。`data.features` 是 JSON 字符串。
|
||||||
- **怎么实现**(`gateway/`,逻辑参考 worker `face_features.py` / `/home/xsl/fuyan`):调**火山方舟 豆包视觉模型**
|
- **怎么实现**(`gateway/`,逻辑参考 worker `face_features.py` / `/home/xsl/fuyan`):调**火山方舟 豆包视觉模型**
|
||||||
`doubao-seed-1-6-vision`(OpenAI 兼容,base64 data URI 喂图),解析 JSON + 映射 6 个英文优先字段并保留全部中文。
|
`doubao-seed-1-6-vision`(OpenAI 兼容,base64 data URI 喂图),解析眉形/年龄/动静/性别/基因风格 5 项。
|
||||||
无人脸→1001。**唯一调外网的接口**:网关需可达 `ark.cn-beijing.volces.com`,API Key 走网关配置(不入 git)。
|
**`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)
|
### 接口5 发际线PNG生成 `/api/v1/hairline/generate`(worker)
|
||||||
- **做什么**:入参同接口2(`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
|
- **做什么**:入参同接口2(`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
|
||||||
@@ -108,8 +110,10 @@
|
|||||||
- `worker_config.json`(不入 git):`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token`。
|
- `worker_config.json`(不入 git):`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token`。
|
||||||
|
|
||||||
**网关机**
|
**网关机**
|
||||||
- 很轻:FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
|
- FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
|
||||||
- 不装 torch/mediapipe/opencv。配置 `gateway/config.json`(不入 git):`workers` 列表、`shared_password`、
|
- ⚠️ 接口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**)。
|
`ark` 的 api_key/base_url/model、`public_base_url`、超时(**生发接口慢,`request_timeout_seconds` 调大 ≥120s**)。
|
||||||
- 托管 `/static/annotations/`(落盘的图),定期清理。
|
- 托管 `/static/annotations/`(落盘的图),定期清理。
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ face_shape_classifier.py
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Tuple, Union
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
@@ -343,6 +344,9 @@ def get_mixed_description(details: Dict) -> str:
|
|||||||
|
|
||||||
|
|
||||||
_face_mesh = None
|
_face_mesh = None
|
||||||
|
# mediapipe Solutions API 的单个 FaceMesh 实例不是线程安全的;被 web 服务用
|
||||||
|
# run_in_threadpool 并发调用时(如网关接口4、worker 调试接口)必须加锁串行化。
|
||||||
|
_face_mesh_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _get_face_mesh():
|
def _get_face_mesh():
|
||||||
@@ -502,7 +506,8 @@ def annotate_face_features(
|
|||||||
|
|
||||||
if landmarks is None:
|
if landmarks is None:
|
||||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
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:
|
if not results.multi_face_landmarks:
|
||||||
raise ValueError("未检测到人脸关键点")
|
raise ValueError("未检测到人脸关键点")
|
||||||
landmarks = results.multi_face_landmarks[0].landmark
|
landmarks = results.multi_face_landmarks[0].landmark
|
||||||
@@ -741,7 +746,8 @@ def classify_from_image(
|
|||||||
bgr = _load_image(image)
|
bgr = _load_image(image)
|
||||||
h, w = bgr.shape[:2]
|
h, w = bgr.shape[:2]
|
||||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
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:
|
if not results.multi_face_landmarks:
|
||||||
raise ValueError("未检测到人脸关键点")
|
raise ValueError("未检测到人脸关键点")
|
||||||
|
|||||||
+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(脸型):本机 MediaPipe 分类(face/face_shape_classifier.py)覆盖,
|
||||||
(face_shape 等),并保留 doubao 返回的全部中文字段。
|
不用豆包结果
|
||||||
|
|
||||||
⚠️ 这是**唯一调外网云模型**的接口(其余接口全本地)。API Key 走配置/环境变量,不入 git。
|
⚠️ 仍依赖外网豆包(其余 5 项)。API Key 走配置/环境变量,不入 git。
|
||||||
|
网关本机需可 import face 包(opencv + mediapipe)。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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_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")
|
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-6-vision-250815")
|
||||||
|
|
||||||
# doubao 中文键 → 接口4 英文优先字段(仅保留这 6 项)
|
# doubao 中文键 → 接口4 英文字段(脸型不走豆包,见 _local_face_shape)
|
||||||
_KEY_MAP = {
|
_KEY_MAP = {
|
||||||
"脸型": "face_shape",
|
|
||||||
"眉形": "eyebrow_shape",
|
"眉形": "eyebrow_shape",
|
||||||
"面部年龄": "facial_age",
|
"面部年龄": "facial_age",
|
||||||
"动静类型": "dynamic_static_type",
|
"动静类型": "dynamic_static_type",
|
||||||
@@ -28,11 +28,11 @@ _KEY_MAP = {
|
|||||||
"基因风格": "gene_style",
|
"基因风格": "gene_style",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 仅请求接口4 需要的 6 个字段(+「图片是否有人脸」用于 1001 判定,不进最终输出)
|
# 豆包只问 5 项 + 是否有人脸;脸型由本地分类器给出
|
||||||
_PROMPT = (
|
_PROMPT = (
|
||||||
"分析一下图片告诉我以下特征,只要答案,格式为json字符串,"
|
"分析一下图片告诉我以下特征,只要答案,格式为json字符串,"
|
||||||
"图片是否有人脸(有人/没人) "
|
"图片是否有人脸(有人/没人) "
|
||||||
"脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 眉形 "
|
"眉形 "
|
||||||
"面部年龄(给出区间年龄) 动静类型(静态型/动态型) 性别(男/女) "
|
"面部年龄(给出区间年龄) 动静类型(静态型/动态型) 性别(男/女) "
|
||||||
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
|
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
|
||||||
)
|
)
|
||||||
@@ -42,12 +42,13 @@ _client_key: str | None = None # _client 构建时使用的 api_key,用
|
|||||||
|
|
||||||
|
|
||||||
def _load_api_key() -> str | None:
|
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")
|
key = os.getenv("ARK_API_KEY")
|
||||||
if key:
|
if key:
|
||||||
return key
|
return key
|
||||||
base = os.path.dirname(__file__)
|
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)
|
cfg = os.path.join(base, cfg_name)
|
||||||
if os.path.isfile(cfg):
|
if os.path.isfile(cfg):
|
||||||
try:
|
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()
|
return f"data:image/{fmt};base64," + base64.b64encode(image_bytes).decode()
|
||||||
|
|
||||||
|
|
||||||
def analyze_features(image_bytes: bytes = None, image_url: str = None):
|
def _resolve_image_bytes(image_bytes: bytes = None, image_url: str = None) -> bytes:
|
||||||
"""调 doubao 视觉模型分析人脸特征。
|
"""本地分类器用:优先已有字节;仅有 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)。
|
dynamic_static_type/gender/gene_style);**无人脸返回 None**(调用方据此判 1001)。
|
||||||
"""
|
"""
|
||||||
url = _image_to_url(image_bytes, image_url)
|
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
|
raise RuntimeError(f"豆包模型返回格式异常,无法解析为 JSON:{text[:200]}") from e
|
||||||
if not has_face(raw):
|
if not has_face(raw):
|
||||||
return None
|
return None
|
||||||
# 只保留 6 个英文字段(doubao 缺某字段则跳过)
|
# 豆包 5 项 + 本地脸型覆盖
|
||||||
return {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
|
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:
|
def has_face(features: dict) -> bool:
|
||||||
|
|||||||
+1
-1
@@ -552,7 +552,7 @@ async def face_features(
|
|||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
|
||||||
):
|
):
|
||||||
"""接口4:用户特征分析 — 本机直接调豆包视觉模型,不经过 worker。"""
|
"""接口4:用户特征分析 — 豆包 5 项 + 本机 MediaPipe 脸型(face/),不经过 worker。"""
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
|
|
||||||
# 三选一校验
|
# 三选一校验
|
||||||
|
|||||||
+4
-1
@@ -26,8 +26,11 @@ transformers==4.45.2 # SegFormer 人脸分割(jonathandinu/face-parsing,
|
|||||||
# ⚠️ 必须 0.24.x —— 0.25+ 强依赖 numpy>=2,会顶掉 mediapipe 需要的 numpy<2
|
# ⚠️ 必须 0.24.x —— 0.25+ 强依赖 numpy>=2,会顶掉 mediapipe 需要的 numpy<2
|
||||||
scikit-image==0.24.0 # route_through_array(黑帽响应图上的 Dijkstra 最小路径)
|
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
|
# 网关机装: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
|
pytest==8.3.3
|
||||||
|
|||||||
@@ -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>
|
||||||
|
| 本地 <code>face/face_shape_classifier.py</code>(7 类)
|
||||||
|
| 非接口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 正面照 | 走本机 worker(MediaPipe),约 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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyJson() {
|
||||||
|
const t = $('jsonContent').textContent;
|
||||||
|
if (!t) return;
|
||||||
|
navigator.clipboard.writeText(t).then(function () {
|
||||||
|
setStatus('已复制 JSON', 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user