Source code for photutils.psf.image_models

# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
Image-based PSF models.
"""

import copy
from functools import cached_property

import numpy as np
from astropy.modeling import Fittable2DModel, Parameter
from scipy.interpolate import RectBivariateSpline

from photutils.psf.utils import _out_of_grid_mask
from photutils.utils._parameters import as_pair

__all__ = ['ImagePSF']


[docs] class ImagePSF(Fittable2DModel): """ A model representing a 2D image PSF. This class evaluates a 2D image PSF at arbitrary positions, including fractional pixel coordinates, using spline interpolation provided by `~scipy.interpolate.RectBivariateSpline`. This model has three parameters: an image intensity scaling factor (``flux``), which scales the input image, and two positional parameters (``x_0`` and ``y_0``), which specify the location of the feature in the coordinate grid where the model is evaluated. Parameters ---------- data : 2D `~numpy.ndarray` A 2D array containing the PSF image. The x and y dimensions must both be at least 4 pixels. All values must be finite. By default, the PSF peak is assumed to be centered in the input image (see ``origin``). See the Notes section for details on the required normalization of the input image. flux : float, optional The flux scaling factor. This corresponds to the total source flux, assuming the input PSF image is properly normalized. x_0, y_0 : float, optional The x and y positions of a feature in the image in the output coordinate grid on which the model is evaluated. Typically, this refers to the position of the PSF peak, which is assumed to be located at the center of the input image (see the ``origin`` keyword). origin : tuple of 2 float or None, optional The ``(x, y)`` coordinate in the input image corresponding to the reference pixel. The reference pixel is placed at the model ``x_0`` and ``y_0`` coordinates in the output coordinate grid. In most cases, the PSF should be centered in the input image, so ``origin`` should be set to the central pixel of ``data``. If `None`, ``origin`` is set to the center of the input image, ``((n_x - 1) / 2, (n_y - 1) / 2)``. oversampling : int or array_like (int), optional The integer oversampling factor(s) of the input PSF image. If a scalar is provided, it is applied to both axes. If two values are provided, they must be in ``(y, x)`` order. fill_value : float or `None`, optional The value used for points outside the input pixel grid. The default is 0.0. If `None`, values outside the input pixel grid are extrapolated from the spline fit. **kwargs : dict, optional Additional keyword arguments passed to the `~astropy.modeling.Model` base class. See Also -------- GriddedPSFModel : A model for a grid of ePSF models. Notes ----- The fitted ``flux`` parameter represents the total source flux, provided the input PSF image is properly normalized. The fitted flux is a multiplicative scale factor applied to the input PSF after accounting for any oversampling. For a fully sampled ePSF (i.e., no oversampling), the sum of the ePSF values over an infinite grid is 1.0. Because ePSFs are represented by finite images in practice, the sum of the array values may be less than 1.0. For oversampled ePSF images, the normalization should instead be such that the sum of the array values over an infinite grid equals the product of the oversampling factors (e.g., ``oversampling**2`` when the oversampling is the same along both axes). Again, a finite image will generally have a smaller sum because it does not contain the full PSF wings. If the input PSF image covers only a finite region of the PSF, correction factors based on the encircled or ensquared energy can be used to estimate the missing flux and obtain the proper normalization. Examples -------- In this simple example, we create a PSF image model from a Circular Gaussian PSF. In this case, one should use the `CircularGaussianPSF` model directly as a PSF model. However, this example demonstrates how to create an image PSF model from an input image. .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import CircularGaussianPSF, ImagePSF gaussian_psf = CircularGaussianPSF(x_0=12, y_0=12, fwhm=3.2) yy, xx = np.mgrid[:25, :25] psf_data = gaussian_psf(xx, yy) psf_model = ImagePSF(psf_data, x_0=12, y_0=12, flux=10) data = psf_model(xx, yy) fig, ax = plt.subplots() ax.imshow(data, origin='lower') """ flux = Parameter(default=1, description='Intensity scaling factor of the image.') x_0 = Parameter(default=0, description=('Position of a feature in the image along ' 'the x axis')) y_0 = Parameter(default=0, description=('Position of a feature in the image along ' 'the y axis')) def __init__(self, data, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, origin=None, oversampling=1, fill_value=0.0, **kwargs): self.data = data self.origin = origin self.oversampling = oversampling self.fill_value = fill_value super().__init__(flux, x_0, y_0, **kwargs) @staticmethod def _validate_data(data): if not isinstance(data, np.ndarray): msg = 'Input data must be a 2D numpy array' raise TypeError(msg) if data.ndim != 2: msg = 'Input data must be a 2D numpy array' raise ValueError(msg) if not np.all(np.isfinite(data)): msg = 'All elements of input data must be finite' raise ValueError(msg) # The minimum number of data points required is 4 along each # axis. This is because RectBivariateSpline requires at least 4 # points along each axis for cubic spline interpolation (kx=3, # ky=3). if np.any(np.array(data.shape) < 4): msg = 'The length of the x and y axes must both be at least 4' raise ValueError(msg) def __str__(self): keywords = [('PSF shape (oversampled pixels)', self.data.shape), ('Origin', self.origin.tolist()), ('Oversampling', tuple(self.oversampling.tolist())), ('Fill Value', self.fill_value), ] return self._format_str(keywords=keywords) def __repr__(self): kwargs = {'origin': self.origin.tolist(), 'oversampling': self.oversampling.tolist(), 'fill_value': self.fill_value} return self._format_repr(kwargs=kwargs)
[docs] def copy(self): """ Return a copy of this model where only the model parameters are copied. All other copied model attributes are references to the original model. This prevents copying the image data, which may be a large array. This method is useful if one is interested in only changing the model parameters in a model copy. It is used in the PSF photometry classes during model fitting. Use the `deepcopy` method if you want to copy all the model attributes, including the PSF image data. Returns ------- result : `ImagePSF` A copy of this model with only the model parameters copied. """ newcls = object.__new__(self.__class__) # Snapshot so concurrent cached_property fills cannot resize # the dict during iteration for key, val in dict(self.__dict__).items(): if key in self.param_names: # copy only the parameter values newcls.__dict__[key] = copy.copy(val) else: newcls.__dict__[key] = val return newcls
[docs] def deepcopy(self): """ Return a deep copy of this model. Returns ------- result : `ImagePSF` A deep copy of this model. """ return copy.deepcopy(self)
@property def data(self): """ The 2D image of the PSF. Setting this attribute revalidates the input array and discards the cached `interpolator`. The `origin` is not updated, so it should be reset explicitly if the new image has a different shape or a different reference pixel. """ return self._data @data.setter def data(self, value): """ Set the 2D image of the PSF. Parameters ---------- value : 2D `~numpy.ndarray` The 2D image of the PSF. """ self._validate_data(value) self._data = value # Discard the cached interpolators, which are tied to the old # data self.__dict__.pop('interpolator', None) self.__dict__.pop('_deriv_interpolators', None) @property def shape(self): """ The shape of the (oversampled) PSF data array. Returns ------- shape : tuple The shape of the (oversampled) PSF data array. """ return self.data.shape @property def oversampling(self): """ The integer oversampling factor(s) of the input PSF image. If ``oversampling`` is a scalar then it will be used for both axes. If ``oversampling`` has two elements, they must be in ``(y, x)`` order. """ return self._oversampling @oversampling.setter def oversampling(self, value): """ Set the oversampling factor(s) of the input PSF image. Parameters ---------- value : int or tuple of int The integer oversampling factor(s) of the input PSF image. If ``oversampling`` is a scalar then it will be used for both axes. If ``oversampling`` has two elements, they must be in ``(y, x)`` order. """ self._oversampling = as_pair('oversampling', value, lower_bound=(0, 0)) @property def origin(self): """ The (x, y) pixel coordinates, as a 1D `~numpy.ndarray`, of the origin of the coordinate system within the model image. The reference ``origin`` pixel will be placed at the model ``x_0`` and ``y_0`` coordinates in the output coordinate system on which the model is evaluated. Most typically, the input PSF should be centered in the input image, and thus the origin should be set to the central pixel of the ``data`` array. If the origin is set to `None`, then the origin will be set to the center of the ``data`` array (``(n_pixels - 1) / 2.0``). """ return self._origin @origin.setter def origin(self, origin): if origin is None: origin = (np.array(self.data.shape) - 1.0) / 2.0 origin = origin[::-1] # flip to (x, y) order else: origin = np.asarray(origin) if origin.ndim != 1 or len(origin) != 2: msg = 'origin must be 1D and have 2-elements' raise ValueError(msg) if not np.all(np.isfinite(origin)): msg = 'All elements of origin must be finite' raise ValueError(msg) self._origin = origin
[docs] @cached_property def interpolator(self): """ The interpolating spline function. The interpolator is computed with a 3rd-degree `~scipy.interpolate.RectBivariateSpline` (kx=3, ky=3, s=0) using the input image data. The interpolator is used to evaluate the model at arbitrary locations, including fractional pixel positions. Notes ----- This property can be overridden in a subclass to define custom interpolators. A custom interpolator must provide a `~scipy.interpolate.RectBivariateSpline`-compatible ``partial_derivative`` method to support `fit_deriv`. Otherwise, the subclass should also set ``fit_deriv = None`` to fall back to the fitter's finite-difference Jacobian. """ x = np.arange(self.data.shape[1]) y = np.arange(self.data.shape[0]) # RectBivariateSpline expects the data to be in (x, y) axis order return RectBivariateSpline(x, y, self.data.T, kx=3, ky=3, s=0)
@cached_property def _deriv_interpolators(self): """ The spline partial-derivative interpolators. The interpolators evaluate the partial derivatives of `interpolator` with respect to its first (x) and second (y) variables. They are precomputed here because evaluating them is faster than passing ``dx=1`` or ``dy=1`` to `interpolator`, which computes the derivative on the fly. They are used by `fit_deriv`. """ return (self.interpolator.partial_derivative(1, 0), self.interpolator.partial_derivative(0, 1)) def _calc_bounding_box(self): """ Return a bounding box defining the limits of the model. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ dy, dx = np.array(self.data.shape) / 2 / self.oversampling # Apply the origin shift. If origin is None, the origin is set # to the center of the image and the shift is 0. xshift = (self.data.shape[1] - 1) / 2 - self.origin[0] yshift = (self.data.shape[0] - 1) / 2 - self.origin[1] xshift /= self.oversampling[1] yshift /= self.oversampling[0] return ((self.y_0 - dy + yshift, self.y_0 + dy + yshift), (self.x_0 - dx + xshift, self.x_0 + dx + xshift)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import ImagePSF >>> psf_data = np.arange(30, dtype=float).reshape(5, 6) >>> psf_data /= np.sum(psf_data) >>> model = ImagePSF(psf_data, flux=1, x_0=0, y_0=0) >>> model.bounding_box ModelBoundingBox( intervals={ x: Interval(lower=-3.0, upper=3.0) y: Interval(lower=-2.5, upper=2.5) } model=ImagePSF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box()
[docs] def evaluate(self, x, y, flux, x_0, y_0): """ Calculate the value of the image model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float The total flux of the source, assuming the input image was properly normalized. x_0, y_0 : float The x and y positions of the feature in the image in the output coordinate grid on which the model is evaluated. Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ # Promote scalar inputs to 1D arrays so that the interpolator # returns an array that supports masked assignment below, # regardless of the scipy version x = np.atleast_1d(np.asarray(x, dtype=float)) y = np.atleast_1d(np.asarray(y, dtype=float)) xi = self.oversampling[1] * (x - x_0) yi = self.oversampling[0] * (y - y_0) xi += self._origin[0] yi += self._origin[1] evaluated_model = flux * self.interpolator(xi, yi, grid=False) if self.fill_value is not None: # Set pixels that are outside the input pixel grid to the # fill_value to avoid extrapolation invalid = _out_of_grid_mask(xi, yi, self.data.shape) evaluated_model[invalid] = self.fill_value return evaluated_model
[docs] def fit_deriv(self, x, y, flux, x_0, y_0): """ Calculate the partial derivatives of the image model with respect to the model parameters. Providing this analytic Jacobian allows the fitter to avoid the finite-difference approximation, which requires additional model evaluations. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float The total flux of the source, assuming the input image was properly normalized. x_0, y_0 : float The x and y positions of the feature in the image in the output coordinate grid on which the model is evaluated. Returns ------- result : list of `~numpy.ndarray` The list of partial derivatives with respect to the ``flux``, ``x_0``, and ``y_0`` parameters. """ # Promote scalar inputs to 1D arrays so that the interpolator # returns an array that supports masked assignment below, # regardless of the scipy version x = np.atleast_1d(np.asarray(x, dtype=float)) y = np.atleast_1d(np.asarray(y, dtype=float)) xi = self.oversampling[1] * (x - x_0) yi = self.oversampling[0] * (y - y_0) xi += self._origin[0] yi += self._origin[1] # The spline interpolation is linear in flux, and the chain rule # gives the x_0 and y_0 derivatives from the spline partial # derivatives (dxi/dx_0 = -oversampling[1], dyi/dy_0 = # -oversampling[0]) dx_interp, dy_interp = self._deriv_interpolators d_flux = self.interpolator(xi, yi, grid=False) d_x_0 = (-flux * self.oversampling[1] * dx_interp(xi, yi, grid=False)) d_y_0 = (-flux * self.oversampling[0] * dy_interp(xi, yi, grid=False)) if self.fill_value is not None: # Outside the input pixel grid the model is constant # (fill_value), so all derivatives are zero there invalid = _out_of_grid_mask(xi, yi, self.data.shape) d_flux[invalid] = 0.0 d_x_0[invalid] = 0.0 d_y_0[invalid] = 0.0 return [d_flux, d_x_0, d_y_0]