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:
+128
-54
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user