feat: 新增52号发际线接口并限制GPU worker来源IP

保留5号历史三档实现,新增中下庭高度定位、扶正裁切、矩形叠加与30案例对比页。

worker仅允许本机和配置白名单访问;网关不增加IP限制。空图片URL字段改写限定为52号,保持旧接口兼容。同步接口、裁切与部署文档。

验证:113项测试通过;1项历史数值基线失败,在未修改的a3fddbb版本复现。浏览器验证5/52及30案例对比页通过。
This commit is contained in:
xsl
2026-11-04 10:20:29 +08:00
parent a3fddbbc34
commit 5b2b73f5d5
35 changed files with 2826 additions and 26 deletions
+210 -1
View File
@@ -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 这边不再覆盖。