医生在额头用马克笔画规划发际线 → 检测该线 → 生发。检测算法源自 /home/xsl/headmark。 - hairline/marker_detect.py: 黑帽响应图(MORPH_BLACKHAT)+鬓角锚点(MediaPipe 21/251吸附) +skimage route_through_array 最小路径检测画线;路径平均响应阈值拒识无画线 (headmark 调研:全局灰度阈值不可用,黑帽+Dijkstra 实测误差≤0.5px) - hairline/mask.py: 抽出 mask_from_curve(曲线+ROI闭合),接口2/3共用 - hairline/service.py: generate_grow_b——检测→遮罩→原图重画干净线→ComfyUI生发 - app.py: /hair/grow-b 真实实现,marked+original各三选一+校验;输出 best_hairline_image_base64(=原图)/hair_growth_image_base64/hairline_type="custom"; 无人脸或未检测到画线→1001;重活进线程池 - requirements: scikit-image==0.24.0 (⚠️锁0.24,0.25+强依赖numpy>=2会顶掉mediapipe的numpy<2) - 文档: docs/接口3-B端生发-技术实现方案.md - 测试: test_marker.py(检测/拒识/辅助) + test_api grow-b(mock ComfyUI),42全绿 实测(5090): grow-b ~6.4s,生发图把额头发际线补到医生画线、清除划线、人物保持。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
157 lines
5.6 KiB
Python
157 lines
5.6 KiB
Python
"""接口集成测试(FastAPI TestClient):错误码 + 鉴权 + 正常用例结构。"""
|
||
import base64
|
||
|
||
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_original_1007(client):
|
||
files = {"marked_image_file": ("m.jpg", open(fixture("marked_hairline.jpg"), "rb"), "application/octet-stream")}
|
||
r = client.post(GROWB, headers=H, files=files)
|
||
assert r.json()["code"] == 1007
|
||
|
||
|
||
def test_growb_no_line_1001(client):
|
||
f = lambda: open(fixture("frontal.jpg"), "rb")
|
||
files = {"marked_image_file": ("m.jpg", f(), "application/octet-stream"),
|
||
"original_image_file": ("o.jpg", f(), "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)
|
||
fm = open(fixture("marked_hairline.jpg"), "rb")
|
||
fo = open(fixture("marked_hairline.jpg"), "rb")
|
||
files = {"marked_image_file": ("m.jpg", fm, "application/octet-stream"),
|
||
"original_image_file": ("o.jpg", fo, "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 d["best_hairline_image_base64"] # 原图原样(非空)
|
||
assert "best_hairline_image_url" not in d
|
||
|
||
|
||
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"
|