Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 33 additions & 43 deletions optika/distortion/_distortion.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,22 @@ class AbstractDistortionModel(
@abc.abstractmethod
def distort(
self,
coordinates: optika.vectors.SceneVectorArray,
coordinates: na.AbstractSpectralPositionalVectorArray,
) -> na.SpectralPositionalVectorArray:
"""
Convert scene coordinates to sensor coordinates.

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.

Expand All @@ -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.
"""
Expand All @@ -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
Expand All @@ -125,20 +125,20 @@ 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"),
num=13,
),
)
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(
Expand All @@ -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
Expand All @@ -178,58 +178,48 @@ 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,
position=self.coordinates_sensor,
)
return na.PolynomialFitFunctionArray(
inputs=inputs,
outputs=scene.field,
outputs=scene.position,
center=inputs.mean(self._axis_scene),
degree=self.degree,
where_polynomial=self.where,
)

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(
Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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)
Expand Down
16 changes: 8 additions & 8 deletions optika/distortion/_distortion_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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)


Expand All @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down
Loading