From 35087f440d691d9140afa5fcb30b34ba47b1b051 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sun, 31 May 2026 10:22:00 -0600 Subject: [PATCH] Retype distortion models to `SpectralPositionalVectorArray`. Make `AbstractDistortionModel.distort`/`undistort` accept and return `na.SpectralPositionalVectorArray` instead of `optika.vectors.SceneVectorArray`, so the scene and sensor coordinates share one named-arrays vector type. `PolynomialDistortionModel` now feeds `coordinates_scene` directly into the fit (the scene vector already exposes `wavelength`/`position` as polynomial components), and `undistort` returns a `SpectralPositionalVectorArray`. This also lets downstream consumers (e.g. `AbstractInterpolatedSystem`, and `ctis` instruments) call the model without repackaging coordinates. Co-Authored-By: Claude Opus 4.8 --- optika/distortion/_distortion.py | 76 ++++++++++++--------------- optika/distortion/_distortion_test.py | 16 +++--- 2 files changed, 41 insertions(+), 51 deletions(-) diff --git a/optika/distortion/_distortion.py b/optika/distortion/_distortion.py index e29da3a..b67534e 100644 --- a/optika/distortion/_distortion.py +++ b/optika/distortion/_distortion.py @@ -35,7 +35,7 @@ class AbstractDistortionModel( @abc.abstractmethod def distort( self, - coordinates: optika.vectors.SceneVectorArray, + coordinates: na.AbstractSpectralPositionalVectorArray, ) -> na.SpectralPositionalVectorArray: """ Convert scene coordinates to sensor coordinates. @@ -43,14 +43,14 @@ def distort( Parameters ---------- coordinates - The wavelength and field position of each point in the scene. + The wavelength and position of each point in the scene. """ @abc.abstractmethod def undistort( self, - coordinates: na.SpectralPositionalVectorArray, - ) -> optika.vectors.SceneVectorArray: + coordinates: na.AbstractSpectralPositionalVectorArray, + ) -> na.SpectralPositionalVectorArray: """ Convert sensor coordinates to scene coordinates. @@ -76,14 +76,14 @@ class AbstractInterpolatedDistortionModel( @property @abc.abstractmethod - def coordinates_scene(self) -> optika.vectors.SceneVectorArray: + def coordinates_scene(self) -> na.AbstractSpectralPositionalVectorArray: """ - The wavelength and field position of each calibration point in the scene. + The wavelength and position of each calibration point in the scene. """ @property @abc.abstractmethod - def coordinates_sensor(self) -> na.Cartesian2dVectorArray: + def coordinates_sensor(self) -> na.AbstractCartesian2dVectorArray: """ The position of each calibration point mapped onto the sensor. """ @@ -106,7 +106,7 @@ class PolynomialDistortionModel( """ A distortion model which fits a polynomial to known scene/sensor coordinates. - The forward model (:meth:`distort`) is a polynomial fit mapping scene field + The forward model (:meth:`distort`) is a polynomial fit mapping scene position to sensor position as a function of wavelength. The inverse model (:meth:`undistort`) is a *separate* polynomial fit in the opposite direction, so the round trip is exact only to the accuracy of the @@ -125,9 +125,9 @@ class PolynomialDistortionModel( import named_arrays as na import optika - scene = optika.vectors.SceneVectorArray( + scene = na.SpectralPositionalVectorArray( wavelength=na.linspace(500, 600, axis="wavelength", num=3) * u.nm, - field=na.Cartesian2dVectorLinearSpace( + position=na.Cartesian2dVectorLinearSpace( start=-1 * u.deg, stop=+1 * u.deg, axis=na.Cartesian2dVectorArray("field_x", "field_y"), @@ -135,10 +135,10 @@ class PolynomialDistortionModel( ), ) sensor = na.Cartesian2dVectorArray( - x=scene.field.x * (10 * u.mm / u.deg) - + scene.field.x**2 * (1 * u.mm / u.deg**2), - y=scene.field.y * (10 * u.mm / u.deg) - + scene.field.y**2 * (1 * u.mm / u.deg**2), + x=scene.position.x * (10 * u.mm / u.deg) + + scene.position.x**2 * (1 * u.mm / u.deg**2), + y=scene.position.y * (10 * u.mm / u.deg) + + scene.position.y**2 * (1 * u.mm / u.deg**2), ) model = optika.distortion.PolynomialDistortionModel( @@ -153,10 +153,10 @@ class PolynomialDistortionModel( na.plt.set_aspect("equal", ax=ax); """ - coordinates_scene: optika.vectors.SceneVectorArray = dataclasses.MISSING - """The wavelength and field position of each calibration point in the scene.""" + coordinates_scene: na.AbstractSpectralPositionalVectorArray = dataclasses.MISSING + """The wavelength and position of each calibration point in the scene.""" - coordinates_sensor: na.Cartesian2dVectorArray = dataclasses.MISSING + coordinates_sensor: na.AbstractCartesian2dVectorArray = dataclasses.MISSING """The position of each calibration point mapped onto the sensor.""" axis_wavelength: str = dataclasses.MISSING @@ -178,23 +178,19 @@ def _axis_scene(self) -> tuple[str, ...]: @functools.cached_property def fit(self) -> na.PolynomialFitFunctionArray: - """The polynomial fit mapping scene field position to sensor position.""" + """The polynomial fit mapping scene position to sensor position.""" scene = self.coordinates_scene - inputs = na.SpectralPositionalVectorArray( - wavelength=scene.wavelength, - position=scene.field, - ) return na.PolynomialFitFunctionArray( - inputs=inputs, + inputs=scene, outputs=self.coordinates_sensor, - center=inputs.mean(self._axis_scene), + center=scene.mean(self._axis_scene), degree=self.degree, where_polynomial=self.where, ) @functools.cached_property def fit_inverse(self) -> na.PolynomialFitFunctionArray: - """The polynomial fit mapping sensor position back to scene field position.""" + """The polynomial fit mapping sensor position back to scene position.""" scene = self.coordinates_scene inputs = na.SpectralPositionalVectorArray( wavelength=scene.wavelength, @@ -202,7 +198,7 @@ def fit_inverse(self) -> na.PolynomialFitFunctionArray: ) return na.PolynomialFitFunctionArray( inputs=inputs, - outputs=scene.field, + outputs=scene.position, center=inputs.mean(self._axis_scene), degree=self.degree, where_polynomial=self.where, @@ -210,26 +206,20 @@ def fit_inverse(self) -> na.PolynomialFitFunctionArray: def distort( self, - coordinates: optika.vectors.SceneVectorArray, + coordinates: na.AbstractSpectralPositionalVectorArray, ) -> na.SpectralPositionalVectorArray: - inputs = na.SpectralPositionalVectorArray( - wavelength=coordinates.wavelength, - position=coordinates.field, - ) - position = self.fit(inputs).outputs return na.SpectralPositionalVectorArray( wavelength=coordinates.wavelength, - position=position, + position=self.fit(coordinates).outputs, ) def undistort( self, - coordinates: na.SpectralPositionalVectorArray, - ) -> optika.vectors.SceneVectorArray: - field = self.fit_inverse(coordinates).outputs - return optika.vectors.SceneVectorArray( + coordinates: na.AbstractSpectralPositionalVectorArray, + ) -> na.SpectralPositionalVectorArray: + return na.SpectralPositionalVectorArray( wavelength=coordinates.wavelength, - field=field, + position=self.fit_inverse(coordinates).outputs, ) def plot_residual( @@ -267,7 +257,7 @@ def plot_residual( :func:`named_arrays.plt.pcolormesh`. """ scene = self.coordinates_scene - field = scene.field + position = scene.position wavelength = na.as_named_array(scene.wavelength) axis_wavelength = self.axis_wavelength @@ -285,7 +275,7 @@ def plot_residual( # shape each subplot to the field-of-view aspect ratio, and widen # the figure to fit one subplot per wavelength height_subplot = 3 - aspect = (field.x.ptp() / field.y.ptp()).ndarray.value + aspect = (position.x.ptp() / position.y.ptp()).ndarray.value figsize = ( ncols * height_subplot * aspect + 1.5, height_subplot + 1, @@ -311,16 +301,16 @@ def plot_residual( ) na.plt.pcolormesh( - field, + position, C=residual, ax=ax, colorizer=colorizer, **kwargs, ) - na.plt.set_xlabel(f"field $x$ ({na.unit(field.x):latex_inline})", ax=ax) + na.plt.set_xlabel(f"field $x$ ({na.unit(position.x):latex_inline})", ax=ax) na.plt.set_ylabel( - f"field $y$ ({na.unit(field.y):latex_inline})", + f"field $y$ ({na.unit(position.y):latex_inline})", ax=ax[{axis_wavelength: 0}], ) na.plt.set_title(wavelength.to_string_array(), ax=ax) diff --git a/optika/distortion/_distortion_test.py b/optika/distortion/_distortion_test.py index 832532b..f2a77d0 100644 --- a/optika/distortion/_distortion_test.py +++ b/optika/distortion/_distortion_test.py @@ -8,10 +8,10 @@ from .._tests import test_mixins -def _scene() -> optika.vectors.SceneVectorArray: - return optika.vectors.SceneVectorArray( +def _scene() -> na.SpectralPositionalVectorArray: + return na.SpectralPositionalVectorArray( wavelength=na.linspace(500, 600, axis="wavelength", num=3) * u.nm, - field=na.Cartesian2dVectorLinearSpace( + position=na.Cartesian2dVectorLinearSpace( start=-1 * u.deg, stop=+1 * u.deg, axis=na.Cartesian2dVectorArray("field_x", "field_y"), @@ -34,13 +34,13 @@ def test_distort(self, a: optika.distortion.AbstractDistortionModel): def test_undistort(self, a: optika.distortion.AbstractDistortionModel): coordinates = a.distort(_scene()) result = a.undistort(coordinates) - assert isinstance(result, optika.vectors.SceneVectorArray) + assert isinstance(result, na.SpectralPositionalVectorArray) assert np.all(result.wavelength == coordinates.wavelength) def test_roundtrip(self, a: optika.distortion.AbstractDistortionModel): scene = _scene() result = a.undistort(a.distort(scene)) - error = (result.field - scene.field).length + error = (result.position - scene.position).length assert np.all(error < 1e-9 * u.deg) @@ -51,7 +51,7 @@ def test_coordinates_scene( self, a: optika.distortion.AbstractInterpolatedDistortionModel, ): - assert isinstance(a.coordinates_scene, optika.vectors.SceneVectorArray) + assert isinstance(a.coordinates_scene, na.AbstractSpectralPositionalVectorArray) def test_coordinates_sensor( self, @@ -79,8 +79,8 @@ def test_axis_field( optika.distortion.PolynomialDistortionModel( coordinates_scene=_scene(), coordinates_sensor=na.Cartesian2dVectorArray( - x=_scene().field.x * (10 * u.mm / u.deg), - y=_scene().field.y * (10 * u.mm / u.deg), + x=_scene().position.x * (10 * u.mm / u.deg), + y=_scene().position.y * (10 * u.mm / u.deg), ), axis_wavelength="wavelength", axis_field=("field_x", "field_y"),