feat: 新增52号发际线接口并限制GPU worker来源IP
保留5号历史三档实现,新增中下庭高度定位、扶正裁切、矩形叠加与30案例对比页。 worker仅允许本机和配置白名单访问;网关不增加IP限制。空图片URL字段改写限定为52号,保持旧接口兼容。同步接口、裁切与部署文档。 验证:113项测试通过;1项历史数值基线失败,在未修改的a3fddbb版本复现。浏览器验证5/52及30案例对比页通过。
This commit is contained in:
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"description": "用户指定的接口 52 发际线高度规则回归验证图片清单",
|
||||
"recorded_on": "2026-11-03",
|
||||
"endpoint": "/api/v1/hairline/generate-52",
|
||||
"total": 30,
|
||||
"cases": [
|
||||
{"image_number": 1, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%201.png"},
|
||||
{"image_number": 2, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%202.png"},
|
||||
{"image_number": 3, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%203.png"},
|
||||
{"image_number": 4, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%204.png"},
|
||||
{"image_number": 5, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%205.png"},
|
||||
{"image_number": 6, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%206.png"},
|
||||
{"image_number": 7, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%207.png"},
|
||||
{"image_number": 20, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2020.png"},
|
||||
{"image_number": 21, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2021.png"},
|
||||
{"image_number": 22, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2022.png"},
|
||||
{"image_number": 23, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2023.png"},
|
||||
{"image_number": 27, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2027.png"},
|
||||
{"image_number": 32, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2032.png"},
|
||||
{"image_number": 33, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2033.png"},
|
||||
{"image_number": 34, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2034.png"},
|
||||
{"image_number": 35, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2035.png"},
|
||||
{"image_number": 36, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2036.png"},
|
||||
{"image_number": 37, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2037.png"},
|
||||
{"image_number": 40, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2040.png"},
|
||||
{"image_number": 41, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2041.png"},
|
||||
{"image_number": 42, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2042.png"},
|
||||
{"image_number": 43, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2043.png"},
|
||||
{"image_number": 44, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2044.png"},
|
||||
{"image_number": 45, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2045.png"},
|
||||
{"image_number": 46, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2046.png"},
|
||||
{"image_number": 47, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2047.png"},
|
||||
{"image_number": 50, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2050.png"},
|
||||
{"image_number": 51, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2051.png"},
|
||||
{"image_number": 52, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2052.png"},
|
||||
{"image_number": 53, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2053.png"}
|
||||
]
|
||||
}
|
||||
+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 这边不再覆盖。
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Framing must align real pixel coordinates without scaling or invented pixels."""
|
||||
import io
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import pytest
|
||||
|
||||
from hairline.framing import calibrate_reference, crop_fits, decode_portrait, normalize_portrait, transform_points
|
||||
|
||||
|
||||
PROFILE = {"version": "test", "reference_image_url": "/reference.png",
|
||||
"width_per_face_length": 2.1, "height_per_face_length": 2.8,
|
||||
"brow_x_ratio": .49, "brow_y_ratio": .34}
|
||||
|
||||
|
||||
def landmarks(w, h, brow, length=100, angle=0):
|
||||
lm = np.zeros((478, 3), np.float64)
|
||||
down = np.array([np.sin(np.deg2rad(angle)), np.cos(np.deg2rad(angle))])
|
||||
lm[:, :2] = np.array(brow)/[w, h]
|
||||
lm[152, :2] = (np.array(brow)+down*length)/[w, h]
|
||||
lm[:, 2] = .01
|
||||
return lm
|
||||
|
||||
|
||||
@pytest.mark.parametrize("angle", [-22, 0, 17])
|
||||
def test_native_scale_upright_and_inverse_mapping(angle):
|
||||
photo = np.full((700, 600, 3), 127, np.uint8)
|
||||
lm = landmarks(600, 700, (300, 280), angle=angle)
|
||||
result, moved, metadata = normalize_portrait(photo, lm, PROFILE)
|
||||
h, w = result.shape[:2]
|
||||
assert metadata["standard_crop"] is True
|
||||
assert w/h == .75
|
||||
xy = moved[[9, 152], :2]*[w, h]
|
||||
assert abs(xy[0, 0]-xy[1, 0]) < 1e-9
|
||||
assert xy[1, 1]-xy[0, 1] == pytest.approx(100)
|
||||
assert xy[0, 0]/w == pytest.approx(PROFILE["brow_x_ratio"])
|
||||
assert xy[0, 1]/h == pytest.approx(PROFILE["brow_y_ratio"])
|
||||
matrix = np.array(metadata["source_to_processed"])
|
||||
np.testing.assert_allclose(np.linalg.svd(matrix[:, :2])[1], [1, 1], atol=1e-10)
|
||||
restored = transform_points(moved[:, :2]*[w, h], np.array(metadata["processed_to_source"]))
|
||||
np.testing.assert_allclose(restored, lm[:, :2]*[600, 700], atol=1e-9)
|
||||
assert result.min() == 127 # no black triangles / padded background
|
||||
|
||||
|
||||
def test_insufficient_space_keeps_upright_original_size_when_no_rotation_needed():
|
||||
photo = np.full((300, 230, 3), 127, np.uint8)
|
||||
result, moved, metadata = normalize_portrait(photo, landmarks(230, 300, (115, 80), 150), PROFILE)
|
||||
assert metadata["standard_crop"] is False
|
||||
assert metadata["skip_reason"] == "insufficient_space"
|
||||
assert result.shape == photo.shape
|
||||
np.testing.assert_array_equal(result, photo)
|
||||
assert abs(moved[9, 0]-moved[152, 0]) < 1e-10
|
||||
|
||||
|
||||
def test_rotated_fallback_has_no_empty_corners():
|
||||
photo = np.full((400, 300, 3), 127, np.uint8)
|
||||
result, moved, metadata = normalize_portrait(photo, landmarks(300, 400, (150, 130), 160, 12), PROFILE)
|
||||
assert metadata["standard_crop"] is False
|
||||
assert result.min() == 127
|
||||
assert metadata["axis_x_error_px"] < 1e-10
|
||||
assert crop_fits(np.array(metadata["source_to_processed"]), (300, 400), result.shape[1::-1])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("brow,angle", [((150, 330), 0), ((150, 330), 8), ((150, 70), -8)])
|
||||
def test_tall_fallback_is_three_by_four_and_retains_face(brow, angle):
|
||||
photo = np.full((700, 300, 3), 127, np.uint8)
|
||||
lm = landmarks(300, 700, brow, length=200, angle=angle)
|
||||
image, moved, metadata = normalize_portrait(photo, lm, PROFILE)
|
||||
h, w = image.shape[:2]
|
||||
assert metadata["standard_crop"] is False
|
||||
assert metadata["aspect_crop"] is True
|
||||
assert metadata["status"] == "aspect_crop"
|
||||
assert w*4 == h*3
|
||||
xy = moved[:, :2]*[w, h]
|
||||
assert (xy >= -1e-7).all() and (xy <= [w-1+1e-7, h-1+1e-7]).all()
|
||||
assert abs(xy[9, 0]-xy[152, 0]) < 1e-9
|
||||
assert np.linalg.norm(xy[152]-xy[9]) == pytest.approx(200)
|
||||
assert image.min() == 127
|
||||
assert crop_fits(np.array(metadata["source_to_processed"]), (300, 700), (w, h))
|
||||
|
||||
|
||||
def test_tall_fallback_does_not_cut_face_that_cannot_fit_three_by_four():
|
||||
photo = np.full((700, 300, 3), 127, np.uint8)
|
||||
image, moved, metadata = normalize_portrait(photo, landmarks(300, 700, (150, 100), length=450), PROFILE)
|
||||
assert metadata["aspect_crop"] is False
|
||||
assert metadata["status"] == "rotation_only"
|
||||
assert image.shape == photo.shape
|
||||
|
||||
|
||||
def test_reference_parameters_are_measured_after_straightening():
|
||||
photo = np.full((400, 300, 3), 127, np.uint8)
|
||||
image, moved, profile = calibrate_reference(photo, landmarks(300, 400, (145, 140), angle=8))
|
||||
h, w = image.shape[:2]
|
||||
assert w/h == .75 and w < 300 and h < 400
|
||||
assert profile["brow_9"]["x"] == pytest.approx(profile["chin_152"]["x"], abs=1e-9)
|
||||
assert profile["brow_x_ratio"] == pytest.approx(moved[9, 0])
|
||||
assert profile["brow_y_ratio"] == pytest.approx(moved[9, 1])
|
||||
assert profile["reference_width_per_face_length"] == pytest.approx(w/100)
|
||||
assert profile["reference_height_per_face_length"] == pytest.approx(h/100)
|
||||
assert profile["width_per_face_length"] == 2.5
|
||||
assert profile["height_per_face_length"] == pytest.approx(3/.9)
|
||||
assert image.min() == 127
|
||||
|
||||
|
||||
def test_default_crop_makes_face_ten_percent_smaller_than_three_l():
|
||||
photo = np.full((700, 600, 3), 127, np.uint8)
|
||||
result, moved, metadata = normalize_portrait(photo, landmarks(600, 700, (300, 280), length=120))
|
||||
assert metadata["standard_crop"] is True
|
||||
assert result.shape[:2] == (400, 300)
|
||||
assert metadata["face_axis_length_px"] == pytest.approx(120)
|
||||
assert metadata["height_per_face_length"] == pytest.approx(3/.9)
|
||||
assert metadata["width_per_face_length"] == 2.5
|
||||
old_height = 3*120
|
||||
assert old_height/result.shape[0] == pytest.approx(.9)
|
||||
|
||||
|
||||
def test_phone_orientation_is_applied_before_landmarks():
|
||||
image = Image.new("RGB", (120, 80), "white")
|
||||
exif = Image.Exif(); exif[274] = 6
|
||||
buf = io.BytesIO(); image.save(buf, format="JPEG", exif=exif)
|
||||
assert decode_portrait(buf.getvalue()).shape[:2] == (120, 80)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""The legacy interface 5 and new interface 52 forward to separate worker paths."""
|
||||
import asyncio
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,path", [
|
||||
("hairline_generate", "/api/v1/hairline/generate"),
|
||||
("hairline_generate_52", "/api/v1/hairline/generate-52"),
|
||||
])
|
||||
def test_distinct_hairline_forwarding(monkeypatch, name, path):
|
||||
gateway = importlib.import_module("gateway.app")
|
||||
request = Request({"type": "http", "method": "POST", "path": path, "headers": []})
|
||||
calls = []
|
||||
|
||||
async def proxy(actual_request, actual_path):
|
||||
assert actual_request is request
|
||||
calls.append(actual_path)
|
||||
return {"forwarded_to": actual_path}
|
||||
|
||||
monkeypatch.setattr(gateway, "_proxy", proxy)
|
||||
endpoint = getattr(gateway, name)
|
||||
assert next(route for route in gateway.app.routes if getattr(route, "path", None) == path).endpoint is endpoint
|
||||
assert asyncio.run(endpoint(request)) == {"forwarded_to": path}
|
||||
assert calls == [path]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Nullable/deprecated worker images retain their documented gateway keys."""
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from gateway.forward import rewrite_base64_to_url
|
||||
|
||||
|
||||
def test_nullable_image_fields_keep_external_names(tmp_path):
|
||||
value = {"hairline_images": [{"image_high_base64": None, "image_low_base64": None,
|
||||
"grown_image_base64": None}],
|
||||
"high_hairline_center_point": None, "face_measure": None}
|
||||
actual = rewrite_base64_to_url(value, "https://example.test", str(tmp_path), rewrite_null_images=True)
|
||||
assert actual == {"hairline_images": [{"image_high_url": None, "image_low_url": None,
|
||||
"grown_image_url": None}],
|
||||
"high_hairline_center_point": None, "face_measure": None}
|
||||
assert not list(tmp_path.iterdir())
|
||||
|
||||
|
||||
def test_nonempty_images_still_rewrite_alongside_null_fields(tmp_path):
|
||||
import base64
|
||||
import io
|
||||
from PIL import Image
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGBA", (2, 2), (255, 255, 255, 0)).save(buf, format="PNG")
|
||||
value = {"image_middle_base64": base64.b64encode(buf.getvalue()).decode(),
|
||||
"image_high_base64": None}
|
||||
actual = rewrite_base64_to_url(value, "https://example.test", str(tmp_path), rewrite_null_images=True)
|
||||
assert actual["image_middle_url"].startswith("https://example.test/static/annotations/")
|
||||
assert actual["image_high_url"] is None
|
||||
assert len(list(tmp_path.iterdir())) == 1
|
||||
|
||||
|
||||
def test_legacy_nullable_image_field_names_remain_unchanged(tmp_path):
|
||||
value = {"hairline_images": [{"grown_image_base64": None}], "face_measure": None}
|
||||
assert rewrite_base64_to_url(value, "https://example.test", str(tmp_path)) == value
|
||||
assert not list(tmp_path.iterdir())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,expected_key", [
|
||||
("/api/v1/hairline/generate", "grown_image_base64"),
|
||||
("/api/v1/hairline/generate-52", "grown_image_url"),
|
||||
])
|
||||
def test_proxy_rewrites_null_images_only_for_interface52(tmp_path, monkeypatch, path, expected_key):
|
||||
import gateway.config as config
|
||||
import gateway.forward as forward
|
||||
|
||||
monkeypatch.setattr(config, "get_config", lambda: {
|
||||
"dispatch": {"request_timeout_seconds": 10}, "shared_password": "test-token",
|
||||
"public_base_url": "https://example.test", "static_dir": str(tmp_path),
|
||||
})
|
||||
monkeypatch.setattr(forward, "acquire_worker", AsyncMock(return_value=SimpleNamespace(url="http://worker")))
|
||||
monkeypatch.setattr(forward, "release_worker", AsyncMock())
|
||||
worker_response = httpx.Response(200, json={"code": 0, "data": {"hairline_images": [
|
||||
{"grown_image_base64": None}]}})
|
||||
monkeypatch.setattr(forward, "get_client", lambda: SimpleNamespace(request=AsyncMock(return_value=worker_response)))
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
request = Request({"type": "http", "method": "POST", "path": path, "headers": []}, receive)
|
||||
response = asyncio.run(forward.proxy_request(request, path))
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body)["data"]["hairline_images"] == [{expected_key: None}]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Court-based design placement, including preview/inpaint consistency."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from hairline.height_rule import (
|
||||
height_target, curve_center, place_curve, inpaint_from_curve,
|
||||
)
|
||||
|
||||
|
||||
def _landmarks(width=320, height=400):
|
||||
lm = np.zeros((468, 3), np.float32)
|
||||
for index, xy in {9: (160, 180), 94: (160, 260), 152: (160, 320),
|
||||
10: (160, 140), 21: (70, 170), 251: (250, 170)}.items():
|
||||
lm[index, :2] = np.array(xy) / [width, height]
|
||||
return lm
|
||||
|
||||
|
||||
def test_target_uses_mean_courts_without_old_estimation_ratio():
|
||||
rule = height_target(_landmarks(), 320, 400)
|
||||
assert rule["middle_height_px"] == pytest.approx(80, abs=1e-4)
|
||||
assert rule["lower_height_px"] == pytest.approx(60, abs=1e-4)
|
||||
assert rule["target_upper_height_px"] == pytest.approx(70, abs=1e-4)
|
||||
assert rule["target_center"]["y"] == pytest.approx(110, abs=1e-4)
|
||||
|
||||
|
||||
def test_reference_uses_unrounded_measurement_and_scale():
|
||||
ref = {"brow_center": (161.4, 181.3), "nose_bottom": (160.0, 261.9),
|
||||
"chin_tip": (160.0, 321.5), "px_per_cm": 20}
|
||||
rule = height_target(_landmarks(), 320, 400, ref)
|
||||
assert rule["target_upper_height_px"] == pytest.approx(70.1)
|
||||
assert rule["target_center"]["y"] == pytest.approx(111.2)
|
||||
assert rule["target_upper_height_cm"] == pytest.approx(3.505)
|
||||
assert rule["measurement_source"] == "face_measure"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scale", [1, 2])
|
||||
@pytest.mark.parametrize("center_y", [90, 125, 150])
|
||||
def test_all_shapes_align_to_same_height_at_multiple_scales(scale, center_y):
|
||||
h, w = 400 * scale, 320 * scale
|
||||
lm = _landmarks()
|
||||
overlay = np.zeros((h, w, 4), np.float32)
|
||||
# A non-horizontal design, preserving a relative 20px arc after movement.
|
||||
pts = np.array([[70, center_y + 20], [160, center_y], [250, center_y + 20]]) * scale
|
||||
cv2.polylines(overlay, [pts.astype(np.int32)], False, (255, 255, 255, 255), 3 * scale)
|
||||
original = overlay.copy()
|
||||
target = height_target(lm, w, h)["target_center"]
|
||||
moved, placement = place_curve(overlay, target, lm)
|
||||
assert abs(curve_center(moved, target["x"])["y"] - target["y"]) < .2
|
||||
assert placement["target_error_px"] < .2
|
||||
# Shape's relative heights stay constant after translation.
|
||||
before_delta = curve_center(original, 100 * scale)["y"] - curve_center(original, 160 * scale)["y"]
|
||||
after_delta = curve_center(moved, 100 * scale)["y"] - curve_center(moved, 160 * scale)["y"]
|
||||
assert after_delta == pytest.approx(before_delta, abs=.1)
|
||||
np.testing.assert_array_equal(overlay, original)
|
||||
|
||||
|
||||
def test_rolled_face_preserves_curve_angle():
|
||||
lm = _landmarks()
|
||||
lm[10, 0] -= .08
|
||||
overlay = np.zeros((400, 320, 4), np.float32)
|
||||
cv2.line(overlay, (60, 95), (260, 135), (255, 255, 255, 255), 3)
|
||||
moved, _ = place_curve(overlay, {"x": 160, "y": 150}, lm)
|
||||
original_slope = curve_center(overlay, 200)["y"] - curve_center(overlay, 120)["y"]
|
||||
moved_slope = curve_center(moved, 200)["y"] - curve_center(moved, 120)["y"]
|
||||
assert moved_slope == pytest.approx(original_slope, abs=.2)
|
||||
|
||||
|
||||
def test_inpaint_mask_follows_new_line_not_old_black_template():
|
||||
lm = _landmarks()
|
||||
photo = np.full((400, 320, 3), 200, np.uint8)
|
||||
parsing = np.ones((400, 320), np.int32)
|
||||
overlay = np.zeros((400, 320, 4), np.float32)
|
||||
cv2.line(overlay, (20, 140), (300, 140), (255, 255, 255, 255), 3)
|
||||
moved, _ = place_curve(overlay, {"x": 160, "y": 100}, lm)
|
||||
marked, mask = inpaint_from_curve(photo, lm, parsing, moved)
|
||||
assert marked[100, 160, 0] == 0
|
||||
assert marked[140, 160, 0] == 200
|
||||
assert mask[90, 160] > 240
|
||||
assert mask[130, 160] < 10
|
||||
|
||||
|
||||
def test_out_of_frame_target_is_reported_instead_of_silent_clamping():
|
||||
ref = {"brow_center": (160, 50), "nose_bottom": (160, 200), "chin_tip": (160, 350)}
|
||||
with pytest.raises(ValueError, match="超出图片"):
|
||||
height_target(_landmarks(), 320, 400, ref)
|
||||
|
||||
|
||||
def test_missing_midline_curve_is_reported():
|
||||
with pytest.raises(ValueError, match="有效曲线"):
|
||||
place_curve(np.zeros((400, 320, 4), np.float32), {"x": 160, "y": 100}, _landmarks())
|
||||
@@ -0,0 +1,101 @@
|
||||
import json
|
||||
from ipaddress import ip_address
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.requests import Request
|
||||
|
||||
import ip_access as access
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def policy(tmp_path, monkeypatch):
|
||||
path = tmp_path / "access.json"
|
||||
path.write_text(json.dumps({"allowed_ips": []}))
|
||||
monkeypatch.setattr(access, "CONFIG_PATH", path)
|
||||
monkeypatch.setattr(access, "local_addresses", lambda: {ip_address("192.168.10.2"), ip_address("::1")})
|
||||
return path
|
||||
|
||||
|
||||
def request(host, headers=None, port=50000):
|
||||
return Request({"type": "http", "path": "/api/v1/hairline/generate-52", "scheme": "http",
|
||||
"client": (host, port), "headers": [(k.encode(), v.encode()) for k, v in (headers or {}).items()]})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["127.0.0.1", "::1", "::ffff:127.0.0.1", "192.168.10.2"])
|
||||
def test_only_actual_local_addresses_allowed_by_default(policy, host):
|
||||
assert access.check_access(request(host))[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["203.0.113.9", "192.168.10.3", "0.0.0.0", "invalid"])
|
||||
def test_other_addresses_denied(policy, host):
|
||||
assert not access.check_access(request(host))[0]
|
||||
|
||||
|
||||
def test_whitelist_ipv4_ipv6_cidr_and_hot_reload(policy):
|
||||
policy.write_text(json.dumps({"allowed_ips": ["203.0.113.9", "198.51.100.0/24", "2001:db8::/64"]}))
|
||||
for host in ["203.0.113.9", "::ffff:203.0.113.9", "198.51.100.12", "2001:db8::12"]:
|
||||
assert access.check_access(request(host))[0]
|
||||
policy.write_text('{"allowed_ips": []}')
|
||||
assert not access.check_access(request("203.0.113.9"))[0]
|
||||
|
||||
|
||||
def test_configured_public_whitelist_is_exact(policy):
|
||||
policy.write_text('{"allowed_ips": ["49.232.51.171"]}')
|
||||
assert access.check_access(request("49.232.51.171"))[0]
|
||||
assert not access.check_access(request("49.232.51.172"))[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("header", ["x-forwarded-for", "x-real-ip", "forwarded"])
|
||||
def test_forwarding_headers_cannot_override_socket_peer(policy, header):
|
||||
assert not access.check_access(request("203.0.113.9", {header: "127.0.0.1"}))[0]
|
||||
assert access.check_access(request("127.0.0.1", {header: "203.0.113.9"}))[0]
|
||||
|
||||
|
||||
def test_gateway_is_allowed_independently_of_end_user_ip(policy):
|
||||
policy.write_text('{"allowed_ips": ["49.232.51.171"]}')
|
||||
assert access.check_access(request("49.232.51.171", {"x-forwarded-for": "203.0.113.9"}))[0]
|
||||
assert not access.check_access(request("203.0.113.9", {"x-forwarded-for": "49.232.51.171"}))[0]
|
||||
|
||||
|
||||
def test_rewritten_peer_or_invalid_policy_fail_closed(policy):
|
||||
assert not access.check_access(request("127.0.0.1", port=0))[0]
|
||||
policy.write_text('{"allowed_ips": ["not-an-ip"]}')
|
||||
assert not access.check_access(request("127.0.0.1"))[0]
|
||||
policy.write_text('invalid JSON')
|
||||
assert not access.check_access(request("127.0.0.1"))[0]
|
||||
|
||||
|
||||
def test_worker_guard_covers_all_interfaces_before_auth(policy, monkeypatch):
|
||||
import app as worker
|
||||
monkeypatch.setattr(worker, "ACCEPT_PASSWORDS", ["access-test"])
|
||||
headers = {"X-Internal-Token": "access-test"}
|
||||
with TestClient(worker.app, client=("203.0.113.9", 50000)) as client:
|
||||
assert client.post('/api/v1/hairline/generate-52').status_code == 403
|
||||
assert client.post('/api/v1/hairline/generate-52/', headers=headers).status_code == 403
|
||||
assert client.post('/api/v1/debug/hairline-height-compare').status_code == 403
|
||||
assert client.post('/api/v1/hairline/generate', headers=headers).status_code == 403
|
||||
assert client.post('/api/v1/face/measure', headers=headers).status_code == 403
|
||||
assert client.post('/api/v1/redraw', headers=headers).status_code == 403
|
||||
assert client.get('/health').status_code == 403
|
||||
assert client.get('/openapi.json').status_code == 403
|
||||
policy.write_text('{"allowed_ips": ["203.0.113.9"]}')
|
||||
assert client.post('/api/v1/hairline/generate-52', headers=headers).json()['code'] == 1004
|
||||
assert client.post('/api/v1/hairline/generate-52').status_code == 401
|
||||
|
||||
|
||||
def test_gateway_does_not_apply_worker_ip_policy(policy, monkeypatch):
|
||||
import importlib
|
||||
gateway = importlib.import_module('gateway.app')
|
||||
calls = []
|
||||
async def proxy(request, path):
|
||||
calls.append(path)
|
||||
return {'code': 0}
|
||||
monkeypatch.setattr(gateway, '_proxy', proxy)
|
||||
# Deliberately skip lifespan: no workers or configuration are needed.
|
||||
client = TestClient(gateway.app, client=('203.0.113.9', 50000))
|
||||
assert client.post('/api/v1/hairline/generate-52').json()['code'] == 0
|
||||
assert client.post('/api/v1/hairline/generate').json()['code'] == 0
|
||||
assert client.post('/api/v1/hair/grow').json()['code'] == 0
|
||||
assert client.get('/static/test_interface52.html').status_code == 200
|
||||
assert calls == ['/api/v1/hairline/generate-52', '/api/v1/hairline/generate', '/api/v1/hair/grow']
|
||||
Reference in New Issue
Block a user