diff --git a/app.py b/app.py index 178c7e1..91fa320 100644 --- a/app.py +++ b/app.py @@ -630,20 +630,23 @@ async def hair_grow_b( --- +由**火山方舟 豆包视觉模型**分析,返回一大批面部特征(脸型/眉形/眼型/肤色/三庭五眼/四季色彩季型/ +量感/轮廓/基因风格/性别…几十项),**英文优先字段 + doubao 中文字段并存**。 + **返回格式**:`data.features` 为一个 **JSON 字符串**(不是对象),需要在客户端 `JSON.parse()` 后使用。 -当前优先返回字段: +英文优先字段(其余中文字段同时返回): -| 字段 | 类型 | 说明 | -|------|------|------| -| face_shape | string | 脸形,如"鹅蛋脸" | -| eyebrow_shape | string | 眉形,如"柳叶眉" | -| facial_age | int | 面部年龄 | -| dynamic_static_type | string | 动静类型,如"静态" | -| gender | string | 性别,如"女" | -| gene_style | object | 面部特征对应的基因风格 | +| 字段 | 说明 | +|------|------| +| face_shape | 脸形(如"鹅蛋脸") | +| eyebrow_shape | 眉形(如"平眉") | +| facial_age | 面部年龄(区间,如"18-25岁") | +| dynamic_static_type | 动静类型("静态型"/"动态型") | +| gender | 性别("男"/"女") | +| gene_style | 基因风格(如"自然型") | -> 字段后续可随时增删,不固定,客户端按需取用即可。 +> 字段不固定、可随时增删,客户端按需取用。无人脸返回 `1001`。 """, responses={ 200: { @@ -655,7 +658,7 @@ async def hair_grow_b( "message": "success", "request_id": "mock-request-id", "data": { - "features": '{"face_shape": "鹅蛋脸", "eyebrow_shape": "柳叶眉", "facial_age": 26, "dynamic_static_type": "静态", "gender": "女", "gene_style": {"label": "面部特征标签", "style": "基因风格示例"}}', + "features": '{"图片是否有人脸":"有人","脸型":"鹅蛋脸","眉形":"平眉","面部年龄":"18-25岁","四季色彩季型":"冷夏型","基因风格":"少年型","性别":"女","face_shape":"鹅蛋脸","eyebrow_shape":"平眉","facial_age":"18-25岁","dynamic_static_type":"静态型","gender":"女","gene_style":"少年型"}', }, } } @@ -676,18 +679,32 @@ 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, 前缀)"), ): - features = json.dumps( - { - "face_shape": "鹅蛋脸", - "eyebrow_shape": "柳叶眉", - "facial_age": 26, - "dynamic_static_type": "静态", - "gender": "女", - "gene_style": {"label": "面部特征标签", "style": "基因风格示例"}, - }, - ensure_ascii=False, - ) - return ok({"features": features}) + # 三选一。image_url 直接交给豆包拉取;file/base64 解成字节转 data URI。 + provided = [x for x in (image_file, image_url, image_base64) if x] + if len(provided) != 1: + return err(1007, "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个") + + img_bytes = None + if not image_url: + raw, e = await resolve_image_bytes(image_file, None, image_base64) + if e is not None: + return e + if len(raw) > MAX_FILE_BYTES: + return err(1006, "文件超出 1 MB 限制") + img_bytes = raw + + try: + from fastapi.concurrency import run_in_threadpool + from face_features import analyze_features, has_face + + feats = await run_in_threadpool(analyze_features, img_bytes, image_url) + if not has_face(feats): + return err(1001, "无法识别人像") + # 契约:data.features 为 JSON 字符串 + return ok({"features": json.dumps(feats, ensure_ascii=False)}) + except Exception as ex: # noqa: BLE001 + logger.exception("接口4 处理异常") + return err(1007, f"处理失败:{ex}") # --------------------------------------------------------------------------- diff --git a/docs/接口文档.md b/docs/接口文档.md index 8e3d9ae..6927b73 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -271,7 +271,7 @@ ## 接口 4:用户特征接口 -**说明**:输入用户照片,输出 N 个用户特征字段。 +**说明**:输入用户照片,由**火山方舟 豆包视觉模型**(`doubao-seed-1-6-vision`)分析,输出一大批面部特征。 **请求**:`POST /api/v1/face/features` @@ -281,18 +281,25 @@ ### 输出(data) -`data` 直接返回一个 **JSON 字符串**(`features`),其内部字段不固定、可随时调整,由业务方约定。当前优先返回的字段如下(仅作示例,最终以实际返回为准): +`data.features` 是一个 **JSON 字符串**(不是对象,客户端 `JSON.parse()` 后用)。内含**几十项**特征: +脸型/眉形/眼型/鼻型/眼袋/法令纹/人中/瞳色/脖长/肤色、三庭五眼、四季色彩季型、量感/轮廓类型、 +直曲量感得分、面部立体度、瞳距、对比度、基因风格、动静类型、性别……字段不固定、可随时增删。 -| 字段 | 类型 | 说明 | -|------|------|------| -| face_shape | string | 脸形 | -| eyebrow_shape | string | 眉形 | -| facial_age | int | 面部年龄 | -| dynamic_static_type | string | 动静类型 | -| gender | string | 性别 | -| gene_style | object | 面部特征对应面部标签的「基因风格」 | +其中**英文优先字段**(与 doubao 中文字段并存,方便客户端直接取): -### 响应示例(当前 Mock 返回值) +| 字段 | 说明 | +|------|------| +| face_shape | 脸形(脸型)| +| eyebrow_shape | 眉形 | +| facial_age | 面部年龄(区间字符串,如"18-25岁")| +| dynamic_static_type | 动静类型(静态型/动态型)| +| gender | 性别(男/女)| +| gene_style | 基因风格(如"自然型")| + +> 无人脸返回 `1001`(据 doubao「图片是否有人脸」判定)。⚠️ 本接口是唯一调**外网云模型**的接口, +> worker 需可访问 `ark.cn-beijing.volces.com`;API Key 走 worker 配置/环境变量。 + +### 响应示例 ```json { @@ -300,7 +307,7 @@ "message": "success", "request_id": "mock-request-id", "data": { - "features": "{\"face_shape\": \"鹅蛋脸\", \"eyebrow_shape\": \"柳叶眉\", \"facial_age\": 26, \"dynamic_static_type\": \"静态\", \"gender\": \"女\", \"gene_style\": {\"label\": \"面部特征标签\", \"style\": \"基因风格示例\"}}" + "features": "{\"图片是否有人脸\":\"有人\",\"脸型\":\"鹅蛋脸\",\"眉形\":\"平眉\",\"面部年龄\":\"18-25岁\",\"四季色彩季型\":\"冷夏型\",\"基因风格\":\"少年型\",\"性别\":\"女\",\"face_shape\":\"鹅蛋脸\",\"eyebrow_shape\":\"平眉\",\"facial_age\":\"18-25岁\",\"dynamic_static_type\":\"静态型\",\"gender\":\"女\",\"gene_style\":\"少年型\"}" } } ``` diff --git a/face_features.py b/face_features.py new file mode 100644 index 0000000..f5f87c1 --- /dev/null +++ b/face_features.py @@ -0,0 +1,139 @@ +"""接口4:用户面部特征分析(调用火山方舟 豆包视觉模型 doubao-seed-1-6-vision)。 + +算法来源:/home/xsl/fuyan(FaceArk.py)。worker 把图片以 base64 data URI 传给方舟 +多模态模型,模型返回一大堆人脸特征 JSON;本模块解析后映射出接口4 的英文优先字段 +(face_shape 等),并保留 doubao 返回的全部中文字段。 + +⚠️ 这是**唯一调外网云模型**的接口(其余接口全本地)。API Key 走配置/环境变量,不入 git。 +""" +from __future__ import annotations + +import base64 +import json +import logging +import os + +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 英文优先字段 +_KEY_MAP = { + "脸型": "face_shape", + "眉形": "eyebrow_shape", + "面部年龄": "facial_age", + "动静类型": "dynamic_static_type", + "性别": "gender", + "基因风格": "gene_style", +} + +# 移植自 fuyan FaceArk.GetPicDesc 的特征枚举(去掉身高体重前缀) +_PROMPT = ( + "分析一下图片告诉我以下特征,只要答案,格式为json字符串," + "图片是否有人脸(有人/没人) 三庭五眼特征(答案要有三庭五眼四个字,9个字以内) " + "面部年龄(给出区间年龄)鼻长(鼻长适中/长鼻/短鼻) " + "脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 嘴型 " + "眼袋(答案要有眼袋两个个字) 眼型 鼻型 眼皮(双眼皮/单眼皮) " + "法令纹(有法令纹/无法令纹) 人中(人中适中/人中长/人中短) 眉形 " + "瞳色(答案要有瞳色两个字) 脖长(脖长适中/脖子短/脖子长) " + "肤色(粉一白/粉二白/粉三白/黄一白/黄二白/黄黑皮)" + "直得分 曲得分 直曲总分(直得分-曲得分) 大量感得分 小量感得分 量感总分(大量感得分-小量感得) " + "面部立体度(总分十分)瞳距(毫米)对比度(对比度较强/对比度适中/对比度较弱)" + "鼻子立体度(立体度高/立体度适中/立体度低)" + "色相(中间表示0,最大值分别是-5和5,负数表示偏冷,正数表示偏暖)" + "亮度(中间表示0,最大值分别是-5和5;负数表示暗沉,正数表示白皙)" + "色度(中间表示0,最大值分别是-5和5;负数表示饱和度低,正数表示鲜艳)" + "面部颜色对比度(10分制)" + "四季色彩季型(净春型/暖春型/浅春型/浅夏型/冷夏型/柔夏型/柔秋型/暖秋型/深秋型/净冬型/冷冬型/深冬型)" + "基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)" + "量感类型(大量感/中量感/小量感)轮廓类型(轮廓偏曲/轮廓适中/轮廓偏直)" + "动静类型(静态型/动态型)性别(男/女)" +) + +_client = None + + +def _load_api_key() -> str | None: + """ARK_API_KEY 环境变量优先,否则读 worker_config.json 的 ark_api_key。""" + key = os.getenv("ARK_API_KEY") + if key: + return key + cfg = os.path.join(os.path.dirname(__file__), "worker_config.json") + if os.path.isfile(cfg): + try: + with open(cfg, encoding="utf-8") as f: + return json.load(f).get("ark_api_key") + except Exception as e: # noqa: BLE001 + logger.warning("读取 worker_config.json ark_api_key 失败:%s", e) + return None + + +def get_client(): + global _client + if _client is None: + from volcenginesdkarkruntime import Ark + key = _load_api_key() + if not key: + raise RuntimeError("缺少火山方舟 API Key(设 ARK_API_KEY 或 worker_config.json.ark_api_key)") + _client = Ark(base_url=ARK_BASE_URL, api_key=key) + return _client + + +def _parse_json(text: str) -> dict: + """去掉 ```json 包裹后解析。""" + s = text.strip() + if s.startswith("```"): + s = s.strip("`") + if s[:4].lower() == "json": + s = s[4:] + return json.loads(s.strip()) + + +def _image_to_url(image_bytes: bytes = None, image_url: str = None) -> str: + """优先用现成 URL;否则把字节转 base64 data URI(doubao 兼容)。""" + if image_url: + return image_url + fmt = "png" if image_bytes[:8] == b"\x89PNG\r\n\x1a\n" else "jpeg" + return f"data:image/{fmt};base64," + base64.b64encode(image_bytes).decode() + + +def analyze_features(image_bytes: bytes = None, image_url: str = None) -> dict: + """调 doubao 视觉模型分析人脸特征。 + + Returns: dict —— 含接口4 英文优先字段(face_shape 等) + doubao 全部中文字段。 + 无人脸时 doubao 的「图片是否有人脸」= 没人,调用方据此可判 1001。 + """ + url = _image_to_url(image_bytes, image_url) + resp = get_client().chat.completions.create( + model=ARK_MODEL, + messages=[{ + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": url}}, + {"type": "text", "text": _PROMPT}, + ], + }], + ) + text = resp.choices[0].message.content + data = _parse_json(text) # doubao 中文字段 + # 英文优先字段映射(doubao 缺某字段则跳过) + for zh, en in _KEY_MAP.items(): + if zh in data and en not in data: + data[en] = data[zh] + return data + + +def has_face(features: dict) -> bool: + """据 doubao 的「图片是否有人脸」判断。""" + v = features.get("图片是否有人脸") or features.get("是否有人") or "" + return "没人" not in str(v) and "没有" not in str(v) + + +if __name__ == "__main__": + import sys + path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg" + with open(path, "rb") as f: + feats = analyze_features(image_bytes=f.read()) + print("has_face:", has_face(feats)) + print(json.dumps(feats, ensure_ascii=False, indent=2)) diff --git a/requirements.txt b/requirements.txt index 4e818cc..3969a90 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,5 +26,8 @@ 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:用户特征(调用火山方舟 豆包视觉模型,唯一外网依赖) +volcengine-python-sdk[ark] # from volcenginesdkarkruntime import Ark;API Key 走配置不入 git + # 测试 pytest==8.3.3 diff --git a/tests/test_api.py b/tests/test_api.py index 32514dd..933af15 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,6 @@ """接口集成测试(FastAPI TestClient):错误码 + 鉴权 + 正常用例结构。""" import base64 +import json import pytest from fastapi.testclient import TestClient @@ -150,6 +151,34 @@ def test_hairline_gen_female(client): assert "image_url" not in d["hairline_images"][0] +FEATURES = "/api/v1/face/features" + + +def test_features_success(client, monkeypatch): + import face_features as ff + monkeypatch.setattr(ff, "analyze_features", lambda *a, **k: { + "图片是否有人脸": "有人", "脸型": "鹅蛋脸", "face_shape": "鹅蛋脸", + "gender": "女", "gene_style": "少女型"}) + files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")} + body = client.post(FEATURES, headers=H, files=files).json() + assert body["code"] == 0, body + f = json.loads(body["data"]["features"]) # features 是 JSON 字符串 + assert f["face_shape"] == "鹅蛋脸" and f["gender"] == "女" + + +def test_features_no_face_1001(client, monkeypatch): + import face_features as ff + monkeypatch.setattr(ff, "analyze_features", lambda *a, **k: {"图片是否有人脸": "没人"}) + files = {"image_file": ("x.jpg", open(fixture("landscape.jpg"), "rb"), "application/octet-stream")} + assert client.post(FEATURES, headers=H, files=files).json()["code"] == 1001 + + +def test_features_multi_param_1007(client): + files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")} + r = client.post(FEATURES, headers=H, files=files, data={"image_url": "http://x/y.jpg"}) + assert r.json()["code"] == 1007 + + def test_success_structure(client): r = _post(client, "frontal.jpg") body = r.json() diff --git a/worker_config.example.json b/worker_config.example.json index fb0a0bc..172af39 100644 --- a/worker_config.example.json +++ b/worker_config.example.json @@ -1,4 +1,5 @@ { - "_comment": "worker 内网鉴权密码列表。复制为 worker_config.json 并改成强密码(不入 git)。网关请求时带 X-Internal-Token: <其中之一>。也可用环境变量 WORKER_ACCEPT_PASSWORDS=逗号分隔 覆盖。", - "accept_passwords": ["change-me-to-a-strong-secret"] + "_comment": "复制为 worker_config.json(不入 git)。accept_passwords:worker 内网鉴权密码列表,网关带 X-Internal-Token: <其中之一>(也可用环境变量 WORKER_ACCEPT_PASSWORDS 覆盖)。ark_api_key:接口4 火山方舟豆包视觉模型的 API Key(也可用环境变量 ARK_API_KEY 覆盖)。", + "accept_passwords": ["change-me-to-a-strong-secret"], + "ark_api_key": "your-volcengine-ark-api-key" }