Source code for photutils.profiles.radial_profile

# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
Tools for generating radial profiles.
"""

import warnings

import numpy as np
from astropy.modeling.fitting import TRFLSQFitter
from astropy.modeling.models import Gaussian1D, Moffat1D
from astropy.stats import gaussian_sigma_to_fwhm
from astropy.utils import lazyproperty
from astropy.utils.exceptions import AstropyUserWarning

from photutils.aperture.core import _update_method_subpixels_docstring
from photutils.profiles.core import ProfileBase

__all__ = ['RadialProfile']


[docs] @_update_method_subpixels_docstring class RadialProfile(ProfileBase): """ Class to create a radial profile using concentric circular annulus apertures. The radial profile represents the azimuthally-averaged flux in circular annuli apertures as a function of radius. For this class, the input radii represent the edges of the radial bins. This differs from the `CurveOfGrowth` class, where the input radii are the radii of the circular apertures used to compute the cumulative flux. The output `radius` attribute represents the bin centers. Parameters ---------- data : 2D `~numpy.ndarray` The 2D data array. The data should be background-subtracted. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` array are automatically masked. xycen : tuple of 2 floats The ``(x, y)`` pixel coordinate of the source center. radii : 1D float `~numpy.ndarray` An array of radii defining the *edges* of the radial bins. ``radii`` must be strictly increasing with a minimum value greater than or equal to zero, and contain at least 2 values. The radial spacing does not need to be constant. The output `radius` attribute will be defined at the bin centers. error : 2D `~numpy.ndarray`, optional The 1-sigma errors of the input ``data``. ``error`` is assumed to include all sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`). ``error`` must have the same shape as the input ``data``. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` array are automatically masked. mask : 2D bool `~numpy.ndarray`, optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. <method_subpixels_descriptions> See Also -------- CurveOfGrowth Notes ----- If the minimum of ``radii`` is zero, then a circular aperture with radius equal to ``radii[1]`` will be used for the innermost aperture. Examples -------- >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.centroids import centroid_2dg >>> from photutils.datasets import make_noise_image >>> from photutils.profiles import RadialProfile Create an artificial single source. Note that this image does not have any background. >>> gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) >>> yy, xx = np.mgrid[0:100, 0:100] >>> data = gmodel(xx, yy) >>> bkg_sig = 2.1 >>> noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) >>> data += noise >>> error = np.zeros_like(data) + bkg_sig Create the radial profile. >>> xycen = centroid_2dg(data) >>> edge_radii = np.arange(25) >>> rp = RadialProfile(data, xycen, edge_radii, error=error) >>> print(rp.radius) # doctest: +FLOAT_CMP [ 0.5 1.5 2.5 3.5 4.5 5.5 6.5 7.5 8.5 9.5 10.5 11.5 12.5 13.5 14.5 15.5 16.5 17.5 18.5 19.5 20.5 21.5 22.5 23.5] >>> print(rp.profile) # doctest: +FLOAT_CMP [ 4.30187860e+01 4.02502046e+01 3.57758011e+01 3.16071235e+01 2.61511082e+01 2.10539746e+01 1.63701300e+01 1.16674718e+01 8.12828014e+00 5.78962699e+00 3.59342666e+00 2.35353336e+00 1.20355937e+00 7.67093923e-01 4.24650784e-01 8.67989701e-02 5.11484374e-02 -9.82041768e-02 2.37482124e-02 -3.66602855e-02 6.84802299e-02 1.72239596e-01 -3.86056497e-02 7.30423743e-02] >>> print(rp.profile_error) # doctest: +FLOAT_CMP [1.18479813 0.68404352 0.52985783 0.4478116 0.39493271 0.35723008 0.32860388 0.30591356 0.28735575 0.27181133 0.25854415 0.24704749 0.23695963 0.22801451 0.22001149 0.21279603 0.20624688 0.20026744 0.19477961 0.18971954 0.18503438 0.18068002 0.17661928 0.17282057] Plot the radial profile, including the raw data profile. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # Create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error) # Plot the radial profile fig, ax = plt.subplots(figsize=(8, 6)) rp.plot(ax=ax, color='C0') rp.plot_error(ax=ax) ax.scatter(rp.data_radius, rp.data_profile, s=1, color='C1') Normalize the profile and plot the normalized radial profile. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # Create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error) # Plot the radial profile rp.normalize() fig, ax = plt.subplots(figsize=(8, 6)) rp.plot(ax=ax) rp.plot_error(ax=ax) Plot three of the annulus apertures on the data. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from astropy.visualization import simple_norm from photutils.centroids import centroid_2dg from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # Create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error) norm = simple_norm(data, 'sqrt') fig, ax = plt.subplots(figsize=(5, 5)) ax.imshow(data, norm=norm, origin='lower') rp.apertures[5].plot(ax=ax, color='C0', lw=2) rp.apertures[10].plot(ax=ax, color='C1', lw=2) rp.apertures[15].plot(ax=ax, color='C3', lw=2) Fit 1D Gaussian and Moffat models to the normalized radial profile. >>> rp.normalize() >>> rp.gaussian_fit # doctest: +FLOAT_CMP <Gaussian1D(amplitude=0.98231088, mean=0., stddev=4.67512776)> >>> rp.moffat_fit # doctest: +ELLIPSIS <Moffat1D(amplitude=..., x_0=0., gamma=..., alpha=...)> >>> print(rp.gaussian_fwhm) # doctest: +FLOAT_CMP 11.009084813327846 >>> print(rp.moffat_fwhm) # doctest: +FLOAT_CMP 10.868426507928344 Plot the fitted 1D Gaussian and Moffat models on the radial profile. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # Create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error) # Plot the radial profile rp.normalize() fig, ax = plt.subplots(figsize=(8, 6)) rp.plot(ax=ax, label='Radial Profile') rp.plot_error(ax=ax) ax.plot(rp.radius, rp.gaussian_profile, label='Gaussian Fit') ax.plot(rp.radius, rp.moffat_profile, label='Moffat Fit') ax.legend() """ # Define y-axis label used by `~photutils.profiles.ProfileBase.plot` _ylabel = 'Radial Profile' # Define the fit properties that should be invalidated when the # profile normalization is changed, so they are always consistent # with the current profile. _fit_properties = ('_profile_nanmask', 'gaussian_fit', 'gaussian_profile', 'gaussian_fwhm', 'moffat_fit', 'moffat_profile', 'moffat_fwhm') @lazyproperty def radius(self): """ The profile radius (bin centers) in pixels as a 1D `~numpy.ndarray`. The returned radius values are defined as the arithmetic means of the input radial-bins edges (``radii``). For logarithmically-spaced input ``radii``, one could instead use a radius array defined using the geometric mean of the bin edges, i.e. ``np.sqrt(radii[:-1] * radii[1:])``. """ # Define the radial bin centers from the radial bin edges return (self.radii[:-1] + self.radii[1:]) / 2 @lazyproperty def apertures(self): """ A list of the circular annulus apertures used to measure the radial profile, as `~photutils.aperture.CircularAnnulus` objects. If the minimum of ``radii`` is zero, then the innermost element will be a `~photutils.aperture.CircularAperture` with radius equal to ``radii[1]``. """ # Prevent circular imports from photutils.aperture import CircularAnnulus, CircularAperture apertures = [] for i in range(len(self.radii) - 1): try: aperture = CircularAnnulus(self.xycen, self.radii[i], self.radii[i + 1]) except ValueError: # zero radius aperture = CircularAperture(self.xycen, self.radii[i + 1]) apertures.append(aperture) return apertures @lazyproperty def _flux(self): """ The flux in a circular annulus. """ return np.diff(self._photometry[0]) @lazyproperty def _flux_err(self): """ The flux error in a circular annulus. """ return np.sqrt(np.diff(self._photometry[1] ** 2)) @lazyproperty def area(self): """ The unmasked area in each circular annulus (or aperture) as a function of radius as a 1D `~numpy.ndarray`. """ return np.diff(self._photometry[2]) @lazyproperty def profile(self): """ The radial profile as a 1D `~numpy.ndarray`. """ # Ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return self._flux / self.area @lazyproperty def profile_error(self): """ The radial profile errors as a 1D `~numpy.ndarray`. If no ``error`` array was provided, an empty array with shape ``(0,)`` is returned. """ if self.error is None: return self._flux_err # Ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return self._flux_err / self.area @lazyproperty def _profile_nanmask(self): return np.isfinite(self.profile) @lazyproperty def gaussian_fit(self): """ The fitted 1D Gaussian to the radial profile as a `~astropy.modeling.functional_models.Gaussian1D` model. The cached fit is automatically invalidated when the profile normalization is changed, so the fit is always consistent with the current profile. """ profile = self.profile[self._profile_nanmask] radius = self.radius[self._profile_nanmask] if len(profile) == 0: msg = ('The radial profile is entirely non-finite or masked; ' 'cannot fit a Gaussian.') warnings.warn(msg, AstropyUserWarning) return None amplitude = np.max(profile) sum_profile = np.sum(profile) if sum_profile == 0: msg = ('The profile sum is zero; the Gaussian fit initial ' 'guess may be inaccurate.') warnings.warn(msg, AstropyUserWarning) std = 1.0 # fallback to avoid zero initial guess else: std = np.sqrt(abs(np.sum(profile * radius**2) / sum_profile)) std = max(std, 1.0) # guard against near-zero initial guess g_init = Gaussian1D(amplitude=amplitude, mean=0.0, stddev=std) g_init.mean.fixed = True fitter = TRFLSQFitter() gaussian_fit = fitter(g_init, radius, profile) if radius.min() > 0.3 * gaussian_fit.stddev.value: msg = ('Gaussian fit may be unreliable because the input ' 'radii do not extend close to the source center.') warnings.warn(msg, AstropyUserWarning) return gaussian_fit @lazyproperty def gaussian_profile(self): """ The fitted 1D Gaussian profile to the radial profile as a 1D `~numpy.ndarray`. The cached profile is automatically invalidated when the profile normalization is changed. Returns `None` if the fit failed (e.g., the profile is entirely non-finite or masked). """ if self.gaussian_fit is None: return None return self.gaussian_fit(self.radius) @lazyproperty def gaussian_fwhm(self): """ The full-width at half-maximum (FWHM) in pixels of the 1D Gaussian fitted to the radial profile. The cached value is automatically invalidated when the profile normalization is changed. Returns `None` if the fit failed (e.g., the profile is entirely non-finite or masked). """ if self.gaussian_fit is None: return None return self.gaussian_fit.stddev.value * gaussian_sigma_to_fwhm @lazyproperty def moffat_fit(self): """ The fitted 1D Moffat to the radial profile as a `~astropy.modeling.functional_models.Moffat1D` model. The cached fit is automatically invalidated when the profile normalization is changed, so the fit is always consistent with the current profile. """ profile = self.profile[self._profile_nanmask] radius = self.radius[self._profile_nanmask] if len(profile) == 0: msg = ('The radial profile is entirely non-finite or masked; ' 'cannot fit a Moffat.') warnings.warn(msg, AstropyUserWarning) return None amplitude = np.max(profile) sum_profile = np.sum(profile) if sum_profile == 0: msg = ('The profile sum is zero; the Moffat fit initial ' 'guess may be inaccurate.') warnings.warn(msg, AstropyUserWarning) gamma = 1.0 # fallback to avoid zero initial guess else: # Estimate gamma from the half-max radius half_max = amplitude / 2.0 above = profile >= half_max gamma = (max(np.max(radius[above]), 1.0) if np.any(above) else 1.0) m_init = Moffat1D(amplitude=amplitude, x_0=0.0, gamma=gamma, alpha=2.5) m_init.x_0.fixed = True m_init.gamma.bounds = (0, None) m_init.alpha.bounds = (1, 25) fitter = TRFLSQFitter() return fitter(m_init, radius, profile) @lazyproperty def moffat_profile(self): """ The fitted 1D Moffat profile to the radial profile as a 1D `~numpy.ndarray`. The cached profile is automatically invalidated when the profile normalization is changed. Returns `None` if the fit failed (e.g., the profile is entirely non-finite or masked). """ if self.moffat_fit is None: return None return self.moffat_fit(self.radius) @lazyproperty def moffat_fwhm(self): """ The full-width at half-maximum (FWHM) in pixels of the 1D Moffat fitted to the radial profile. The cached value is automatically invalidated when the profile normalization is changed. Returns `None` if the fit failed (e.g., the profile is entirely non-finite or masked). """ if self.moffat_fit is None: return None return self.moffat_fit.fwhm @lazyproperty def _data_profile(self): """ The raw data profile returned as 1D arrays (`~numpy.ndarray`) of radii and data values. Returns the radii and values of the unmasked data points within the maximum radius defined by the input radii. Pixels flagged in ``self.mask`` (including auto-masked non-finite values) are excluded. """ shape = self.data.shape max_radius = np.max(self.radii) x_min = int(max(np.floor(self.xycen[0] - max_radius), 0)) x_max = int(min(np.ceil(self.xycen[0] + max_radius), shape[1])) y_min = int(max(np.floor(self.xycen[1] - max_radius), 0)) y_max = int(min(np.ceil(self.xycen[1] + max_radius), shape[0])) yidx, xidx = np.indices((y_max - y_min, x_max - x_min)) xidx += x_min yidx += y_min # Calculate the radii of the pixels from the center and select # those within the maximum radius defined by the input radii radii = np.hypot(xidx - self.xycen[0], yidx - self.xycen[1]) within = radii <= max_radius radii = radii[within] yidx_sub = yidx[within] xidx_sub = xidx[within] # Exclude masked pixels (user mask and auto-masked non-finite # values) valid = ~self.mask[yidx_sub, xidx_sub] radii = radii[valid] data_values = self.data[yidx_sub[valid], xidx_sub[valid]] return radii, data_values @lazyproperty def data_radius(self): """ The radii of the raw data profile as a 1D `~numpy.ndarray`. """ return self._data_profile[0] @lazyproperty def data_profile(self): """ The raw data profile as a 1D `~numpy.ndarray`. """ return self._data_profile[1] def _invalidate_fit_cache(self): """ Remove cached Gaussian and Moffat fit lazy properties so they are recomputed on next access using the current profile. """ for key in self._fit_properties: self.__dict__.pop(key, None) def _normalize_hook(self, normalization): """ Also normalize ``data_profile`` if it has been computed, and invalidate fit caches so they are recomputed on next access. """ if 'data_profile' in self.__dict__: self.__dict__['data_profile'] = self.data_profile / normalization self._invalidate_fit_cache() def _unnormalize_hook(self): """ Also unnormalize ``data_profile`` if it has been computed, and invalidate fit caches so they are recomputed on next access. """ if 'data_profile' in self.__dict__: self.__dict__['data_profile'] = (self.data_profile * self.normalization_value) self._invalidate_fit_cache()