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
+38
View File
@@ -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
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 这边不再覆盖。
+122
View File
@@ -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)
+27
View File
@@ -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]
+69
View File
@@ -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}]
+91
View File
@@ -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())
+101
View File
@@ -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']