feat: 新增52号发际线接口并限制GPU worker来源IP
保留5号历史三档实现,新增中下庭高度定位、扶正裁切、矩形叠加与30案例对比页。 worker仅允许本机和配置白名单访问;网关不增加IP限制。空图片URL字段改写限定为52号,保持旧接口兼容。同步接口、裁切与部署文档。 验证:113项测试通过;1项历史数值基线失败,在未修改的a3fddbb版本复现。浏览器验证5/52及30案例对比页通过。
This commit is contained in:
+210
-1
@@ -18,7 +18,7 @@ H = {"X-Internal-Token": "testpass"}
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
# with 触发 lifespan:加载模型单例(detector + 尽力加载 segmenter)
|
||||
with TestClient(app_module.app) as c:
|
||||
with TestClient(app_module.app, client=("127.0.0.1", 50000)) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@@ -189,6 +189,40 @@ def test_growb_success(client, monkeypatch):
|
||||
HLGEN = "/api/v1/hairline/generate"
|
||||
|
||||
|
||||
def test_hairline5_restored_schema_has_only_historical_parameters(client):
|
||||
schema = client.get("/openapi.json", headers=H).json()
|
||||
ref = schema["paths"][HLGEN]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"]["$ref"]
|
||||
fields = schema["components"]["schemas"][ref.rsplit("/", 1)[-1]]["properties"]
|
||||
assert set(fields) == {"image_file", "image_url", "image_base64", "gender", "hair_style",
|
||||
"use_mask", "prompt", "generate_grow_image"}
|
||||
assert fields["use_mask"]["default"] is True
|
||||
assert fields["generate_grow_image"]["default"] is True
|
||||
assert fields["prompt"]["default"] == "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gender", ["female", "male"])
|
||||
def test_hairline5_restored_three_levels_and_original_coordinate_contract(client, gender):
|
||||
from pathlib import Path
|
||||
photo = Path(fixture("frontal.jpg")).read_bytes()
|
||||
body = client.post(HLGEN, headers=H, files={"image_file": ("frontal.jpg", photo, "image/jpeg")},
|
||||
data={"gender": gender, "hair_style": "1,3", "generate_grow_image": "false"}).json()
|
||||
assert body["code"] == 0, body
|
||||
data = body["data"]
|
||||
assert set(data) == {"hairline_images", "best_hairline_center_point", "high_hairline_center_point",
|
||||
"low_hairline_center_point", "face_measure"}
|
||||
expected = _cv2.imdecode(_np.frombuffer(photo, _np.uint8), _cv2.IMREAD_COLOR).shape[:2]
|
||||
for key in ("best_hairline_center_point", "high_hairline_center_point", "low_hairline_center_point"):
|
||||
assert set(data[key]) == {"x", "y"}
|
||||
assert isinstance(data[key]["x"], int) and isinstance(data[key]["y"], int)
|
||||
for item in data["hairline_images"]:
|
||||
assert set(item) == {"hairline_type", "image_middle_base64", "image_high_base64", "image_low_base64",
|
||||
"grown_image_base64", "order"}
|
||||
assert item["grown_image_base64"] is None
|
||||
for level in ("middle", "high", "low"):
|
||||
image = _cv2.imdecode(_np.frombuffer(base64.b64decode(item[f"image_{level}_base64"]), _np.uint8), _cv2.IMREAD_UNCHANGED)
|
||||
assert image.shape == (*expected, 4)
|
||||
|
||||
|
||||
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
|
||||
@@ -241,6 +275,181 @@ def test_hairline_gen_female(client, monkeypatch):
|
||||
assert set(["yaw", "pitch", "roll"]).issubset(fm["head_pose"].keys())
|
||||
|
||||
|
||||
HLGEN52 = "/api/v1/hairline/generate-52"
|
||||
|
||||
|
||||
def test_hairline52_legacy_form_parameters_and_defaults_are_preserved(client):
|
||||
schema = client.get("/openapi.json", headers=H).json()
|
||||
body = schema["paths"][HLGEN52]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"]
|
||||
body = schema["components"]["schemas"][body["$ref"].rsplit("/", 1)[-1]]
|
||||
fields = body["properties"]
|
||||
legacy = {"image_file", "image_url", "image_base64", "gender", "hair_style",
|
||||
"use_mask", "prompt", "generate_grow_image"}
|
||||
assert legacy.issubset(fields)
|
||||
assert not set(body.get("required", [])) - legacy
|
||||
for key in ("image_file", "image_url", "image_base64", "gender", "hair_style"):
|
||||
assert any(choice.get("type") == "string" for choice in fields[key].get("anyOf", [fields[key]]))
|
||||
assert fields["use_mask"]["type"] == "boolean" and fields["use_mask"]["default"] is True
|
||||
assert fields["generate_grow_image"]["type"] == "boolean" and fields["generate_grow_image"]["default"] is True
|
||||
assert fields["prompt"]["default"] == "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
assert fields["normalize_image"]["default"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("image_kind", ["file", "base64", "url"])
|
||||
def test_hairline52_legacy_request_keeps_fields_and_original_coordinates(client, monkeypatch, image_kind):
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import httpx
|
||||
import hairline.comfyui as comfy
|
||||
|
||||
raw = Path(fixture("frontal.jpg")).read_bytes()
|
||||
calls = []
|
||||
def generate(*args, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return _PNG_1x1
|
||||
monkeypatch.setattr(comfy, "run", generate)
|
||||
form = {"gender": "male", "hair_style": "1", "use_mask": "false", "prompt": "legacy prompt"}
|
||||
kwargs = {"headers": H, "data": form}
|
||||
if image_kind == "file":
|
||||
kwargs["files"] = {"image_file": ("frontal.jpg", raw, "image/jpeg")}
|
||||
elif image_kind == "base64":
|
||||
form["image_base64"] = "data:image/jpeg;base64," + base64.b64encode(raw).decode()
|
||||
else:
|
||||
url = "https://legacy.example.test/frontal.jpg"
|
||||
form["image_url"] = url
|
||||
response = httpx.Response(200, content=raw, request=httpx.Request("GET", url))
|
||||
def get_image(actual_url):
|
||||
assert actual_url == url
|
||||
return response
|
||||
monkeypatch.setattr(httpx, "Client", lambda **kw: contextlib.nullcontext(SimpleNamespace(get=get_image)))
|
||||
# No new parameters: old clients keep their original-size overlays.
|
||||
body = client.post(HLGEN52, **kwargs).json()
|
||||
assert body["code"] == 0, body
|
||||
assert {"code", "message", "request_id", "data"}.issubset(body)
|
||||
data = body["data"]
|
||||
assert {"hairline_images", "best_hairline_center_point", "high_hairline_center_point",
|
||||
"low_hairline_center_point", "face_measure"}.issubset(data)
|
||||
assert data["coordinate_space"] == "original_image" and data["framing"] is None
|
||||
assert data["input_image_base64"] == data["original_image_base64"]
|
||||
original = _cv2.imdecode(_np.frombuffer(raw, _np.uint8), _cv2.IMREAD_COLOR)
|
||||
expected = original.shape[:2]
|
||||
assert (data["height"], data["width"]) == expected
|
||||
item = data["hairline_images"][0]
|
||||
assert {"hairline_type", "image_middle_base64", "image_high_base64", "image_low_base64",
|
||||
"grown_image_base64", "order"}.issubset(item)
|
||||
assert item["image_high_base64"] is None and item["image_low_base64"] is None
|
||||
assert data["high_hairline_center_point"] is None and data["low_hairline_center_point"] is None
|
||||
for key in ("image_middle_base64", "grown_image_base64"):
|
||||
image = _cv2.imdecode(_np.frombuffer(base64.b64decode(item[key]), _np.uint8), _cv2.IMREAD_UNCHANGED)
|
||||
assert image.shape[:2] == expected
|
||||
assert len(calls) == 1 and calls[0]["prompt"] == "legacy prompt"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("normalize_image", [True, False])
|
||||
def test_hairline52_height_compare_uses_same_new_rule(client, monkeypatch, normalize_image):
|
||||
import hairline.comfyui as comfy
|
||||
def unexpected_generation(*args, **kwargs):
|
||||
pytest.fail("Position comparison must not invoke ComfyUI by default")
|
||||
monkeypatch.setattr(comfy, "run", unexpected_generation)
|
||||
with open(fixture("frontal.jpg"), "rb") as photo:
|
||||
response = client.post("/api/v1/debug/hairline-height-compare",
|
||||
files={"image_file": ("frontal.jpg", photo, "image/jpeg")},
|
||||
data={"gender": "male", "hair_style": "1,3", "normalize_image": str(normalize_image).lower()})
|
||||
data = response.json()
|
||||
assert data["code"] == 0, data
|
||||
data = data["data"]
|
||||
assert [r["order"] for r in data["results"]] == [1, 3]
|
||||
assert set(data["guide_landmarks"]) == {"9", "150", "152", "162", "251", "389"}
|
||||
assert data["guide_landmarks"]["9"]["y"] < data["guide_landmarks"]["152"]["y"]
|
||||
expected_size = (data["height"], data["width"])
|
||||
base = _cv2.imdecode(_np.frombuffer(base64.b64decode(data["input_base64"]), _np.uint8), _cv2.IMREAD_COLOR)
|
||||
assert base.shape[:2] == expected_size
|
||||
assert data["coordinate_space"] == ("processed_image" if normalize_image else "original_image")
|
||||
if normalize_image:
|
||||
assert data["framing"]["enabled"] is True
|
||||
assert data["framing"]["axis_x_error_px"] < 1e-8
|
||||
assert data["guide_landmarks"]["9"]["x"] == pytest.approx(data["guide_landmarks"]["152"]["x"], abs=1e-8)
|
||||
else:
|
||||
assert data["framing"] is None
|
||||
original = _cv2.imread(fixture("frontal.jpg"))
|
||||
assert base.shape == original.shape
|
||||
for result in data["results"]:
|
||||
assert result["height_placement"]["target_error_px"] <= 1
|
||||
assert result["grown_image_base64"] is None
|
||||
for key in ("old_overlay_base64", "new_overlay_base64"):
|
||||
assert base64.b64decode(result[key])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
overlay = _cv2.imdecode(_np.frombuffer(base64.b64decode(result[key]), _np.uint8), _cv2.IMREAD_UNCHANGED)
|
||||
assert overlay.shape[:2] == expected_size
|
||||
|
||||
|
||||
def test_hairline52_gen_missing_gender_1004(client):
|
||||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||||
assert client.post(HLGEN52, headers=H, files=files).json()["code"] == 1004
|
||||
|
||||
|
||||
def test_hairline52_gen_missing_hairstyle_1007(client):
|
||||
# hair_style 已改为必填(同接口2):只给 gender、不给 hair_style → 1007
|
||||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||||
assert client.post(HLGEN52, headers=H, files=files, data={"gender": "female"}).json()["code"] == 1007
|
||||
|
||||
|
||||
def test_hairline52_gen_female(client, monkeypatch):
|
||||
# mock ComfyUI:验证处理后底图、发际线和生发图使用相同尺寸与坐标。
|
||||
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")}
|
||||
body = client.post(HLGEN52, headers=H, files=files,
|
||||
data={"gender": "female", "hair_style": "1,3", "normalize_image": "true"}).json()
|
||||
assert body["code"] == 0, body
|
||||
d = body["data"]
|
||||
assert d["coordinate_space"] == "processed_image"
|
||||
assert d["framing"]["enabled"] is True
|
||||
assert d["framing"]["axis_x_error_px"] < 1e-8
|
||||
expected_size = (d["height"], d["width"])
|
||||
for value in (d["input_image_base64"], d["hairline_images"][0]["image_middle_base64"],
|
||||
d["hairline_images"][0]["grown_image_base64"]):
|
||||
image = _cv2.imdecode(_np.frombuffer(base64.b64decode(value), _np.uint8), _cv2.IMREAD_UNCHANGED)
|
||||
assert image.shape[:2] == expected_size
|
||||
imgs = d["hairline_images"]
|
||||
# 只返回选中发型,order = 发型序号
|
||||
assert [x["order"] for x in imgs] == [1, 3]
|
||||
assert [x["hairline_type"] for x in imgs] == ["ellipse", "heart"]
|
||||
# Only the designed middle curve is generated; deprecated fields stay null.
|
||||
assert base64.b64decode(imgs[0]["image_middle_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
assert all(x["image_high_base64"] is None and x["image_low_base64"] is None for x in imgs)
|
||||
assert d["high_hairline_center_point"] is None and d["low_hairline_center_point"] is None
|
||||
rule = d["height_rule"]
|
||||
assert rule["target_upper_height_px"] == pytest.approx(
|
||||
(rule["middle_height_px"] + rule["lower_height_px"]) / 2)
|
||||
for x in imgs:
|
||||
assert x["height_placement"]["new_center"]["y"] == pytest.approx(rule["target_center"]["y"], abs=1)
|
||||
assert base64.b64decode(imgs[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff"
|
||||
assert "image_middle_url" not in imgs[0]
|
||||
c = d["best_hairline_center_point"]
|
||||
assert 0 <= c["x"] < d["width"] and 0 <= c["y"] < d["height"]
|
||||
# face_measure:复用接口1测量数值(容错,失败为 null;本用例正常 → 必须有完整结构)
|
||||
fm = d["face_measure"]
|
||||
assert fm is not None, "face_measure 不应为 null(正常正面照)"
|
||||
assert set(["face_total_height_cm", "four_courts", "seven_eyes",
|
||||
"landmarks", "hairline_source", "head_pose"]).issubset(fm.keys())
|
||||
# 不应含标注图字段(接口52 只要数值,不要画线图)
|
||||
assert "annotated_image_base64" not in fm
|
||||
assert "annotated_image_url" not in fm
|
||||
# 子结构
|
||||
assert set(["top_court_cm", "upper_court_cm", "middle_court_cm",
|
||||
"lower_court_cm", "ratios"]).issubset(fm["four_courts"].keys())
|
||||
assert set(["eye_width_cm", "face_width_cm", "inter_eye_distance_cm",
|
||||
"ratios"]).issubset(fm["seven_eyes"].keys())
|
||||
# 七眼 eye1~eye7 键必须存在(eye1/eye7 在耳朵不可见时可为 null)
|
||||
assert set([f"eye{i}" for i in range(1, 8)]).issubset(fm["seven_eyes"].keys())
|
||||
assert set(["hair_top", "hairline", "brow_center",
|
||||
"nose_bottom", "chin_tip"]).issubset(fm["landmarks"].keys())
|
||||
assert fm["hairline_source"] in ("segmentation", "estimated", "corrected")
|
||||
assert set(["yaw", "pitch", "roll"]).issubset(fm["head_pose"].keys())
|
||||
|
||||
|
||||
# 接口4(用户特征)已迁到网关本机实现(直接调豆包),不再在 worker;
|
||||
# 其测试随实现一起在网关侧做,worker 这边不再覆盖。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user