Files
hair/docs/接口4-用户特征-网关实现方案.md
T
xslandClaude Opus 4.8 043a4c0603 docs: 接口4 网关侧实现方案(供网关开发照做)
接口4 不碰本地GPU/模型,仅调外网豆包→放网关本地实现更合理。文档含:
路由改本地处理(不转发)、httpx调方舟(OpenAI兼容,无需SDK)、prompt原文、
base64 data URI喂图、字段映射(6英文+全部中文)、无人脸→1001、配置(ark密钥入网关config)、
错误码、worker侧回收步骤、自测。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:33:50 +08:00

11 KiB
Raw Blame History

接口 4:用户特征 — 网关侧实现方案

结论先行:接口 4 不碰任何本地 GPU/模型,只是「调一次外网豆包视觉模型 + 解析 JSON」。 因此在网关本地实现(不转发给 worker)最合理:网关本就是对外那台、天然有公网出口; worker 由此保持纯内网/离线。本文档供网关开发照做。

算法来源 /home/xsl/fuyanFaceArk.py);worker 侧已有一版可跑通的实现 face_features.py(用 volcengine SDK),可直接作为「逻辑参考」。


0. 与现状的差异

  • 现网关 gateway/app.py/api/v1/face/features盲转发给 worker
    @app.post("/api/v1/face/features", tags=["人脸分析"])
    async def face_features(request: Request):
        return await _proxy(request, "/api/v1/face/features")
    
  • 改为网关本地处理(解析图片 → 调豆包 → 返回),不再转发
  • 其余 4 个接口(1/2/3/5)仍然盲转发给 worker,不变。

