Files
hair/tests/test_framing.py
T
xsl 5b2b73f5d5 feat: 新增52号发际线接口并限制GPU worker来源IP
保留5号历史三档实现,新增中下庭高度定位、扶正裁切、矩形叠加与30案例对比页。

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

验证:113项测试通过;1项历史数值基线失败,在未修改的a3fddbb版本复现。浏览器验证5/52及30案例对比页通过。
2026-11-04 10:20:29 +08:00

123 lines
5.4 KiB
Python

"""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)