Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
61acdca
Added `optika.systems.InterpolatedSystem` as an approximation to `Seq…
roytsmart Apr 3, 2026
63d784d
lots of improvements
roytsmart Jul 9, 2026
ba54535
lots of fixes
roytsmart Jul 21, 2026
33b7cd0
add tests
roytsmart Jul 21, 2026
d3bf48a
Document `LinearSystem` with a runnable end-to-end example
roytsmart Jul 21, 2026
de8f425
Add `SequentialSystem.linearize()` to build a `LinearSystem`
roytsmart Jul 22, 2026
edda0fe
fixes
roytsmart Jul 22, 2026
6381076
coverage
roytsmart Jul 22, 2026
7cc1aed
Add `LinearSystem.backproject` and accept photon or energy radiance
roytsmart Jul 23, 2026
495dbaf
Merge remote-tracking branch 'origin/main' into feature/interpolated-…
roytsmart Jul 23, 2026
7befb93
Invert `expose` in `LinearSystem.backproject`
roytsmart Jul 23, 2026
9d6f0b0
Fold volume and sensor response into `image_from_weights`/`backprojec…
roytsmart Jul 23, 2026
25a1388
Merge remote-tracking branch 'origin/main' into feature/interpolated-…
roytsmart Jul 23, 2026
c2f956f
Add an `uncertainty` flag to `LinearSystem.image`
roytsmart Jul 23, 2026
a154d49
revert to old ruff rules
roytsmart Jul 23, 2026
aa0ca2a
Merge remote-tracking branch 'origin/main' into feature/interpolated-…
roytsmart Jul 24, 2026
098dbfe
Apply the noise model per readout: add `integrate` to the sensor
roytsmart Jul 24, 2026
4554386
Reconcile `image`/`backproject` signatures across systems
roytsmart Jul 24, 2026
becbe63
Express the distortion and sensor grid in pixel coordinates
roytsmart Jul 24, 2026
257688f
Fit the polynomial distortion and vignetting models per channel
jacobdparker Jul 24, 2026
c4731c8
Fix docs build and input handling for the pixel-coordinate LinearSystem
roytsmart Jul 25, 2026
b3a074f
Cover axis inference in image_from_weights/backproject_from_weights
roytsmart Jul 25, 2026
3b75e6e
Let `backproject` express the radiance in photon or energy units
roytsmart Jul 27, 2026
45ec42d
Use `SpectralPositionalVectorArray.volume_cell` for the voxel volume
roytsmart Jul 28, 2026
5d624a5
Accept a Doppler object-plane grid in image and backproject
roytsmart Jul 30, 2026
f701406
Use `.spectral_positional` to accept Doppler grids
roytsmart Jul 31, 2026
532e288
Convert the effective area to `weights_unit` before stripping its uni…
jacobdparker Jul 31, 2026
2ace99a
Raise `NotImplementedError` for `RayVectorArray.volume_cell`
roytsmart Jul 31, 2026
a2003ec
Address code-review findings on the linear-system PR
roytsmart Jul 31, 2026
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
2 changes: 2 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Features
* Sequential raytrace modeling of an optical system
* Stratified random sampling of input rays for faster convergence
* Image simulation of a given scene using an optical system
* Fast linear forward model approximating a raytraced system, for imaging many
scenes without raytracing each one
* Spherical, conical, and toroidal surface sag profiles
* Circular, rectangular, and polygonal apertures
* Support for mirrors and arbitrary multilayer coatings
Expand Down
20 changes: 20 additions & 0 deletions optika/distortion/_distortion.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
@dataclasses.dataclass(eq=False, repr=False)
class AbstractDistortionModel(
optika.mixins.Printable,
optika.mixins.Shaped,
):
"""
An interface describing an arbitrary distortion model,
Expand Down Expand Up @@ -189,6 +190,15 @@ class SimpleDistortionModel(
"""The reference wavelength and the sensor position that the field center
maps to at that wavelength."""

@property
def shape(self) -> dict[str, int]:
return na.broadcast_shapes(
optika.shape(self.plate_scale),
optika.shape(self.dispersion),
optika.shape(self.angle),
optika.shape(self.reference),
)

@functools.cached_property
def matrix(self) -> na.SpectralPositionalMatrixArray:
cos = np.cos(self.angle)
Expand Down Expand Up @@ -344,6 +354,14 @@ class PolynomialDistortionModel(
where: bool | na.AbstractScalar = True
"""A boolean mask selecting which calibration points to use for fitting."""

@property
def shape(self) -> dict[str, int]:
shape = na.broadcast_shapes(
optika.shape(self.coordinates_scene),
optika.shape(self.coordinates_sensor),
)
return {ax: n for ax, n in shape.items() if ax not in self._axis_scene}

@property
def _axis_scene(self) -> tuple[str, ...]:
"""The logical axes over which the calibration points are distributed."""
Expand All @@ -358,6 +376,7 @@ def fit(self) -> na.PolynomialFitFunctionArray:
outputs=self.coordinates_sensor,
center=scene.mean(self._axis_scene),
degree=self.degree,
axis_polynomial=self._axis_scene,
where_polynomial=self.where,
)

Expand All @@ -374,6 +393,7 @@ def fit_inverse(self) -> na.PolynomialFitFunctionArray:
outputs=scene.position,
center=inputs.mean(self._axis_scene),
degree=self.degree,
axis_polynomial=self._axis_scene,
where_polynomial=self.where,
)

Expand Down
41 changes: 41 additions & 0 deletions optika/distortion/_distortion_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def _scene() -> na.SpectralPositionalVectorArray:

class AbstractTestAbstractDistortionModel(
test_mixins.AbstractTestPrintable,
test_mixins.AbstractTestShaped,
):
def test_distort(self, a: optika.distortion.AbstractDistortionModel):
coordinates = _scene()
Expand Down Expand Up @@ -151,3 +152,43 @@ def test_plot_residual(
assert isinstance(ax, na.ScalarArray)
assert a.axis_wavelength in na.shape(ax)
plt.close(fig)


def test_polynomial_distortion_model_channel():
"""
Calibration points that vary along an axis orthogonal to the scene axes
(e.g. the channel axis of a multi-channel instrument) must be fit with an
independent polynomial per channel, not one polynomial averaged over all
the channels.
"""
scene = _scene()

scale = na.ScalarArray([10, 12, 8] * u.mm / u.deg, axes="channel")
angle = na.ScalarArray([0, 10, -15] * u.deg, axes="channel")

cos, sin = np.cos(angle), np.sin(angle)
sensor = na.Cartesian2dVectorArray(
x=scale * (cos * scene.position.x - sin * scene.position.y),
y=scale * (sin * scene.position.x + cos * scene.position.y),
)

a = optika.distortion.PolynomialDistortionModel(
coordinates_scene=scene,
coordinates_sensor=sensor,
axis_wavelength="wavelength",
axis_field=("field_x", "field_y"),
degree=1,
)

distorted = a.distort(scene).position
assert "channel" in distorted.shape
assert np.all((distorted - sensor).length < 1e-9 * u.mm)

undistorted = a.undistort(
na.SpectralPositionalVectorArray(
wavelength=scene.wavelength,
position=sensor,
)
).position
assert "channel" in undistorted.shape
assert np.all((undistorted - scene.position).length < 1e-9 * u.deg)
9 changes: 9 additions & 0 deletions optika/radiometry/_effective_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
@dataclasses.dataclass(eq=False, repr=False)
class AbstractEffectiveAreaModel(
optika.mixins.Printable,
optika.mixins.Shaped,
):
"""
An interface describing the effective area of an optical system as a
Expand Down Expand Up @@ -90,6 +91,14 @@ class InterpolatedEffectiveAreaModel(
axis_wavelength: str = dataclasses.MISSING
"""The logical axis corresponding to changing wavelength."""

@property
def shape(self) -> dict[str, int]:
shape = na.broadcast_shapes(
optika.shape(self.wavelength),
optika.shape(self.area),
)
return {ax: n for ax, n in shape.items() if ax != self.axis_wavelength}

def __call__(
self,
wavelength: na.AbstractScalar,
Expand Down
1 change: 1 addition & 0 deletions optika/radiometry/_effective_area_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def _area() -> na.AbstractScalar:

class AbstractTestAbstractEffectiveAreaModel(
test_mixins.AbstractTestPrintable,
test_mixins.AbstractTestShaped,
):
def test__call__(self, a: optika.radiometry.AbstractEffectiveAreaModel):
wavelength = na.linspace(200, 900, axis="wavelength", num=5) * u.AA
Expand Down
10 changes: 10 additions & 0 deletions optika/radiometry/_vignetting.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
@dataclasses.dataclass(eq=False, repr=False)
class AbstractVignettingModel(
optika.mixins.Printable,
optika.mixins.Shaped,
):
"""
An interface describing an arbitrary vignetting model, which maps scene
Expand Down Expand Up @@ -161,6 +162,14 @@ class PolynomialVignettingModel(
where: bool | na.AbstractScalar = True
"""A boolean mask selecting which calibration points to use for fitting."""

@property
def shape(self) -> dict[str, int]:
shape = na.broadcast_shapes(
optika.shape(self.coordinates_scene),
optika.shape(self.illumination),
)
return {ax: n for ax, n in shape.items() if ax not in self._axis_scene}

@property
def _axis_scene(self) -> tuple[str, ...]:
"""The logical axes over which the calibration points are distributed."""
Expand All @@ -175,6 +184,7 @@ def fit(self) -> na.PolynomialFitFunctionArray:
outputs=self.illumination,
center=scene.mean(self._axis_scene),
degree=self.degree,
axis_polynomial=self._axis_scene,
where_polynomial=self.where,
)

Expand Down
26 changes: 26 additions & 0 deletions optika/radiometry/_vignetting_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def _illumination() -> na.AbstractScalar:

class AbstractTestAbstractVignettingModel(
test_mixins.AbstractTestPrintable,
test_mixins.AbstractTestShaped,
):
def test__call__(self, a: optika.radiometry.AbstractVignettingModel):
scene = _scene()
Expand Down Expand Up @@ -125,3 +126,28 @@ def test_plot_residual(
assert isinstance(ax, na.ScalarArray)
assert a.axis_wavelength in na.shape(ax)
plt.close(fig)


def test_polynomial_vignetting_model_channel():
"""
Calibration points that vary along an axis orthogonal to the scene axes
(e.g. the channel axis of a multi-channel instrument) must be fit with an
independent polynomial per channel, not one polynomial averaged over all
the channels.
"""
scene = _scene()

coefficient = na.ScalarArray([0.1, 0.2, 0.05] / u.deg**2, axes="channel")
illumination = 1 - coefficient * scene.position.length**2

a = optika.radiometry.PolynomialVignettingModel(
coordinates_scene=scene,
illumination=illumination,
axis_wavelength="wavelength",
axis_field=("field_x", "field_y"),
degree=2,
)

result = a(scene)
assert "channel" in result.shape
assert np.all(np.abs(result - illumination) < 1e-9)
8 changes: 8 additions & 0 deletions optika/rays/_ray_vectors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations
from typing import TypeVar, Generic
from collections.abc import Sequence
import abc
import dataclasses
import numpy as np
Expand Down Expand Up @@ -98,6 +99,13 @@ def type_explicit(self) -> type[na.AbstractExplicitArray]:
def type_matrix(self) -> type[na.AbstractMatrixArray]:
raise NotImplementedError

def volume_cell(self, axis: None | str | Sequence[str]) -> na.AbstractScalar:
"""
A ray bundle is a scattered collection of rays rather than a
logically-rectangular grid, so the per-voxel volume is undefined.
"""
raise NotImplementedError

@property
def explicit(self) -> RayVectorArray:
return super().explicit
Expand Down
Loading
Loading