feat: 新增接口22生发控制与测试页

This commit is contained in:
xsl
2026-11-09 21:19:39 +08:00
parent c800822d54
commit b84a506b9b
21 changed files with 770 additions and 175 deletions
+20 -29
View File
@@ -8,11 +8,7 @@ from conftest import fixture
import app as app_module
# 测试自带固定密码,避免依赖 worker_config.json 的实际值(中间件运行期读模块全局)
app_module.ACCEPT_PASSWORDS = ["testpass"]
URL = "/api/v1/face/measure"
H = {"X-Internal-Token": "testpass"}
@pytest.fixture(scope="module")
@@ -22,18 +18,13 @@ def client():
yield c
def _post(client, fixture_name=None, headers=H, data=None, extra_files=None):
def _post(client, fixture_name=None, data=None, extra_files=None):
files = {}
if fixture_name:
files["image_file"] = (fixture_name, open(fixture(fixture_name), "rb"), "application/octet-stream")
if extra_files:
files.update(extra_files)
return client.post(URL, headers=headers, files=files or None, data=data)
def test_auth_missing_token_401(client):
r = _post(client, "frontal.jpg", headers={})
assert r.status_code == 401
return client.post(URL, files=files or None, data=data)
def _patch_health_ok(monkeypatch):
@@ -107,7 +98,7 @@ def test_tcp_probe_real_socket():
def test_param_none_provided_1007(client):
r = client.post(URL, headers=H)
r = client.post(URL)
assert r.json()["code"] == 1007
@@ -131,7 +122,7 @@ GROW = "/api/v1/hair/grow"
def test_grow_missing_gender_1004(client):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
r = client.post(GROW, headers=H, files=files)
r = client.post(GROW, files=files)
assert r.json()["code"] == 1004
@@ -148,7 +139,7 @@ def test_grow_female_returns_5(client, monkeypatch):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
# hair_style 必填(cb1989c 起):指定全部 5 种发型以验证完整管线
r = client.post(GROW, headers=H, files=files, data={"gender": "female", "hair_style": "1,2,3,4,5"})
r = client.post(GROW, files=files, data={"gender": "female", "hair_style": "1,2,3,4,5"})
body = r.json()
assert body["code"] == 0, body
results = body["data"]["results"]
@@ -163,13 +154,13 @@ GROWB = "/api/v1/hair/grow-b"
def test_growb_missing_marked_1007(client):
r = client.post(GROWB, headers=H) # 一张图都没传
r = client.post(GROWB) # 一张图都没传
assert r.json()["code"] == 1007
def test_growb_no_line_1001(client):
files = {"marked_image_file": ("m.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
r = client.post(GROWB, headers=H, files=files) # 无划线 → 拒识
r = client.post(GROWB, files=files) # 无划线 → 拒识
assert r.json()["code"] == 1001
@@ -177,7 +168,7 @@ def test_growb_success(client, monkeypatch):
import hairline.comfyui as comfy
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
files = {"marked_image_file": ("m.jpg", open(fixture("marked_hairline.jpg"), "rb"), "application/octet-stream")}
body = client.post(GROWB, headers=H, files=files).json() # 只传划线图一张
body = client.post(GROWB, files=files).json() # 只传划线图一张
assert body["code"] == 0, body
d = body["data"]
assert d["hairline_type"] == "custom"
@@ -190,7 +181,7 @@ HLGEN = "/api/v1/hairline/generate"
def test_hairline5_restored_schema_has_only_historical_parameters(client):
schema = client.get("/openapi.json", headers=H).json()
schema = client.get("/openapi.json").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",
@@ -204,7 +195,7 @@ def test_hairline5_restored_schema_has_only_historical_parameters(client):
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")},
body = client.post(HLGEN, 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"]
@@ -225,13 +216,13 @@ def test_hairline5_restored_three_levels_and_original_coordinate_contract(client
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
assert client.post(HLGEN, files=files).json()["code"] == 1004
def test_hairline_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(HLGEN, headers=H, files=files, data={"gender": "female"}).json()["code"] == 1007
assert client.post(HLGEN, files=files, data={"gender": "female"}).json()["code"] == 1007
def test_hairline_gen_female(client, monkeypatch):
@@ -240,7 +231,7 @@ def test_hairline_gen_female(client, monkeypatch):
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(HLGEN, headers=H, files=files, data={"gender": "female", "hair_style": "1,3"}).json()
body = client.post(HLGEN, files=files, data={"gender": "female", "hair_style": "1,3"}).json()
assert body["code"] == 0, body
d = body["data"]
imgs = d["hairline_images"]
@@ -279,7 +270,7 @@ 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()
schema = client.get("/openapi.json").json()
body = schema["paths"][HLGEN52]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"]
body = schema["components"]["schemas"][body["$ref"].rsplit("/", 1)[-1]]
fields = body["properties"]
@@ -310,7 +301,7 @@ def test_hairline52_legacy_request_keeps_fields_and_original_coordinates(client,
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}
kwargs = {"data": form}
if image_kind == "file":
kwargs["files"] = {"image_file": ("frontal.jpg", raw, "image/jpeg")}
elif image_kind == "base64":
@@ -385,13 +376,13 @@ def test_hairline52_height_compare_uses_same_new_rule(client, monkeypatch, norma
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
assert client.post(HLGEN52, 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
assert client.post(HLGEN52, files=files, data={"gender": "female"}).json()["code"] == 1007
def test_hairline52_gen_female(client, monkeypatch):
@@ -400,7 +391,7 @@ def test_hairline52_gen_female(client, monkeypatch):
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,
body = client.post(HLGEN52, files=files,
data={"gender": "female", "hair_style": "1,3", "normalize_image": "true"}).json()
assert body["code"] == 0, body
d = body["data"]
@@ -422,7 +413,7 @@ def test_hairline52_gen_female(client, monkeypatch):
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)
(rule["middle_height_px"] + rule["lower_height_px"]) * 0.45)
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"
@@ -512,6 +503,6 @@ def _assert_vertical_lines(data, variant):
def test_measure_v2_vertical_lines_have_no_head_edges(client):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
body = client.post("/api/v1/face/measure-v2", headers=H, files=files).json()
body = client.post("/api/v1/face/measure-v2", files=files).json()
assert body["code"] == 0, body
_assert_vertical_lines(body["data"], "v6")
+6 -2
View File
@@ -2,11 +2,15 @@
import asyncio
import importlib
import pytest
from starlette.requests import Request
def test_legacy_hairline_forwarding(monkeypatch):
name, path = "hairline_generate", "/api/v1/hairline/generate"
@pytest.mark.parametrize(("name", "path"), [
("hairline_generate", "/api/v1/hairline/generate"),
("hair_grow_22", "/api/v1/hair/grow-22"),
])
def test_hairline_forwarding(monkeypatch, name, path):
gateway = importlib.import_module("gateway.app")
request = Request({"type": "http", "method": "POST", "path": path, "headers": []})
calls = []
+91
View File
@@ -0,0 +1,91 @@
import base64
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
import app as worker
@pytest.mark.parametrize(("densities", "labels"), [
(["dense"], ["适中偏多"]),
(["sparse", "normal", "dense"], ["稀疏", "正常", "适中偏多"]),
([], []),
])
def test_interface22_selects_prompts_and_aligns_output_size(monkeypatch, densities, labels):
from hairline import service
prompts = []
rounded_png = cv2.imencode(".png", np.zeros((15, 13, 3), np.uint8))[1].tobytes()
def generate(image, styles, defaults, *, prompt, hairline_height, **kwargs):
assert styles == [1, 3]
assert hairline_height == -1
prompts.append(prompt)
return [
{"order": order, "hairline_type": key, "grown_png": rounded_png,
"overlay": np.zeros((16, 19, 4), np.uint8)}
for order, key in [(1, "ellipse"), (3, "heart")]
]
monkeypatch.setattr(service, "generate_grow_results_swap", generate)
image = cv2.imencode(".png", np.zeros((16, 19, 3), np.uint8))[1].tobytes()
form = {"gender": "female", "hair_style": "1,3", "hairline_height": "-1"}
if densities:
form["hair_density"] = densities
client = TestClient(worker.app, client=("127.0.0.1", 50000))
response = client.post("/api/v1/hair/grow-22", data=form,
files={"image_file": ("input.png", image, "image/png")})
if not densities:
assert response.status_code == 422
assert prompts == []
return
assert response.status_code == 200
assert prompts == [f"填充遮罩区域的头发,发量{label},人物增加一点磨皮" for label in labels]
results = response.json()["data"]["results"]
assert [(item["hair_density"], item["order"]) for item in results] == [
(density, order) for density in densities for order in (1, 3)
]
for item in results:
jpg = np.frombuffer(base64.b64decode(item["grown_image_base64"]), np.uint8)
assert cv2.imdecode(jpg, cv2.IMREAD_COLOR).shape[:2] == (16, 19)
@pytest.mark.parametrize("dy", [-5, 5])
def test_height_moves_paste_and_redraw_band_after_swap(monkeypatch, dy):
from face_analysis import hairline_grow as grow
image = np.zeros((80, 80, 3), np.uint8)
source_mask = np.zeros((80, 80), dtype=bool)
source_mask[25:45, 20:60] = True
inner = np.array([[20, 42], [59, 42]])
outer = np.array([[20, 32], [59, 32]])
upper = np.ones_like(source_mask)
monkeypatch.setattr(grow.detector, "detect", lambda image: object())
monkeypatch.setattr(grow, "estimate_scale_factor", lambda *args: 50)
monkeypatch.setattr(grow, "compute_mask", lambda *args, **kwargs: (
source_mask, {"_inner_pts": inner, "_outer_pts": outer, "_upper_mask": upper,
"mask_pixels": int(source_mask.sum())}))
def swap(original, hairline_id, is_hr, ext_mask, denoising_strength, **kwargs):
assert original is image
assert ext_mask is source_mask
generated = np.zeros_like(image)
generated[30, :, :] = 180
return generated
def composite(original, generated, mask, *args, **kwargs):
assert np.flatnonzero(mask[:, 40]).tolist() == list(range(25 + dy, 45 + dy))
assert np.flatnonzero(generated[:, 40, 0]).tolist() == [30 + dy]
return np.where(mask[..., None], generated, original), mask.astype(np.float32)
def encode_red_mask(mask, h, w):
assert np.flatnonzero(mask[:, 40]).tolist() == list(range(27 + dy, 38 + dy))
return "verified"
monkeypatch.setattr(grow, "_call_swap", swap)
monkeypatch.setattr(grow, "_composite", composite)
monkeypatch.setattr(grow, "_red_mask_b64", encode_red_mask)
result = grow.generate_hairline_redraw(image, "chang_tuoyuan", paste_dy=dy)
assert result["steps"]["redraw_band_mask_base64"] == "verified"
+6 -6
View File
@@ -16,21 +16,21 @@ def _landmarks(width=320, height=400):
return lm
def test_target_uses_mean_courts_without_old_estimation_ratio():
def test_target_is_lowered_by_five_percent_of_brow_to_chin():
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)
assert rule["target_upper_height_px"] == pytest.approx(63, abs=1e-4)
assert rule["target_center"]["y"] == pytest.approx(117, 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["target_upper_height_px"] == pytest.approx(63.09)
assert rule["target_center"]["y"] == pytest.approx(118.21)
assert rule["target_upper_height_cm"] == pytest.approx(3.1545)
assert rule["measurement_source"] == "face_measure"
+10 -14
View File
@@ -66,22 +66,21 @@ def test_rewritten_peer_or_invalid_policy_fail_closed(policy):
assert not access.check_access(request("127.0.0.1"))[0]
def test_worker_guard_covers_all_interfaces_before_auth(policy, monkeypatch):
def test_worker_guard_covers_all_interfaces(policy):
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/hairline/generate-52/').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.post('/api/v1/hairline/generate').status_code == 403
assert client.post('/api/v1/face/measure').status_code == 403
assert client.post('/api/v1/redraw').status_code == 403
assert client.post('/api/v1/hair/grow-22').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
assert client.post('/api/v1/hairline/generate-52').json()['code'] == 1004
assert client.post('/api/v1/hair/grow-22').status_code == 422
def test_gateway_does_not_apply_worker_ip_policy(policy, monkeypatch):
@@ -105,16 +104,13 @@ def test_gateway_does_not_apply_worker_ip_policy(policy, monkeypatch):
("/api/v1/hairline/generate-52", 1004),
("/api/v1/face/b-end-points", 1007),
])
def test_52_and_72_use_worker_transport_auth_not_client_bearer(policy, monkeypatch, path, expected_code):
def test_52_and_72_accept_allowlisted_peer_without_token(policy, path, expected_code):
import app as worker
policy.write_text('{"allowed_ips": ["49.232.51.171"]}')
monkeypatch.setattr(worker, "ACCEPT_PASSWORDS", ["access-test"])
client = TestClient(worker.app, client=("49.232.51.171", 50000))
try:
denied = client.post(path, headers={"Authorization": "Bearer client-access-token"})
assert denied.status_code == 401 and denied.json()["code"] == 1009
accepted = client.post(path, headers={"X-Internal-Token": "access-test"})
accepted = client.post(path)
assert accepted.status_code == 200 and accepted.json()["code"] == expected_code
finally:
client.close()