Replace cosine-compression geometry model with a full pinhole CameraModel

GeometryCalibration now performs true perspective forward/inverse
projection (with genuine keystoning) around a shared CameraModel, paired
with a CameraModelTolerance that will drive ModalFitter's per-field
fixed/refined behavior in a later task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-07-03 11:32:31 +02:00
parent fabb3d4efc
commit 4f65c2ce4f
3 changed files with 352 additions and 96 deletions
+128 -54
View File
@@ -1,77 +1,151 @@
import numpy as np
import pytest
from he11lib.data import MeasurementPlane
from he11lib.geometry import GeometryCalibration
from he11lib.geometry import CameraModel, CameraModelTolerance, GeometryCalibration
def test_pixel_scale_known_reflects_plane():
plane_known = MeasurementPlane(flux=np.ones((5, 5)), z=0.3, pixel_scale=1e-4)
plane_unknown = MeasurementPlane(flux=np.ones((5, 5)), z=0.3)
assert GeometryCalibration(plane_known).pixel_scale_known is True
assert GeometryCalibration(plane_unknown).pixel_scale_known is False
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_viewing_angle_known_reflects_plane():
plane_known = MeasurementPlane(flux=np.ones((5, 5)), z=0.3, viewing_angle_deg=10.0)
plane_unknown = MeasurementPlane(flux=np.ones((5, 5)), z=0.3)
assert GeometryCalibration(plane_known).viewing_angle_known is True
assert GeometryCalibration(plane_unknown).viewing_angle_known is False
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_physical_coordinates_uses_known_calibration():
plane = MeasurementPlane(
flux=np.ones((5, 5)), z=0.3, pixel_scale=2e-4, viewing_angle_deg=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),
)
calib = GeometryCalibration(plane)
x, y = calib.physical_coordinates()
row_idx = np.arange(5) - 2
col_idx = np.arange(5) - 2
expected_x = col_idx * 2e-4
expected_y = row_idx * 2e-4
np.testing.assert_allclose(x[2, :], expected_x)
np.testing.assert_allclose(y[:, 2], expected_y)
def test_physical_coordinates_compresses_x_for_viewing_angle():
plane = MeasurementPlane(
flux=np.ones((5, 5)), z=0.3, pixel_scale=2e-4, viewing_angle_deg=60.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),
)
calib = GeometryCalibration(plane)
x, y = calib.physical_coordinates()
col_idx = np.arange(5) - 2
expected_x = col_idx * 2e-4 / np.cos(np.deg2rad(60.0))
np.testing.assert_allclose(x[2, :], expected_x)
def test_physical_coordinates_raises_without_calibration_or_override():
plane = MeasurementPlane(flux=np.ones((5, 5)), z=0.3)
calib = GeometryCalibration(plane)
@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)
with pytest.raises(ValueError, match="pixel_scale"):
calib.physical_coordinates()
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_physical_coordinates_accepts_override_for_unknown_values():
plane = MeasurementPlane(flux=np.ones((5, 5)), z=0.3)
calib = GeometryCalibration(plane)
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
x, y = calib.physical_coordinates(pixel_scale=1e-4, viewing_angle_deg=0.0)
col_idx = np.arange(5) - 2
np.testing.assert_allclose(x[2, :], col_idx * 1e-4)
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_known_calibration_takes_precedence_over_override():
plane = MeasurementPlane(
flux=np.ones((5, 5)), z=0.3, pixel_scale=2e-4, viewing_angle_deg=0.0
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(plane)
calib = GeometryCalibration(camera)
# override should be ignored since plane already specifies calibration
x, _ = calib.physical_coordinates(pixel_scale=999.0, viewing_angle_deg=45.0)
col_idx = np.arange(5) - 2
np.testing.assert_allclose(x[2, :], col_idx * 2e-4)
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)