Files
hair/tests/test_api.py
T
xslandClaude Opus 4.8 0e71830bb6 feat(接口4): 用户特征-火山方舟豆包视觉模型(替换Mock)
接口4 改用远程视觉大模型(算法来源 /home/xsl/fuyan),一次返回几十项面部特征。

- face_features.py: 火山方舟 Ark 客户端(单例)+doubao-seed-1-6-vision prompt(移植fuyan)
  +base64 data URI喂图(实测doubao接受)+解析JSON+映射6英文优先字段(face_shape等)
  并保留doubao全部中文字段;has_face 据"图片是否有人脸"判定
- app.py: /face/features 真实实现,三选一图(image_url直传doubao,file/base64转dataURI),
  features 为 JSON 字符串;无人脸→1001;重活线程池
- 配置: API Key 走 worker_config.json.ark_api_key / 环境变量 ARK_API_KEY(不入git);
  worker_config.example 加占位; requirements 加 volcengine-python-sdk[ark]
- 文档: 接口文档/OpenAPI 更新为豆包实现+几十项字段+1001
- 测试: mock doubao 的成功/无人脸/多参,47全绿

⚠️ 唯一调外网云模型的接口,worker 需可达 ark.cn-beijing.volces.com(已实测可达)。
实测 frontal: 42字段, 鹅蛋脸/平眉/18-25岁/静态型/女/少年型。

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

200 lines
7.5 KiB
Python
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.
"""接口集成测试(FastAPI TestClient):错误码 + 鉴权 + 正常用例结构。"""
import base64
import json
import pytest
from fastapi.testclient import TestClient
from conftest import fixture
import app as app_module
# 测试自带固定密码,避免依赖 worker_config.json 的实际值(中间件运行期读模块全局)
app_module.ACCEPT_PASSWORDS = ["testpass"]
URL = "/api/v1/face/measure"
H = {"X-Internal-Token": "testpass"}
@pytest.fixture(scope="module")
def client():
# with 触发 lifespan:加载模型单例(detector + 尽力加载 segmenter
with TestClient(app_module.app) as c:
yield c
def _post(client, fixture_name=None, headers=H, data=None, extra_files=None):
files = {}
if fixture_name:
files["image_file"] = (fixture_name, open(fixture(fixture_name), "rb"), "application/octet-stream")
if extra_files:
files.update(extra_files)
return client.post(URL, headers=headers, files=files or None, data=data)
def test_auth_missing_token_401(client):
r = _post(client, "frontal.jpg", headers={})
assert r.status_code == 401
def test_health_no_token_200(client):
r = client.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_param_none_provided_1007(client):
r = client.post(URL, headers=H)
assert r.json()["code"] == 1007
def test_param_multiple_provided_1007(client):
r = _post(client, "frontal.jpg", data={"image_url": "http://example.com/x.jpg"})
assert r.json()["code"] == 1007
def test_oversize_1006(client, oversize_file):
files = {"image_file": ("oversize.bin", open(oversize_file, "rb"), "application/octet-stream")}
r = client.post(URL, headers=H, files=files)
assert r.json()["code"] == 1006
def test_lowres_1002(client):
r = _post(client, "lowres.png")
assert r.json()["code"] == 1002
def test_no_face_1001(client):
r = _post(client, "landscape.jpg")
assert r.json()["code"] == 1001
def test_corrupt_1008(client):
r = _post(client, "corrupt.bin")
assert r.json()["code"] == 1008
GROW = "/api/v1/hair/grow"
def test_grow_missing_gender_1004(client):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
r = client.post(GROW, headers=H, files=files)
assert r.json()["code"] == 1004
_PNG_1x1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
def test_grow_female_returns_5(client, monkeypatch):
# mock ComfyUI:不依赖 8182、不跑 Flux,只验证管线接线 + grown 字段
import hairline.comfyui as comfy
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
r = client.post(GROW, headers=H, files=files, data={"gender": "female"})
body = r.json()
assert body["code"] == 0, body
results = body["data"]["results"]
assert [x["hairline_type"] for x in results] == ["ellipse", "flower", "heart", "straight", "wave"]
assert [x["order"] for x in results] == [1, 2, 3, 4, 5]
assert base64.b64decode(results[0]["image_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
assert base64.b64decode(results[0]["grown_image_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
assert "image_url" not in results[0]
GROWB = "/api/v1/hair/grow-b"
def test_growb_missing_marked_1007(client):
r = client.post(GROWB, headers=H) # 一张图都没传
assert r.json()["code"] == 1007
def test_growb_no_line_1001(client):
files = {"marked_image_file": ("m.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
r = client.post(GROWB, headers=H, files=files) # 无划线 → 拒识
assert r.json()["code"] == 1001
def test_growb_success(client, monkeypatch):
import hairline.comfyui as comfy
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
files = {"marked_image_file": ("m.jpg", open(fixture("marked_hairline.jpg"), "rb"), "application/octet-stream")}
body = client.post(GROWB, headers=H, files=files).json() # 只传划线图一张
assert body["code"] == 0, body
d = body["data"]
assert d["hairline_type"] == "custom"
assert base64.b64decode(d["hair_growth_image_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
assert "best_hairline_image_base64" not in d # 已去掉该字段
assert "best_hairline_image_url" not in d
HLGEN = "/api/v1/hairline/generate"
def test_hairline_gen_missing_gender_1004(client):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
assert client.post(HLGEN, headers=H, files=files).json()["code"] == 1004
def test_hairline_gen_female(client):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
body = client.post(HLGEN, headers=H, files=files, data={"gender": "female"}).json()
assert body["code"] == 0, body
d = body["data"]
assert [x["order"] for x in d["hairline_images"]] == [1, 2, 3, 4, 5]
assert base64.b64decode(d["hairline_images"][0]["image_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
c = d["best_hairline_center_point"]
assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内
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()
assert body["code"] == 0, body
data = body["data"]
# 业务字段对齐文档
assert set(["face_total_height_cm", "four_courts", "seven_eyes",
"landmarks", "hairline_source", "head_pose",
"annotated_image_base64"]).issubset(data.keys())
assert set(["top_court_cm", "upper_court_cm", "middle_court_cm",
"lower_court_cm", "ratios"]).issubset(data["four_courts"].keys())
assert set(["eye_width_cm", "face_width_cm", "inter_eye_distance_cm",
"ratios"]).issubset(data["seven_eyes"].keys())
assert data["hairline_source"] in ("segmentation", "estimated")
# base64 解码为合法 PNG(非 URL
assert "annotated_image_url" not in data
png = base64.b64decode(data["annotated_image_base64"])
assert png[:8] == b"\x89PNG\r\n\x1a\n"