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

220 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 接口 4:用户特征 — 网关侧实现方案
> **结论先行**:接口 4 不碰任何本地 GPU/模型,只是「调一次外网豆包视觉模型 + 解析 JSON」。
> 因此**在网关本地实现**(不转发给 worker)最合理:网关本就是对外那台、天然有公网出口;
> worker 由此保持纯内网/离线。本文档供网关开发照做。
>
> 算法来源 `/home/xsl/fuyan`FaceArk.py);worker 侧已有一版可跑通的实现
> `face_features.py`(用 volcengine SDK),可直接作为「逻辑参考」。
---
## 0. 与现状的差异
- 现网关 `gateway/app.py``/api/v1/face/features` 是**盲转发**给 worker
```python
@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
```json
{
"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}`,体:
```json
{
"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,XXXX`PNG 头 `\x89PNG` 用 `image/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. **英文优先字段**(与中文并存,方便客户端直接取):
```python
_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`
```python
@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`(新增,伪代码):
```python
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_json``s.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. 自测
```bash
# 经网关(对外,客户端不带 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`