Source code for optika.sensors._sensors

"""
Models of light sensors that can be used in optical systems.
"""

from typing import TypeVar, Sequence
import abc
import dataclasses
import numpy as np
import astropy.units as u
import named_arrays as na
import optika
from .materials import AbstractSensorMaterial, IdealSensorMaterial

__all__ = [
    "AbstractImagingSensor",
    "ImagingSensor",
]


MaterialT = TypeVar("MaterialT", bound=AbstractSensorMaterial)


[docs] @dataclasses.dataclass(eq=False, repr=False) class AbstractImagingSensor( optika.surfaces.AbstractSurface[ None, MaterialT, optika.apertures.RectangularAperture, optika.apertures.RectangularAperture, None, ], ): """ An interface describing an imaging sensor that can be used as the last surface in an optical system. """ @property def sag(self) -> optika.sags.AbstractSag: return optika.sags.NoSag() @property def rulings(self) -> None: return None @property @abc.abstractmethod def width_pixel(self) -> u.Quantity | na.AbstractCartesian2dVectorArray: """ The physical size of each pixel on the sensor. """ @property @abc.abstractmethod def axis_pixel(self) -> na.Cartesian2dVectorArray[str, str]: """ The names of the logical axes corresponding to the rows and columns of the pixel grid. """ @property @abc.abstractmethod def num_pixel(self) -> na.Cartesian2dVectorArray[int, int]: """ The number of pixels along each axis of the sensor. """ @property @abc.abstractmethod def timedelta_exposure(self) -> u.Quantity | na.AbstractScalar: """ The exposure time of the sensor. """ @property def aperture(self): """ The light-sensitive aperture of the sensor. """ return optika.apertures.RectangularAperture( half_width=self.width_pixel * self.num_pixel / 2, )
[docs] def collect( self, rays: optika.rays.RayVectorArray, wavelength: na.AbstractScalar, axis: None | str | Sequence[str] = None, where: bool | na.AbstractScalar = True, ) -> tuple[ na.FunctionArray[na.SpectralPositionalVectorArray, na.AbstractScalar], na.AbstractScalar, ]: """ Bin a cloud of rays onto the pixel grid. Returns the per-pixel photon image (the binned ray intensity) and the flux-weighted mean cosine of the *refracted* angle inside the light-sensitive region in each pixel: the two quantities :meth:`expose` needs. Refracting each ray here (with its own ambient index of refraction) and binning the result is what lets :meth:`expose` and the material's :meth:`~optika.sensors.materials.AbstractSensorMaterial.signal` model be shared with systems that have no rays, without threading a separate ambient-index argument through them. Parameters ---------- rays A set of incident rays in local coordinates to bin. wavelength The edges of the wavelength bins to sample. axis The logical axes along which to collect photons. where A boolean mask used to indicate which rays should be considered. """ where = where & rays.unvignetted normal = self.sag.normal(rays.position) # Cosine of the refracted angle inside the light-sensitive region, # folding in each ray's ambient index of refraction. This is generally # complex, so its real and imaginary parts are binned separately below. direction = self.material.direction_refracted( wavelength=rays.wavelength, direction=rays.direction, n=rays.n, normal=normal, ) flux = rays.intensity * where bins = na.SpectralPositionalVectorArray( wavelength=wavelength, position=na.Cartesian2dVectorLinearSpace( start=self.aperture.bound_lower.xy, stop=self.aperture.bound_upper.xy, axis=self.axis_pixel, num=self.num_pixel + 1, ), ) a = na.SpectralPositionalVectorArray( wavelength=rays.wavelength, position=rays.position.xy, ) image = na.histogram(a, bins=bins, axis=axis, weights=flux) moment_real = na.histogram( a, bins=bins, axis=axis, weights=flux * np.real(direction) ) moment_imag = na.histogram( a, bins=bins, axis=axis, weights=flux * np.imag(direction) ) # flux-weighted mean refracted cosine; empty pixels carry no flux, so # assume normal incidence (cosine of 1). nonempty = image.outputs > 0 with np.errstate(invalid="ignore", divide="ignore"): direction_real = np.where(nonempty, moment_real.outputs / image.outputs, 1) direction_imag = np.where(nonempty, moment_imag.outputs / image.outputs, 0) direction = direction_real + direction_imag * 1j return image, direction
[docs] def expose( self, image: na.FunctionArray[ na.SpectralPositionalVectorArray, na.AbstractScalar, ], direction: float | na.AbstractScalar = 1, axis_wavelength: None | str = None, timedelta: None | u.Quantity | na.AbstractScalar = None, noise: bool = True, ) -> na.FunctionArray[ na.SpectralPositionalVectorArray, na.AbstractScalar, ]: """ Convert a per-pixel photon image into the electrons measured by the sensor. This is the detector-physics step shared by every optical system: it operates on a pixel grid (a photon image plus a refracted-cosine map), so it can be driven by :meth:`collect` for a ray-traced system, or by any model that produces a per-pixel photon image directly. The photon flux is multiplied by the exposure time and converted to electrons using :meth:`~optika.sensors.materials.AbstractSensorMaterial.signal`, which applies the quantum efficiency, noise, and charge-diffusion models. Parameters ---------- image The expected photon flux incident on each pixel, as a function of wavelength and pixel position. The wavelength inputs (``image.inputs.wavelength``) must be the bin *edges*, not the centers. direction The cosine of the refracted angle inside the light-sensitive region in each pixel, as produced by :meth:`collect`. axis_wavelength The logical axis of `image` corresponding to changing wavelength. If :obj:`None` (the default), ``image.inputs.wavelength`` must have only one logical axis. timedelta The exposure time of the measurement. If :obj:`None` (the default), the value in :attr:`timedelta_exposure` will be used. noise Whether to add shot noise and intrinsic sensor noise to the result. """ if axis_wavelength is None: shape_wavelength = na.shape(image.inputs.wavelength) if len(shape_wavelength) != 1: # pragma: nocover raise ValueError( f"if `axis_wavelength` is `None`, `image.inputs.wavelength` " f"must have exactly one logical axis, got {shape_wavelength}." ) (axis_wavelength,) = shape_wavelength if timedelta is None: timedelta = self.timedelta_exposure photons = image.outputs * timedelta electrons = self.material.signal( photons=photons, wavelength=image.inputs.wavelength.cell_centers(axis_wavelength), direction=direction, width_pixel=self.width_pixel, axis_xy=(self.axis_pixel.x, self.axis_pixel.y), noise=noise, ) return dataclasses.replace(image, outputs=electrons)
[docs] def measure( self, rays: optika.rays.RayVectorArray, wavelength: na.AbstractScalar, axis: None | str | Sequence[str] = None, axis_wavelength: None | str = None, where: bool | na.AbstractScalar = True, timedelta: None | u.Quantity | na.AbstractScalar = None, noise: bool = True, ) -> na.FunctionArray[ na.SpectralPositionalVectorArray, na.AbstractScalar, ]: """ Bin a set of rays onto the pixel grid and convert them to the electrons measured by the sensor. This composes :meth:`collect` (gather rays into the pixel grid) with :meth:`expose` (apply the detector physics). Parameters ---------- rays A set of incident rays in local coordinates to measure. wavelength The edges of the wavelength bins to sample. axis The logical axes along which to collect photons. axis_wavelength The logical axis of `wavelength` corresponding to changing wavelength coordinate, forwarded to :meth:`expose`. If :obj:`None` (the default), `wavelength` must have only one logical axis. where A boolean mask used to indicate which rays should be considered. timedelta The exposure time of the measurement. If :obj:`None` (the default), the value in :attr:`timedelta_exposure` will be used. noise Whether to add shot noise and intrinsic sensor noise to the result. """ image, direction = self.collect( rays=rays, wavelength=wavelength, axis=axis, where=where, ) return self.expose( image, direction=direction, axis_wavelength=axis_wavelength, timedelta=timedelta, noise=noise, )
[docs] @dataclasses.dataclass(eq=False, repr=False) class ImagingSensor( AbstractImagingSensor, ): """ An arbitrary imaging sensor described by a pixel grid and a light-sensitive material. """ name: None | str = None """The human-readable name of this sensor.""" width_pixel: u.Quantity | na.AbstractCartesian2dVectorArray = 0 * u.um """The physical size of each pixel on the sensor.""" axis_pixel: na.Cartesian2dVectorArray[str, str] = None """ The names of the logical axes corresponding to the rows and columns of the pixel grid. """ num_pixel: na.Cartesian2dVectorArray[int, int] = None """The number of pixels along each axis of the sensor.""" timedelta_exposure: u.Quantity | na.AbstractScalar = 0 * u.s """The exposure time of the sensor.""" material: AbstractSensorMaterial = None """ A model of the light-sensitive material composing this sensor. If :obj:`None` (the default), :class:`optika.sensors.IdealImagingSensor` will be used. """ aperture_mechanical: optika.apertures.RectangularAperture = None """The shape of the physical substrate supporting the sensor.""" is_field_stop: bool = False """A flag controlling whether this sensor is the field stop for the system.""" is_pupil_stop: bool = False """A flag controlling whether this sensor is the pupil stop for the system.""" transformation: None | na.transformations.AbstractTransformation = None """The position and orientation of the sensor in the global coordinate system.""" kwargs_plot: None | dict = None """Extra keyword arguments to pass to :meth:`plot`""" def __post_init__(self) -> None: if self.material is None: self.material = IdealSensorMaterial() @property def shape(self) -> dict[str, int]: return na.broadcast_shapes( optika.shape(self.name), optika.shape(self.width_pixel), optika.shape(self.num_pixel), optika.shape(self.timedelta_exposure), optika.shape(self.transformation), )