# Copyright 2021 United States Government as represented by the Administrator of the National Aeronautics and Space
# Administration. No copyright is claimed in the United States under Title 17, U.S. Code. All Other Rights Reserved.
r"""
Provides abstract base classes for the construction of Point Spread Function classes for GIANT.
In this module there are a number of abstract base classes (ABCs) that define the interface for and provide some common
functionality for point spread functions (PSFs) in GIANT. In general, most users will not interact with these directly,
and will instead use a predefined implementation of a PSF, however, if you wish to define a new PSF for GIANT to use
then you will likely benefit from what is available in this module.
To define a new PSF you will most likely want to subclass at least one of the ABCs defined in this module, which will
help you to be sure you've defined all the interfaces GIANT expects and possibly add some shared functionality so that
you don't need to reinvent the wheel. However, this is not strictly necessary. While type checkers will complain if
you don't at least inherit from :class:`PointSpreadFunction`, GIANT will not actually error so long as you have defined
all the appropriate interfaces (so called duck typing).
Use
---
To implement a fully function custom PSF for GIANT you must at minimum implement the following methods and attributes
================================================= ======================================================================
Method/Attribute Use
================================================= ======================================================================
:attr:`~PointSpreadFunction.save_residuals` A class attribute which determines whether to save information about
the residuals from attempting to fit the PSF to data. If ``True``
then you should save the residual statistics and formal fit
covariance.
:meth:`~PointSpreadFunction.__call__` The built in call method for the class. This method should apply the
defined PSF in the current instance to a 2D image, returning the image
after the PSF has been applied.
:meth:`~PointSpreadFunction.apply_1d` An analogous method to :meth:`~PointSpreadFunction.__call__` but for
applying the PSF to (a) 1D scan line(s) instead of a 2D image.
:meth:`~PointSpreadFunction.generate_kernel` A method that generates a square unit kernel (sums to 1) of the
current instance of the PSF.
:meth:`~PointSpreadFunction.evaluate` A method that evaluates the current instance of the PSF at provided
x and y locations
:meth:`~PointSpreadFunction.fit` A class method which fits an instance of the PSF to supplied data and
returns and initialized version of the PSF with the fit parameters.
:attr:`~PointSpreadFunction.centroid` A property which returns the location of the peak of the PSF as a
as a length 2 numpy array.
:attr:`~PointSpreadFunction.residual_rss` A property which returns the root sum of squares of the rss of the fit
of the PSF iff the current instance was made by a call to
:meth:`~PointSpreadFunction.fit` and
:attr:`~PointSpreadFunction.save_residuals` is ``True``. If either of
these are not True then returns ``None``.
:attr:`~PointSpreadFunction.residual_std` A property which returns the standard deviation of the rss of the fit
of the PSF iff the current instance was made by a call to
:meth:`~PointSpreadFunction.fit` and
:attr:`~PointSpreadFunction.save_residuals` is ``True``. If either of
these are not True then returns ``None``.
:attr:`~PointSpreadFunction.covariance` A property which returns the formal covariance of the fit
of the PSF iff the current instance was made by a call to
:meth:`~PointSpreadFunction.fit` and
:attr:`~PointSpreadFunction.save_residuals` is ``True``. If either of
these are not True then returns ``None``.
:attr:`~PointSpreadFunction.volume` A method which computes the total volume under the PSF (integral from
:math:`-\inf` to :math:`\inf`)
================================================= ======================================================================
Implementing these, plus whatever else is needed internally for the functionality of the PSF, will result in a PSF class
that can be used throughout GIANT.
For examples of how this is done, refer to the pre-defined PSFs in :mod:`.gaussians`.
"""
from abc import ABCMeta, abstractmethod
from typing import Optional, Tuple
import numpy as np
try:
from scipy.fftpack import next_fast_len
except ImportError:
from scipy.fftpack.helper import next_fast_len
import cv2
from .._typing import ARRAY_LIKE, Real, NONEARRAY
def _fft_convolve_1d(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""
This function performs FFT based convolution on nd arrays of 1d scan lines.
:param a: array of 1d scan lines
:param b: array of 1d scan lines
:return: array of spatial correlation values
"""
# Determine the size of the correlation surface for type "full"
n = a.shape[-1] + b.shape[-1] - 1
# Get the next fast fft length
fftn = next_fast_len(n)
# Transform the input values into the frequency domain
a_fft = np.fft.rfft(a, n=fftn)
b_fft = np.fft.rfft(b, n=fftn)
# Perform the correlation and transform back to the spatial domain
cc = np.fft.irfft(a_fft * b_fft, n=fftn)
# extract the proper data.
dif = (n - a.shape[-1]) // 2
return cc[..., dif:dif + a.shape[-1]]
[docs]class PointSpreadFunction(metaclass=ABCMeta):
"""
This abstract base class serves as the template for implementing a point spread function in GIANT.
A point spread function models how a camera spreads out a point source of light across multiple pixels in an image.
GIANT uses PSFs both for making rendered templates more realistic for correlation in :mod:`.relative_opnav` and for
centroiding of stars and unresolved bodies for center finding (:mod:`.unresolved`), attitude estimation
(:mod:`.stellar_opnav`), and camera calibration (:mod:`.calibration`).
In general, a PSF class is assigned to the :attr:`.ImageProcessing.psf` attribute and an initialized version of the
class is assigned to the :attr:`.Camera.psf` attribute. GIANT will then use the specified PSF wherever it is
needed. For more details refer to the :mod:`~giant.point_spread_functions` package documentation.
This class serves as a prototype for implementing a PSF in GIANT. It defines all the interfaces that GIANT expects
for duck typing as abstract methods and properties to help you know you've implemented everything you need.
.. note:: Because this is an ABC, you cannot create an instance of this class (it will raise a ``TypeError``)
"""
save_residuals = False # type: bool
"""
This class attribute specifies whether to save the residuals when fitting the specified PSF to data.
Saving the residuals can be important for in depth analysis but can use a lot of space when many fits are being
performed and stored so this defaults to off. To store the residuals simply set this to ``True`` before
initialization.
"""
[docs] @abstractmethod
def __call__(self, image: np.ndarray) -> np.ndarray:
"""
Point spread functions are callable on images and should apply the stored PSF to the image and return the
result.
:param image: The image the PSF is to be applied to
:return: The image after applying the PSF
"""
[docs] @abstractmethod
def apply_1d(self, image_1d: np.ndarray, direction: Optional[np.ndarray] = None,
step: Real = 1) -> np.ndarray:
"""
Applies the defined PSF using the stored parameters to the 1D image scans provided.
``image_1d`` can be a 2D array but in that case each row will be treated as an independent 1D scan.
For non-symmetric PSFs, a ``direction`` argument can be supplied which should be the direction in the image of
each scan line. This can be used to determine the appropriate cross-section of the PSF to use for applying to
the 1D scans (if applicable). If no direction is provided then the x direction [1, 0] is assumed.
:param image_1d: The scan line(s) to be blurred using the PSF
:param direction: The direction for the 1D cross section of the PSF. This should be either None, a length 2
array, or a shape nx2 array where n is the number of scan lines
:param step: The step size of the lines being blurred.
:return: an array containing the input after blurring with the defined PSF
"""
[docs] @abstractmethod
def generate_kernel(self) -> np.ndarray:
"""
Generates a square kernel centered at the centroid of the PSF normalized to have a volume (sum) of 1.
Essentially this evaluates :math:`z = f(x, y)` for x in :math:`[x_0-size//2, x_0+size//2]` and y in
:math:`[y_0-size//2, y_0+size//2]` where x0 is the x location of the centroid of the PSF and y0 is the y
location of the centroid of the PSF.
The resulting values are then normalized to sum to 1 so that the result can be applied using convolution
without changing the overall signal level.
:return: A normalized kernel of the PSF centered at the centroid
"""
[docs] @abstractmethod
def evaluate(self, x: ARRAY_LIKE, y: ARRAY_LIKE) -> np.ndarray:
"""
This method evaluates the PSF at the given x and y.
This method is not intended to be used to apply the PSF for an image (use the callable capability of the class
instead for this). Instead it simply computes the height of the PSF above the xy-plane at the requested
locations.
:param x: The x locations the height of the PSF is to be calculated at.
:param y: The y locations the height of the PSF is to be calculated at.
:return: A numpy array containing the height of the PSF at the requested locations the same shape as x and y.
"""
[docs] @classmethod
@abstractmethod
def fit(cls, x: ARRAY_LIKE, y: ARRAY_LIKE, z: ARRAY_LIKE) -> __qualname__:
"""
This function fits the defined PSF to the input data and returns an initialize version of the class based on the
fit.
The fit assumes that z = f(x, y) where f is the PSF (and thus z is the "height" of the PSF).
If the fit is unsuccessful then this should set the attributes of the PSF to NaN to indicate to the rest of
GIANT that the fit failed.
:param x: The x values underlying the surface the PSF is to be fit to
:param y: The y values underlying the surface the PSF is to be fit to
:param z: The z or "height" values of the surface the PSF is to be fit to
:return: An instance of the PSF that best fits the provided data
"""
@property
@abstractmethod
def centroid(self) -> np.ndarray:
"""
This should return the centroid or peak of the initialized PSF as a x, y length 2 numpy array.
This property is used to enable the PSF class to be used in identifying the center of
illumination in image processing (see :attr:`.ImageProcessing.centroiding`).
"""
@property
@abstractmethod
def residual_rss(self) -> Optional[float]:
"""
This should return residual sum of squares (RSS) of the post-fit residuals from fitting this PSF to the data.
If the PSF is not the result of a fit or the :attr:`save_residuals` is ``False`` this will return ``None``.
"""
@property
@abstractmethod
def residual_mean(self) -> Optional[float]:
"""
This should return the mean of the post-fit residuals from fitting this PSF to the data.
If the PSF is not the result of a fit or the :attr:`save_residuals` is ``False`` this will return ``None``.
"""
@property
@abstractmethod
def residual_std(self) -> Optional[float]:
"""
This should return the standard deviation of the post-fit residuals from fitting this PSF to the data.
If the PSF is not the result of a fit or the :attr:`save_residuals` is ``False`` this will return ``None``.
"""
@property
@abstractmethod
def covariance(self) -> Optional[np.ndarray]:
"""
This should return the formal covariance of the PSF parameters (if the PSF was fit and not initialized).
If the PSF is not the result of a fit or the :attr:`save_residuals` is ``False`` this will return ``None``.
"""
[docs] @abstractmethod
def volume(self) -> float:
"""
This should compute the total volume contained under the PSF.
:return: The total volume contained under the PSF
"""
[docs] def compare(self, other: __qualname__) -> float:
"""
For real PSFs, this method generates how well the PSF matches another between 0 and 1, with 1 being a perfect
match and 0 being a horrible match.
Typically this is evaluated as the clipped pearson product moment coefficient between the kernels of the 2 psfs.
"""
return float(np.clip(np.corrcoef(self.generate_kernel().ravel(), other.generate_kernel().ravel())[0, 1], 0, 1))
[docs]class KernelBasedApply1DPSF(PointSpreadFunction, metaclass=ABCMeta):
"""
This ABC adds concrete common functionality for applying the initialized PSF to 1D scan lines to
:class:`.PointSpreadFunction`.
The implementation that is shared by most PSFs for 1D scan lines is stored in :meth:`apply_1d_sized`. This method,
which isn't part of the actual interface GIANT expects, is used for applying the specified PSF to 1D scan lines
if the size of the required kernel is known. Therefore, when implementing method:`apply_1d`, all you need to do is
calculate the required size of the 1D kernel and then dispatch to :meth:`apply_1d_sized`. An example of this can
be seen in :class:`.Gaussian`.
"""
[docs] def apply_1d_sized(self, image_1d: np.ndarray, size: int,
direction: Optional[np.ndarray] = None, step: Real = 1) -> np.ndarray:
"""
Applies the defined PSF using the stored parameters to the 1D image scans provided with a given kernel size.
``image_1d`` can be a 2D array but in that case each row will be treated as an independent 1D scan.
For non-symmetric PSFs, a ``direction`` argument can be supplied which should be the direction in the image of
each scan line. This can be used to determine the appropriate cross-section of the PSF to use for applying to
the 1D scans (if applicable). If no direction is provided then the x direction [1, 0] is assumed.
This method works by sampling the PSF in the (optionally) specified direction(s) centered around the centroid of
the PSF according to the input ``size``. These kernels are then applied to the input scan lines using a Fourier
transform, and the resulting scan lines are returned.
:param image_1d: The scan line(s) to be blurred using the PSF
:param size: The size of the kernel to use when convolving the PSF with the scan line
:param direction: The direction for the 1D cross section of the PSF. This should be either None, a length 2
array, or a shape nx2 array where n is the number of scan lines
:param step: The step size of the lines being blurred.
:return: an array containing the input after blurring with the defined PSF
"""
# make sure direction is in the right format/shape
if direction is None:
direction = np.array([[1, 0]])
else:
direction = np.array(direction)
if (direction.shape[1] != 2) and (direction.shape[0] == 2):
direction = direction.T
# get everything the right shapes
image_1d = np.atleast_2d(image_1d)
number_rows = max(image_1d.shape[0], direction.shape[0])
lines = np.broadcast_to(image_1d, (number_rows, image_1d.shape[1]))
directions = np.broadcast_to(direction, (number_rows, direction.shape[1]))
# determine the number of steps we need to take
steps = np.arange(-size / 2, size / 2 + step, step)
# get the lines we are querying along (centered at the centroid)
queries = directions.reshape((-1, 1, 2)) * steps.reshape((1, -1, 1)) + self.centroid.reshape((1, 1, 2))
kx = queries[..., 0]
ky = queries[..., 1]
# compute and normalize the kernels
kernels = self.evaluate(kx, ky)
kernels /= kernels.sum(axis=1, keepdims=True)
# apply the kernels
return _fft_convolve_1d(lines, kernels)
[docs]class KernelBasedCallPSF(PointSpreadFunction, metaclass=ABCMeta):
"""
This ABC adds concrete common functionality for applying the initialized PSF to 2D images to
:class:`.PointSpreadFunction`.
The implementation that is shared by most PSFs for 2D images is stored in :meth:`__call__`. This method,
works by generating a square kernel of the PSF by a call to :meth:`generate_kernel` and then convolving the kernel
with the image. For most PSFs, this form will be used, although a few like :class:`Gaussian` may have a further
optimized call sequence.
"""
[docs] def __call__(self, image: np.ndarray) -> np.ndarray:
"""
This method generates a kernel and then convolves it with the input image.
The kernel is generated by a call the :meth:`generate_kernel`. The kernel is then applied with border
replication to the image either using fourier transforms or a spatial algorithm, whichever is faster.
:param image: The image the PSF is to be applied to
:return: The image after applying the PSF
"""
# generate the kernel
kernel = self.generate_kernel()
# apply it through filtering
return cv2.filter2D(image, -1, kernel, borderType=cv2.BORDER_REPLICATE)
[docs]class SizedPSF(PointSpreadFunction, metaclass=ABCMeta):
"""
This ABC adds common functionality for a PSF where the required size can be determine algorithmically.
Specifically, this adds an instance attribute :attr:`size` which stores the size of the PSF,
a new abstract method :meth:`determine_size` which should be implemented to algorithmically determine the size of
the kernel required for the PSF, and concrete method :meth:`generate_kernel`, which generates a square unit kernel
based on the :attr:`size`.
"""
def __init__(self, size: Optional[int] = None, **kwargs):
"""
:param size: The size of the kernel to generate.
"""
self.size = 1 # type: int
"""
The size of the kernel to return on a call to :meth:`generate_kernel`.
Typically this should be an odd number to ensure that the kernel is square and centered.
"""
if (size is None) or size == 0:
self.determine_size()
else:
self.size = int(size)
super().__init__(**kwargs)
[docs] @abstractmethod
def determine_size(self) -> None:
"""
Sets the size required for the kernel algorithmically.
Typically this is based on the width of the PSF.
The determined size should be stored in the instance attribute :attr:`size`
"""
pass
[docs] def generate_kernel(self, size: Optional[int] = None):
r"""
Generates a square kernel centered at the centroid of the PSF normalized to have a volume (sum) of 1 for the
size input or specified in the :attr:`size` attribute.
Essentially this evaluates :math:`z = f(x, y)` for x in :math:`[x_0-size//2, x_0+size//2]` and y in
:math:`[y_0-size//2, y_0+size//2]` where x0 is the x location of the centroid of the PSF and y0 is the y
location of the centroid of the PSF.
The resulting values are then normalized to sum to 1 so that the result can be applied using convolution
without changing the overall signal level.
:param size: The size of the kernel to generate (ie return a (size, size) shaped array). Overrides the
:attr:`size` attribute.
:return: A normalized kernel of the PSF centered at the centroid
"""
if size is None:
size = self.size
grid_x, grid_y = np.meshgrid(np.arange(self.centroid[0] - size // 2, self.centroid[0] + size // 2 + 1, 1),
np.arange(self.centroid[1] - size // 2, self.centroid[1] + size // 2 + 1, 1))
# just ensure we didn't end up with too many
grid_x = grid_x[:size, :size]
grid_y = grid_y[:size, :size]
kernel = self.evaluate(grid_x, grid_y)
kernel /= kernel.sum()
return kernel
[docs] def compare(self, other: __qualname__) -> float:
"""
For real PSFs, this method generates how well the PSF matches another between 0 and 1, with 1 being a perfect
match and 0 being a horrible match.
Typically this is evaluated as the clipped pearson product moment coefficient between the kernels of the 2 psfs.
"""
size = min(max(self.size, other.size), 10)
return float(np.clip(np.corrcoef(self.generate_kernel(size=size).ravel(),
other.generate_kernel(size=size).ravel())[0, 1], 0, 1))
[docs]class IterativeNonlinearLSTSQPSF(PointSpreadFunction, metaclass=ABCMeta):
"""
This ABC defines common attributes, properties, and methods for Iterative Non-linear least squares estimation of
a Point Spread function.
This class is typically not used by the user except when implementing a new PSF class that uses iterative nonlinear
least squares to fit the PSF to data.
To use this class when implementing a new PSF, simply subclass it and then override the abstract methods
:meth:`compute_jacobian` and :meth:`update_state` (in addition to the required abstract methods from the typical
:class:`PointSpreadFunction` ABC) according to the PSF you are implementing. You may also want to override the
default class attributes :attr:`max_iter`, :attr:`atol`, and :attr:`rtol`, which control when to break out of the
iterations.
Once you have overridden the abstract methods (and possibly the class attributes), you simply need to call the
:meth:`converge` method from somewhere within the :meth:`~PointSpreadFunction.fit` method after initializing the
class with the initial guess of the PSF parameters. The :meth:`converge` method will then perform iterative
non-linear least squares until convergence or the maximum number of iterations have been performed according the the
:attr:`max_iter`, :attr:`atol`, and :attr:`rtol` class attributes. The converged solution will be stored as the
updated class parameters
"""
max_iter = 20 # type: int
"""
An integer defining the maximum number of iterations to attempt in the iterative least squares solution.
"""
atol = 1e-10 # type: float
"""
The absolute tolerance cut-off for the iterative least squares. (The iteration will cease when the new estimate is
within this tolerance for every element from the previous estimate)
"""
rtol = 1e-10 # type: float
"""
The relative tolerance cut-off for the iterative least squares. (The iteration will cease when the maximum percent
change in the state vector from one iteration to the next is less than this value)
"""
[docs] @abstractmethod
def compute_jacobian(self, x: np.ndarray, y: np.ndarray, computed: np.ndarray) -> np.ndarray:
r"""
This method computes the Jacobian of the PSF with respect to a change in the state.
Mathematically, it should return the nxm matrix
.. math::
\mathbf{J} = \frac{\partial f(x, y)}{\partial \mathbf{t}}
where :math:`f(x,y)` is the function being fit, :math:`\mathbf{t}` is a length m vector of the state parameters,
and :math:`\mathbf{J}` is the Jacobian matrix
:param x: The x values to evaluate the Jacobian at as a length n array
:param y: The y values to evaluate the Jacobian at as a length n array
:param computed: :math:`f(x,y)` evaluated at x and y as a length n array.
This is provided for efficiency and convenience as the evaluated function is frequently needed
in the computation of the Jacobian and it is definitely needed in the non-linear least squares.
If not needed for computing the Jacobian this can safely be ignored.
:return: The Jacobian matrix as a nxm numpy array, with n being the number of measurements and m being the
number of state parameters being estimated
"""
pass
[docs] @abstractmethod
def update_state(self, update: NONEARRAY) -> None:
"""
Updates the current values based on the provided update vector.
The provided update vector is in the order according to order of the columns returned from
:meth:`compute_jacobian`.
If the input is ``None`` then this method should set the state parameters to NaN to indicate to the rest of
GIANT that the estimation failed.
:param update: The vector of additive updates to apply or None to indicate that the fit failed
"""
pass
[docs] def converge(self, x: np.ndarray, y: np.ndarray, z: np.ndarray) -> Tuple[NONEARRAY, NONEARRAY]:
"""
Performs iterative non-linear least squares on a PSF model until convergence has been reached for a function of
the form :math:`z=f(x, y)`
Iterative non-linear least squares is performed by linearizing at each step about the current best estimate of
the state. This means that for each iteration, the Jacobian matrix is computed based off the current best
estimate of the state, and then used to form a linear approximation of the model using a Taylor expansion.
The resulting estimate is a delta from the current state, so that it is typically applied by adding the
resulting update state vector to the existing states (although in some instances such as for rotations, a more
complicated update application may be needed.
Iterative non-linear least squares typically needs an adequate initial guess to ensure convergence, therefore,
it is recommended that the state of the class be appropriately initialized before calling this method (what is
appropriate is dependent on the PSF itself.
The iteration performed in this method can be controlled using the :attr:`max_iter`, :attr:`atol`, and
:attr:`rtol` class attributes which control the maximum number of iterations to attempt for convergence, the
absolute tolerance criteria for convergence, and the relative tolerance criteria for convergence respectively.
This method use :meth:`compute_jacobian` to return the Jacobian matrix for the current estimate of the state
vector and :meth:`update_state` to apply the estimated update at each iteration step, therefore, these methods
should expect the same order of state elements.
If the iteration diverges then this method will call :meth:`update_state` with ``None`` as the argument, which
should typically indicate that the state parameters should be set to NaN so that other GIANT algorithms are
aware the PSF fit failed.
If :attr:`~PointSpreadFunction.save_residuals` is set to True, then this function will return a vector of the
residuals and the covariance matrix from the fit as numpy arrays. Otherwise it returns None, None.
:param x: The x locations of the expected values as a 1D array
:param y: The y locations of the expected values as a 1D array
:param z: The expected values to fit to as a 1D array
:return: Either (residuals, covariance) as (n,) and (m,m) arrays if :attr:`save_residuals` is ``True`` or
``(None, None)``.
"""
computed = self.evaluate(x, y)
residuals = z - computed
residual_norm_old = np.inf
jacobian = self.compute_jacobian(x, y, computed)
for ind in range(self.max_iter):
# break self if the jacobian is bad
if not np.isfinite(jacobian).all():
self.update_state(None)
return None, None
# compute the update
update = np.linalg.lstsq(jacobian, residuals, rcond=None)[0]
# update the state
self.update_state(update)
# compute the residuals and jacobian after the update
computed = self.evaluate(x, y)
residuals = z - computed
residual_norm_new = np.linalg.norm(residuals)
jacobian = self.compute_jacobian(x, y, computed)
# check for convergence/divergence
if (np.abs(update) <= self.atol).all():
break
elif abs(residual_norm_new - residual_norm_old)/residual_norm_new < self.rtol:
break
elif residual_norm_old < residual_norm_new:
self.update_state(None)
if self.save_residuals:
return residuals, np.nan*np.ones((jacobian.shape[1], jacobian.shape[1]), dtype=np.float64)
else:
return None, None
else:
residual_norm_old = residual_norm_new
if self.save_residuals:
try:
return residuals, np.linalg.pinv(jacobian.T @ jacobian) * residuals.var()
except np.linalg.linalg.LinAlgError:
return residuals, np.zeros((jacobian.shape[1], jacobian.shape[1]), dtype=float)*np.nan
else:
return None, None
[docs]class IterativeNonlinearLSTSQwBackground(IterativeNonlinearLSTSQPSF, metaclass=ABCMeta):
r"""
This class provides support for estimating the superposition of the PSF and a linear background gradient.
This class is typically not used by the user except when implementing a new PSF class that uses iterative nonlinear
least squares to fit the PSF to data.
Beyond the typical implementation in :class:`IterativeNonLinearLSTSQ`, this class provides a concrete
implementation of methods :meth:`compute_jacobian_bg`, :meth:`evaluate_bg`, and :meth:`apply_update_bg` which
handle the linear background gradient of the form
.. math::
f_{bg}(x, y) = f(x,y)+Bx+Cy+D
where :math:`f_{bg}(x,y)` is the PSF with the background, :math:`f(x,y)` is the PSF without the background,
:math:`B` is the slope of the gradient in the x direction, :math:`C` is the slope of the gradient in the y direction
and :math:`D` is the constant background level.
The way this class should be used it to subclass it, then in the regular :meth:`compute_jacobian` method call
:meth:`compute_jacobian_bg` and include with the rest of your Jacobian matrix (typically at the end),
then in :meth:`evaluate` call :meth:`evaluate_bg` and add the results to the PSF, and finally in
:meth:`update_states` call :meth:`update_states_bg` inputting the portion of the state vector that contains the
background update according to where you added it to your existing Jacobian.
The background terms are stored in instance attributes :attr:`bg_b_coef`, :attr:`bg_c_coef`, and :attr:`bg_d_coef`.
"""
def __init__(self, bg_b_coef: Optional[Real] = None, bg_c_coef: Optional[Real] = None,
bg_d_coef: Optional[Real] = None, **kwargs):
"""
:param bg_b_coef: The x slope of the background gradient
:param bg_c_coef: They y slope of the background gradient
:param bg_d_coef: The constant offset of the background gradient
"""
self.bg_b_coef = 0.0 # type: float
"""
The x slope of the background gradient
"""
if bg_b_coef is not None:
self.bg_b_coef = float(bg_b_coef)
self.bg_c_coef = 0.0 # type: float
"""
The y slope of the background gradient
"""
if bg_c_coef is not None:
self.bg_c_coef = float(bg_c_coef)
self.bg_d_coef = 0.0 # type: float
"""
The constant offset of the background gradient
"""
if bg_d_coef is not None:
self.bg_d_coef = float(bg_d_coef)
super().__init__(**kwargs)
[docs] @staticmethod
def compute_jacobian_bg(x: np.ndarray, y: np.ndarray) -> np.ndarray:
r"""
This computes the Jacobian matrix for the background terms.
Mathematically this is
.. math::
\mathbf{J}_{bg} = \left[\begin{array}{ccc}\frac{\partial f_{bg}(x,y)}{\partial B} &
\frac{\partial f_{bg}(x,y)}{\partial C} & \frac{\partial f_{bg}(x,y)}{\partial D}\end{array}\right]=
\left[\begin{array}{ccc} x & y & 1\end{array}\right]
The results from this function should be appended to the rest of the Jacobian matrix using ``hstack``.
:param x: The x values underlying the data the surface is to be fit to
:param y: The y values underlying the data the surface is to be fit to
:return: The Jacobian for the background as a nx3 numpy array
"""
return np.hstack([x.reshape(-1, 1), y.reshape(-1, 1), np.ones((x.size, 1), dtype=np.float64)])
[docs] def apply_update_bg(self, bg_update: NONEARRAY) -> None:
"""
This applies the background update to the background state
This typically should be called from the regular :meth:`~IterativeNonlinearLSTSQPSF.update_state` and only fed
the components of the update vector that correspond to the background Jacobian matrix.
:param bg_update: The update to apply to the background terms as a length 3 array
"""
if bg_update is not None:
self.bg_b_coef += bg_update[0]
self.bg_c_coef += bg_update[1]
self.bg_d_coef += bg_update[2]
else:
self.bg_b_coef = np.nan
self.bg_c_coef = np.nan
self.bg_d_coef = np.nan
[docs] def evaluate_bg(self, x: np.ndarray, y: np.ndarray) -> np.ndarray:
"""
This computes the background component at locations ``x`` and ``y``.
The background component is defined as
.. math::
Bx+Cy+D
:param x: The x values where the background is to be computed at
:param y: The y values where the background is to be computed at
:return: The background according to the model
"""
return self.bg_b_coef*x+self.bg_c_coef*y+self.bg_d_coef
[docs] @classmethod
def fit_bg(cls, x: ARRAY_LIKE, y: ARRAY_LIKE, z: ARRAY_LIKE) -> __qualname__:
"""
This method tries to fit the background using linear least squares without worrying about any PSF included.
This is useful if you need to subtract off a rough estimate of the background before attempting to fit the PSF
for an initial guess. The results of the fit are stored in the :attr:`bg_b_coef`, :attr:`bg_c_coef`, and
:attr:`bg_d_coef`.
:param x: The x values underlying the data the background is to be fit to
:param y: The y values underlying the data the background is to be fit to
:param z: The z or "height" values for the background
:return: The initialized BG PSF with values according to the fit for the background only
"""
# make numpy
x = np.array(x).ravel()
y = np.array(y).ravel()
z = np.array(z).ravel()
# initialize the class
out = cls()
# get the Jacobian matrix
jac = cls.compute_jacobian_bg(x, y)
# do the least squares estimation
update = np.linalg.lstsq(jac, z, rcond=None)[0]
# store the results
out.apply_update_bg(update)
return out
[docs]class InitialGuessIterativeNonlinearLSTSQPSF(IterativeNonlinearLSTSQPSF, metaclass=ABCMeta):
"""
This class provides a fit class method which generates the initial guess from a subclass and then converges to a
better solution using iterative Nonlinear LSTSQ.
This class is designed to work where you have a non-iterative but biased class for estimating the defined PSF (as
is done with Gaussian PSFs by using a logarithmic transformation). If that is the case, and the unbiased estimator
class uses the same attributes and the biased estimator class, then you can use this as is to add the ability to get
the biased estimate and then correct it. Otherwise you will need to do things yourself and shouldn't bother with
this class.
To use this class, override the :meth:`~PointSpreadFunction.fit` method, and then call
``super().fit_lstsq(x, y, z)``
This also adds 2 instance attributes :attr:`_residuals` and :attr:`_covariance` which store the covariance and
residuals of the fit if requested.
"""
def __init__(self):
super().__init__()
self._covariance = None # type: NONEARRAY
"""
The covariance of the fit as a nxn array (for n state elements) or None, depending on if
:attr:`~PointSpreadFunction.save_residuals` is ``True``.
"""
self._residuals = None # type: NONEARRAY
"""
The residuals of the fit as a length m array (for m observations) or None, depending on if
:attr:`~PointSpreadFunction.save_residuals` is ``True``.
"""
[docs] @classmethod
def fit_lstsq(cls, x: ARRAY_LIKE, y: ARRAY_LIKE, z: ARRAY_LIKE) -> __qualname__:
"""
This fits a PSF to a surface using iterative non-linear least squares estimation.
The estimation in this function is performed iteratively. First, a non-iterative fit is performed using the
super class's fit method. This initial fit is then refined using iterative non-linear least squares to
remove biases that might have been introduced in the non-iterative fit..
If the fit is unsuccessful due to a rank deficient matrix then :meth:`~IterativeNonlinearLSTSQPSF.update_states`
will be called which will likely result in the state parameters being set to NaN.
:param x: The x values underlying the surface the PSF is to be fit to
:param y: The y values underlying the surface the PSF is to be fit to
:param z: The z or "height" values of the surface the PSF is to be fit to
:return: The initialized PSF with values according to the fit
"""
# make sure the arrays are flat and numpy arrays
x = np.array(x)
y = np.array(y)
z = np.array(z)
if (z.ndim == 2) & (max(z.shape) > 5):
# do the initial fit only using the 9 pixels closest to the center
pix_center = np.array(z.shape) // 2
r_slice = slice(pix_center[0] - 1, pix_center[0] + 2)
c_slice = slice(pix_center[1] - 1, pix_center[1] + 2)
out = super(cls, cls).fit(x[r_slice, c_slice], y[r_slice, c_slice], z[r_slice, c_slice])
else:
out = super(cls, cls).fit(x, y, z)
# make things flat
x = x.ravel()
y = y.ravel()
z = z.ravel()
# converge to the better solution and capture the residuals, maybe
out._residuals, out._covariance = out.converge(x, y, z)
return out
[docs]class InitialGuessIterativeNonlinearLSTSQPSFwBackground(IterativeNonlinearLSTSQwBackground, metaclass=ABCMeta):
"""
This class provides a fit class method which generates the initial guess from a subclass and then converges to a
better solution using iterative Nonlinear LSTSQ including a background gradient.
This class is designed to work where you have a non-iterative but biased class for estimating the defined PSF (as
is done with Gaussian PSFs by using a logarithmic transformation). If that is the case, and the unbiased estimator
class uses the same attributes and the biased estimator class, then you can use this as is to add the ability to get
the biased estimate and then correct it along with the background gradient. Otherwise you will need to do things
yourself and shouldn't bother with this class.
To use this class, override the :meth:`~PointSpreadFunction.fit` method, and then call
``super().fit_lstsq(x, y, z)``
This also adds 2 instance attributes :attr:`_residuals` and :attr:`_covariance` which store the covariance and
residuals of the fit if requested.
"""
def __init__(self, bg_b_coef: Optional[Real] = None, bg_c_coef: Optional[Real] = None,
bg_d_coef: Optional[Real] = None, **kwargs):
"""
:param bg_b_coef: The x slope of the background gradient
:param bg_c_coef: They y slope of the background gradient
:param bg_d_coef: The constant offset of the background gradient
"""
super().__init__(bg_b_coef, bg_c_coef, bg_d_coef, **kwargs)
self._covariance = None # type: NONEARRAY
"""
The covariance of the fit as a nxn array (for n state elements) or None, depending on if
:attr:`~PointSpreadFunction.save_residuals` is ``True``.
"""
self._residuals = None # type: NONEARRAY
"""
The residuals of the fit as a length m array (for m observations) or None, depending on if
:attr:`~PointSpreadFunction.save_residuals` is ``True``.
"""
[docs] @classmethod
def fit_lstsq(cls, x: ARRAY_LIKE, y: ARRAY_LIKE, z: ARRAY_LIKE) -> __qualname__:
"""
This fits a PSF to a surface using iterative non-linear least squares estimation.
The estimation in this function is performed iteratively. First, the rough background is estimated and removed.
Then, a non-iterative fit is performed using the super class's fit method on the data with the rough background
removed. This initial fit is then refined using iterative non-linear least squares to
remove biases that might have been introduced in the non-iterative fit.
If the fit is unsuccessful due to a rank deficient matrix then
:meth:`~.IterativeNonlinearLSTSQwBackground.update_states` will be called which will likely result in the state
parameters being set to NaN.
:param x: The x values underlying the surface the PSF is to be fit to
:param y: The y values underlying the surface the PSF is to be fit to
:param z: The z or "height" values of the surface the PSF is to be fit to
:return: The initialized PSF with values according to the fit
"""
# make sure the arrays are flat and numpy arrays
x = np.array(x)
y = np.array(y)
z = np.array(z)
# fit just the background
bg = cls.fit_bg(x, y, z)
# subtract off the rough background
z_no_bg = z - bg.evaluate_bg(x, y).reshape(z.shape)
if (z.ndim == 2) & (max(z.shape) > 5):
# do the initial fit only using the 9 pixels closest to the center
pix_center = np.array(z.shape) // 2
r_slice = slice(pix_center[0] - 1, pix_center[0] + 2)
c_slice = slice(pix_center[1] - 1, pix_center[1] + 2)
use_z = z_no_bg[r_slice, c_slice]
out = super(cls, cls).fit(x[r_slice, c_slice], y[r_slice, c_slice], use_z-use_z.min()+1)
else:
out = super(cls, cls).fit(x, y, z_no_bg)
# noinspection PyArgumentList
# at this point we assume that cls is fully functional and takes the same arguments as the attributes of
# its super class
out.bg_b_coef = bg.bg_b_coef
out.bg_c_coef = bg.bg_c_coef
out.bg_d_coef = bg.bg_d_coef
# make things flat
x = x.ravel()
y = y.ravel()
z = z.ravel()
# converge to the better solution and capture the residuals, maybe
out._residuals, out._covariance = out.converge(x, y, z)
return out
[docs] def compute_jacobian_all(self, x: np.ndarray, y: np.ndarray, computed: np.ndarray) -> np.ndarray:
r"""
This method computes the Jacobian of the PSF with respect to a change in the state.
Mathematically, it should return the nxm matrix
.. math::
\mathbf{J} = \frac{\partial f(x, y)}{\partial \mathbf{t}}
where :math:`f(x,y)` is the function being fit, :math:`\mathbf{t}` is a length m vector of the state parameters,
and :math:`\mathbf{J}` is the Jacobian matrix. This specific implementation appends the background Jacobian to
the normal PSF Jacobian for estimating background terms.
:param x: The x values to evaluate the Jacobian at as a length n array
:param y: The y values to evaluate the Jacobian at as a length n array
:param computed: :math:`f(x,y)` evaluated at x and y as a length n array.
This is provided for efficiency and convenience as the evaluated function is frequently needed
in the computation of the Jacobian and it is definitely needed in the non-linear least squares.
If not needed for computing the Jacobian this can safely be ignored.
:return: The Jacobian matrix as a nxm numpy array, with n being the number of measurements and m being the
number of state parameters being estimated
"""
computed_no_bg = computed - self.evaluate_bg(x, y)
return np.hstack([super(self.__class__, self).compute_jacobian(x, y, computed_no_bg),
self.compute_jacobian_bg(x, y)])
[docs] def evaluate(self, x: ARRAY_LIKE, y: ARRAY_LIKE) -> np.ndarray:
"""
This method evaluates the PSF at the given x and y.
This method is not intended to be used to apply the PSF for an image (use the callable capability of the class
instead for this). Instead it simply computes the height of the PSF above the xy-plane at the requested
locations.
:param x: The x locations the height of the PSF is to be calculated at.
:param y: The y locations the height of the PSF is to be calculated at.
:return: A numpy array containing the height of the PSF at the requested locations the same shape as x and y.
"""
extras = super().evaluate(x, y)
if extras is None:
extras = 0
return self.evaluate_bg(x, y) + extras