保留5号历史三档实现,新增中下庭高度定位、扶正裁切、矩形叠加与30案例对比页。 worker仅允许本机和配置白名单访问;网关不增加IP限制。空图片URL字段改写限定为52号,保持旧接口兼容。同步接口、裁切与部署文档。 验证:113项测试通过;1项历史数值基线失败,在未修改的a3fddbb版本复现。浏览器验证5/52及30案例对比页通过。
123 lines
5.4 KiB
Python
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)
|