Compare commits
3
Commits
795de68985
...
043a4c0603
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
043a4c0603 | ||
|
|
3008552331 | ||
|
|
0e71830bb6 |
@@ -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}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# 接口 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 | 文件 > 1MB(file/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 0;data.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`
|
||||
+19
-12
@@ -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\":\"少年型\"}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+3
-1
@@ -206,7 +206,9 @@ sudo systemctl restart hair-gateway && sudo systemctl status hair-gateway
|
||||
1. **联调依赖 worker**:阶段四之前可用**本地 stub worker**(返回 `/health` 200 + 假的 base64 图)独立开发;worker 真机就绪后再换真实地址端到端联调。
|
||||
2. **接口文档是字段唯一权威**:图片字段映射表、对外字段名都以 `docs/接口文档.md` 为准,冲突时以文档为准并在 PR 说明指出。
|
||||
3. **安全(先跑通后处理,已知项)**:`:28187` 当前 HTTP 明文 + 公网可达,密码明文传输;后续建议加 TLS / 内网 / IP 白名单(见架构文档 §13/§14)。本阶段不阻塞。
|
||||
4. **接口实现进度**:接口 **1/2/3/5 worker 已真实实现**(图片字段见上方映射表);**接口 4(用户特征)仍为 mock**(无图片字段,原样透传)。
|
||||
4. **接口实现进度**:**接口 1/2/3/4/5 worker 均已真实实现**。接口 4(用户特征)返回 `features`(JSON 字符串)、
|
||||
**无图片字段,网关原样透传**;但接口4 worker 会调外网豆包视觉模型(`ark.cn-beijing.volces.com`)——
|
||||
网关本身不受影响,但要知道该接口耗时含一次远程大模型调用(数秒)。
|
||||
5. **生发图耗时**:接口 2(一次 N 张 Flux,~18s)、接口 3(~6s)经 ComfyUI 同步出图,**`request_timeout_seconds` 要调大**(建议 ≥120s),否则网关会先超时换 worker 重试。
|
||||
|
||||
---
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user