83ca084945
effective_pixel_scale now raises a clear ValueError for image shapes too small for its finite-difference indexing, instead of an IndexError. ModalFitter.fit()'s geometry dict now reuses CAMERA_FIELD_NAMES from geometry.py instead of a duplicated hardcoded tuple. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
161 lines
4.9 KiB
Python
161 lines
4.9 KiB
Python
import numpy as np
|
|
import pytest
|
|
|
|
from he11lib.geometry import CameraModel, CameraModelTolerance, GeometryCalibration
|
|
|
|
|
|
def test_camera_model_tolerance_accepts_zero_and_positive():
|
|
CameraModelTolerance(
|
|
focal_length_px=0.0,
|
|
position=(0.0, 0.0, 0.0),
|
|
orientation_deg=(1.0, 2.0, 3.0),
|
|
principal_point=(0.5, 0.5),
|
|
) # should not raise
|
|
|
|
|
|
def test_camera_model_tolerance_rejects_negative_scalar_field():
|
|
with pytest.raises(ValueError, match="focal_length_px"):
|
|
CameraModelTolerance(
|
|
focal_length_px=-1.0,
|
|
position=(0.0, 0.0, 0.0),
|
|
orientation_deg=(0.0, 0.0, 0.0),
|
|
)
|
|
|
|
|
|
def test_camera_model_tolerance_rejects_negative_tuple_component():
|
|
with pytest.raises(ValueError, match="position"):
|
|
CameraModelTolerance(
|
|
focal_length_px=1.0,
|
|
position=(0.0, -0.5, 0.0),
|
|
orientation_deg=(0.0, 0.0, 0.0),
|
|
)
|
|
|
|
|
|
def make_on_axis_camera(focal_length_px=2000.0, camera_z=-2.0):
|
|
return CameraModel(
|
|
focal_length_px=focal_length_px,
|
|
position=(0.0, 0.0, camera_z),
|
|
orientation_deg=(0.0, 0.0, 0.0),
|
|
)
|
|
|
|
|
|
def make_tilted_camera():
|
|
return CameraModel(
|
|
focal_length_px=2000.0,
|
|
position=(0.05, -0.03, -2.0),
|
|
orientation_deg=(8.0, -5.0, 3.0),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"camera",
|
|
[make_on_axis_camera(), make_tilted_camera()],
|
|
ids=["on_axis", "tilted_off_center"],
|
|
)
|
|
@pytest.mark.parametrize("z", [0.3, 0.5, 0.8])
|
|
def test_projection_round_trip_recovers_pixel_grid(camera, z):
|
|
image_shape = (41, 41)
|
|
calib = GeometryCalibration(camera)
|
|
|
|
x, y = calib.physical_coordinates(image_shape, z)
|
|
row, col = calib.pixel_coordinates(x, y, z)
|
|
|
|
rows, cols = image_shape
|
|
row_idx = np.arange(rows) - rows // 2
|
|
col_idx = np.arange(cols) - cols // 2
|
|
expected_col, expected_row = np.meshgrid(col_idx, row_idx)
|
|
|
|
np.testing.assert_allclose(row, expected_row, atol=1e-6)
|
|
np.testing.assert_allclose(col, expected_col, atol=1e-6)
|
|
|
|
|
|
def test_keystone_regression_uniform_for_on_axis_camera():
|
|
# A camera with zero orientation, centered on the beam axis, produces
|
|
# uniform pixel spacing for evenly spaced physical points (no keystoning).
|
|
camera = make_on_axis_camera()
|
|
calib = GeometryCalibration(camera)
|
|
z = 0.5
|
|
|
|
xs = np.array([-0.02, -0.01, 0.0, 0.01, 0.02])
|
|
ys = np.zeros_like(xs)
|
|
_, col = calib.pixel_coordinates(xs, ys, z)
|
|
|
|
spacings = np.diff(col)
|
|
np.testing.assert_allclose(spacings, spacings[0], rtol=1e-6)
|
|
|
|
|
|
def test_keystone_regression_nonuniform_for_tilted_camera():
|
|
# A tilted/off-axis camera produces non-uniform pixel spacing for the
|
|
# same evenly spaced physical points -- genuine keystoning.
|
|
camera = make_tilted_camera()
|
|
calib = GeometryCalibration(camera)
|
|
z = 0.5
|
|
|
|
xs = np.array([-0.02, -0.01, 0.0, 0.01, 0.02])
|
|
ys = np.zeros_like(xs)
|
|
_, col = calib.pixel_coordinates(xs, ys, z)
|
|
|
|
spacings = np.diff(col)
|
|
assert not np.allclose(spacings, spacings[0], rtol=1e-3)
|
|
|
|
|
|
def test_pixel_coordinates_raises_when_point_behind_camera():
|
|
camera = CameraModel(
|
|
focal_length_px=2000.0,
|
|
position=(0.0, 0.0, 10.0),
|
|
orientation_deg=(0.0, 0.0, 0.0),
|
|
)
|
|
calib = GeometryCalibration(camera)
|
|
|
|
with pytest.raises(ValueError):
|
|
calib.pixel_coordinates(np.array([0.0]), np.array([0.0]), z=0.5)
|
|
|
|
|
|
def test_physical_coordinates_raises_when_plane_behind_camera():
|
|
# Camera sits downstream of the target plane and looks further
|
|
# downstream (boresight = +z world) -- the z=0.5 plane is behind it.
|
|
camera = CameraModel(
|
|
focal_length_px=2000.0,
|
|
position=(0.0, 0.0, 10.0),
|
|
orientation_deg=(0.0, 0.0, 0.0),
|
|
)
|
|
calib = GeometryCalibration(camera)
|
|
|
|
with pytest.raises(ValueError):
|
|
calib.physical_coordinates((21, 21), z=0.5)
|
|
|
|
|
|
def test_physical_coordinates_raises_when_edge_on():
|
|
# Pitch=90 deg points the boresight along world -y, making the
|
|
# z=const target plane edge-on (parallel to the view direction).
|
|
camera = CameraModel(
|
|
focal_length_px=2000.0,
|
|
position=(0.0, 0.0, -2.0),
|
|
orientation_deg=(0.0, 90.0, 0.0),
|
|
)
|
|
calib = GeometryCalibration(camera)
|
|
|
|
with pytest.raises(ValueError):
|
|
calib.physical_coordinates((41, 41), z=0.5)
|
|
|
|
|
|
def test_effective_pixel_scale_matches_on_axis_focal_length():
|
|
focal_length_px = 2000.0
|
|
camera_z = -2.0
|
|
z = 0.5
|
|
camera = make_on_axis_camera(focal_length_px=focal_length_px, camera_z=camera_z)
|
|
calib = GeometryCalibration(camera)
|
|
|
|
scale = calib.effective_pixel_scale((41, 41), z)
|
|
expected = (z - camera_z) / focal_length_px
|
|
assert scale == pytest.approx(expected, rel=1e-6)
|
|
|
|
|
|
def test_effective_pixel_scale_raises_for_image_too_small():
|
|
camera = make_on_axis_camera()
|
|
calib = GeometryCalibration(camera)
|
|
z = 0.5
|
|
|
|
with pytest.raises(ValueError, match="image_shape must be at least 2x2"):
|
|
calib.effective_pixel_scale((1, 1), z)
|