Files
he11lib/tests/test_geometry.py
T
Martino Ferrari 83ca084945 Fix final-review findings: pixel-scale size guard, DRY camera field names
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>
2026-07-03 14:04:24 +02:00

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)