1. 对外契约(不变,接口文档.md 接口4

  • POST /api/v1/face/features
  • 输入:图片三选一(image_file / image_url / image_base64),无其它参数。
  • 输出:标准信封 {code, message, request_id, data},其中 data.features 是一个 JSON 字符串(不是对象),内含几十项面部特征。无人脸返回 1001

2. 依赖与配置

2.1 依赖

不需要 volcengine SDK。豆包/方舟是 OpenAI 兼容 接口,网关用现成的 httpx 直接调即可,保持轻量。 (若想省事,也可 pip install "volcengine-python-sdk[ark]" 直接照搬 worker 的 face_features.py,二选一。)

2.2 配置(加到 gateway/config.json,含密钥不入 git

{
  "ark": {
    "api_key": "14fc0280-fc65-462d-ac2d-50178c0212e3",
    "base_url": "https://ark.cn-beijing.volces.com/api/v3",
    "model": "doubao-seed-1-6-vision-250815",
    "timeout_seconds": 60
  }
}
  • config.example.json 里放占位("api_key": "your-volcengine-ark-api-key")。
  • 也可用环境变量覆盖(ARK_API_KEY 等),按网关现有风格来。
  • ⚠️ 网关机需能访问 ark.cn-beijing.volces.com(已实测可达,401=要鉴权即连通)。

3. 豆包调用(httpx 版)

3.1 请求

POST {base_url}/chat/completions,头 Authorization: Bearer {api_key},体:

{
  "model": "doubao-seed-1-6-vision-250815",
  "messages": [{
    "role": "user",
    "content": [
      { "type": "image_url", "image_url": { "url": "<图片URL 或 base64 data URI>" } },
      { "type": "text", "text": "<下面 §3.3 的 PROMPT>" }
    ]
  }]
}

返回里取 resp["choices"][0]["message"]["content"](是个带 ```json 包裹的字符串)。

3.2 图片怎么喂

  • 传了 image_url直接把这个 URL 塞进去(豆包自己去拉,最省)。
  • 传了 image_file / image_base64 → 转成 base64 data URI data:image/jpeg;base64,XXXXPNG 头 \x89PNGimage/png,否则 image/jpeg)。 (已实测豆包接受 data URI,无需先把图落到公网。)

3.3 PROMPT(移植自 fuyan,去掉身高体重前缀,原样使用

分析一下图片告诉我以下特征,只要答案,格式为json字符串,图片是否有人脸(有人/没人) 三庭五眼特征(答案要有三庭五眼四个字,9个字以内) 面部年龄(给出区间年龄)鼻长(鼻长适中/长鼻/短鼻) 脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 嘴型 眼袋(答案要有眼袋两个个字) 眼型 鼻型 眼皮(双眼皮/单眼皮) 法令纹(有法令纹/无法令纹) 人中(人中适中/人中长/人中短) 眉形 瞳色(答案要有瞳色两个字) 脖长(脖长适中/脖子短/脖子长) 肤色(粉一白/粉二白/粉三白/黄一白/黄二白/黄黑皮)直得分 曲得分 直曲总分(直得分-曲得分) 大量感得分 小量感得分 量感总分(大量感得分-小量感得) 面部立体度(总分十分)瞳距(毫米)对比度(对比度较强/对比度适中/对比度较弱)鼻子立体度(立体度高/立体度适中/立体度低)色相(中间表示0,最大值分别是-5和5,负数表示偏冷,正数表示偏暖)亮度(中间表示0,最大值分别是-5和5;负数表示暗沉,正数表示白皙)色度(中间表示0,最大值分别是-5和5;负数表示饱和度低,正数表示鲜艳)面部颜色对比度(10分制)四季色彩季型(净春型/暖春型/浅春型/浅夏型/冷夏型/柔夏型/柔秋型/暖秋型/深秋型/净冬型/冷冬型/深冬型)基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)量感类型(大量感/中量感/小量感)轮廓类型(轮廓偏曲/轮廓适中/轮廓偏直)动静类型(静态型/动态型)性别(男/女)

4. 解析 + 字段映射

  1. 去包裹解析content 去掉首尾 json json.loads,得到 doubao 的中文字段 dict。
  2. 英文优先字段(与中文并存,方便客户端直接取):
    _KEY_MAP = {
        "脸型": "face_shape", "眉形": "eyebrow_shape", "面部年龄": "facial_age",
        "动静类型": "dynamic_static_type", "性别": "gender", "基因风格": "gene_style",
    }
    for zh, en in _KEY_MAP.items():
        if zh in data and en not in data:
            data[en] = data[zh]
    
  3. 无人脸判定data["图片是否有人脸"] 含「没人」/「没有」→ 视为无人脸 → 返回 1001
  4. data.features = json.dumps(data, ensure_ascii=False)字符串)。

字段几十项:脸型/眉形/眼型/鼻型/眼袋/法令纹/人中/瞳色/脖长/肤色、三庭五眼、四季色彩季型、 量感/轮廓类型、各项得分、瞳距、对比度、基因风格、动静类型、性别…… 字段不固定、可增删。


5. 路由实现(替换盲转发)

gateway/app.py

@app.post("/api/v1/face/features", tags=["人脸分析"])
async def face_features(request: Request):
    from gateway.face_features import handle_features   # 新增模块
    return await handle_features(request)               # 网关本地处理,不再 _proxy

gateway/face_features.py(新增,伪代码):

import base64, json, uuid, httpx
from fastapi import Request
from fastapi.responses import JSONResponse
from gateway.config import get_config

PROMPT = "...(§3.3 原样)..."
_KEY_MAP = {...}  # §4

def _env(code, msg):
    return JSONResponse(status_code=200, content={
        "code": code, "message": msg, "request_id": f"gw-{uuid.uuid4().hex[:8]}", "data": None})

async def handle_features(request: Request) -> JSONResponse:
    form = await request.form()
    f = form.get("image_file"); url = form.get("image_url"); b64 = form.get("image_base64")
    provided = [x for x in (f, url, b64) if x]
    if len(provided) != 1:
        return _env(1007, "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个")

    # 图片 → URL 或 data URI
    if url:
        image_ref = url
    else:
        raw = (await f.read()) if f is not None else _decode_b64(b64)   # base64 去 data:前缀再 decode
        if len(raw) > 1_000_000:
            return _env(1006, "文件超出 1 MB 限制")
        fmt = "png" if raw[:8] == b"\x89PNG\r\n\x1a\n" else "jpeg"
        image_ref = f"data:image/{fmt};base64," + base64.b64encode(raw).decode()

    cfg = get_config()["ark"]
    try:
        async with httpx.AsyncClient(timeout=cfg.get("timeout_seconds", 60)) as cli:
            r = await cli.post(
                f'{cfg["base_url"]}/chat/completions',
                headers={"Authorization": f'Bearer {cfg["api_key"]}'},
                json={"model": cfg["model"], "messages": [{"role": "user", "content": [
                    {"type": "image_url", "image_url": {"url": image_ref}},
                    {"type": "text", "text": PROMPT}]}]})
            r.raise_for_status()
            text = r.json()["choices"][0]["message"]["content"]
        data = _parse_json(text)                       # 去 ```json 包裹
        for zh, en in _KEY_MAP.items():
            if zh in data and en not in data: data[en] = data[zh]
        if _no_face(data):
            return _env(1001, "无法识别人像")
        return JSONResponse(status_code=200, content={
            "code": 0, "message": "success", "request_id": f"gw-{uuid.uuid4().hex[:8]}",
            "data": {"features": json.dumps(data, ensure_ascii=False)}})
    except Exception as ex:
        return _env(1007, f"处理失败:{ex}")

_parse_jsons.strip(),若以 ``` 开头去掉反引号和开头的 json,再 json.loads_no_face"没人" in str(data.get("图片是否有人脸",""))_decode_b64:有 data: 前缀就 split(",",1)[1]base64.b64decode

⚠️ 网关其它接口是盲转发(不读 body);接口4 这里读了 form,没问题(不再转发,body 自己消费)。


6. 错误码

触发
1007 图片参数 0 个或多个;或调用/解析异常兜底
1006 文件 > 1MBfile/base64 路径)
1001 豆包判定「没人」
0 正常,data.features 为 JSON 字符串

7. worker 侧回收(迁移后做)

接口4 改由网关处理、不再转发后,worker 的 /api/v1/face/features 就成了死代码。建议:

  • worker app.py 把接口4 回退为 Mock(或保留真实实现做直连兜底,二选一);
  • worker requirements.txt 去掉 volcengine-python-sdk[ark]worker 保持无外网依赖);
  • worker_config.json 去掉 ark_api_key(密钥只放网关配置)。
  • worker 仓库现有 face_features.py 可作为网关实现的逻辑参考,迁移完成后 worker 侧可删。

这步不阻塞网关实现;先把网关跑通,worker 回收随后做。


8. 自测

# 经网关(对外,客户端不带 token)
curl -s -X POST https://hair.xiangsilian.com/api/v1/face/features \
  -F image_file=@<一张人像> | python -m json.tool
# 期望:code 0data.features 是 JSON 字符串,parse 后含 face_shape/gender/基因风格 等几十项
curl -s -X POST https://hair.xiangsilian.com/api/v1/face/features \
  -F image_file=@<风景图> ; # 期望 code 1001(豆包判无人脸;注意合成图偶有波动)

实测参考(worker 直跑 frontal):返回 42 字段,鹅蛋脸 / 平眉 / 18-25岁 / 静态型 / 女 / 少年型


文档版本: v1.0 创建日期: 2026-06-15 模型: 火山方舟 doubao-seed-1-6-vision 运行位置: 网关(本地 httpx 调用,不经 worker)| 逻辑参考: worker face_features.py / /home/xsl/fuyan