Source code for photutils.aperture.stats

# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
Tools for calculating properties of sources defined by an Aperture.
"""

import inspect
import warnings
from copy import deepcopy

import astropy.units as u
import numpy as np
from astropy.coordinates import SkyCoord
from astropy.nddata import NDData, StdDevUncertainty
from astropy.stats import (SigmaClip, biweight_location, biweight_midvariance,
                           mad_std)
from astropy.utils import lazyproperty
from astropy.utils.exceptions import AstropyUserWarning

from photutils.aperture import Aperture, SkyAperture, region_to_aperture
from photutils.aperture._batch_photometry import (FLAG_COL_BBOX_CLIPPED,
                                                  FLAG_COL_MASKED,
                                                  FLAG_COL_NONFINITE_DATA,
                                                  FLAG_COL_NONFINITE_ERROR,
                                                  FLAG_COL_NPIX, FLAG_COL_SEG,
                                                  FLAG_COL_UNCORRECTED,
                                                  FLAG_COL_VALID,
                                                  batch_aperture_sums)
from photutils.aperture._batch_stats import (batch_aperture_gather,
                                             batch_biweight, batch_gini,
                                             batch_mad, batch_mean_var,
                                             batch_moments, batch_order_stats,
                                             batch_sigma_clip_center,
                                             batch_sigma_clip_sum,
                                             batch_sort_values)
from photutils.aperture._segmentation import (SEG_METHOD_CODES,
                                              make_segmentation_exclusion,
                                              process_segmentation_inputs)
from photutils.aperture.core import (_aperture_metadata,
                                     _update_method_subpixels_docstring)
from photutils.aperture.flags import (APERTURE_FLAGS, _counts_to_flag_bits,
                                      decode_aperture_flags)
from photutils.morphology import gini as gini_func
from photutils.utils._deprecation import (create_empty_deprecated_qtable,
                                          deprecated, deprecated_getattr,
                                          deprecated_positional_kwargs)
from photutils.utils._misc import _get_meta
from photutils.utils._moments import _image_moments
from photutils.utils._parameters import validate_table_columns
from photutils.utils._quantity_helpers import process_quantities

__all__ = ['ApertureStats']


# Scale factor that converts the median absolute deviation to a robust
# estimate of the standard deviation (1 / scipy.stats.norm.ppf(0.75)).
# This must match ``astropy.stats.mad_std`` and the value in
# ``photutils.aperture._batch_stats``.
_MAD_STD_SCALE = 1.482602218505602


# Remove in 4.0
_DEPRECATED_ATTRIBUTES: dict = {
    'covar_sigx2': 'covariance_xx',
    'covar_sigxy': 'covariance_xy',
    'covar_sigy2': 'covariance_yy',
    'cxx': 'ellipse_cxx',
    'cxy': 'ellipse_cxy',
    'cyy': 'ellipse_cyy',
    'data_sumcutout': 'data_sum_cutout',
    'error_sumcutout': 'error_sum_cutout',
    'get_id': 'select_id',
    'get_ids': 'select_ids',
    'semimajor_sigma': 'semimajor_axis',
    'semiminor_sigma': 'semiminor_axis',
    'xcentroid': 'x_centroid',
    'ycentroid': 'y_centroid',
}


# Public attributes that are never collapsed to a scalar for a scalar
# instance because they describe the whole object rather than a single
# per-source value (see ``ApertureStats.__getattribute__``).
_SCALAR_EXCLUDE = frozenset({'default_columns', 'isscalar', 'n_apertures',
                             'properties'})


class _UncachedLazyProperty(lazyproperty):
    """
    A property that is discovered as a source property (like
    `~astropy.utils.lazyproperty`) but is recomputed on every access
    instead of being cached.

    This is used for `ApertureStats.flags`, whose value can change
    over the object's lifetime. It reflects whether covariance-derived
    properties have been computed (see `ApertureStats.flags`). Caching
    would freeze the first-computed value, so the getter runs on every
    access. Subclassing `~astropy.utils.lazyproperty` keeps it in the
    `ApertureStats.properties` list and the ``to_table`` machinery.
    """

    def __get__(self, obj, owner=None):
        if obj is None:
            return self
        return self.fget(obj)


[docs] @_update_method_subpixels_docstring class ApertureStats: # numpydoc ignore: PR01,PR02,PR04,PR07 """ Class to create a catalog of statistics for pixels within an aperture. Note that this class returns the statistics of the input ``data`` values within the aperture. It does not convert data in surface brightness units to flux or counts. Conversion from surface-brightness units should be performed before using this function. Parameters ---------- data : 2D `~numpy.ndarray`, `~astropy.units.Quantity`, \ `~astropy.nddata.NDData` The 2D array from which to calculate the source properties. For accurate source properties, ``data`` should be background-subtracted. Non-finite ``data`` values (NaN and inf) are automatically masked. aperture : `~photutils.aperture.Aperture` or supported `~regions.Region` The aperture or region to apply to the data. The aperture or region object may contain more than one position. If the input ``aperture`` is a `~photutils.aperture.SkyAperture` or `~regions.SkyRegion` object, then a WCS must be input using the ``wcs`` keyword. Region objects are converted to aperture objects. error : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, optional The total error array corresponding to the input ``data`` array. ``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``. If ``data`` is a `~astropy.units.Quantity` array then ``error`` must be a `~astropy.units.Quantity` array (and vice versa) with identical units. Non-finite ``error`` values (NaN and +/- inf) are not automatically masked, unless they are at the same position of non-finite values in the input ``data`` array. Such pixels can be masked using the ``mask`` keyword. mask : 2D `~numpy.ndarray` (bool), 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. Non-finite values (NaN and inf) in the input ``data`` are automatically masked. wcs : WCS object or `None`, optional A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS <https://docs.astropy.org/en/stable/wcs/wcsapi.html>`_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). ``wcs`` is required if the input ``aperture`` is a `~photutils.aperture.SkyAperture` or `~regions.SkyRegion` object. If `None`, then all sky-based properties will be set to `None`. sigma_clip : `None` or `astropy.stats.SigmaClip` instance, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. sum_method : {'exact', 'center', 'subpixel'}, optional The method used to determine the pixel weights (the fraction of the pixel area covered by the aperture). This method is used only for calculating the ``sum``, ``sum_error``, ``sum_aper_area``, ``data_sum_cutout``, and ``error_sum_cutout`` properties. All other properties use the "center" aperture mask method. The following methods are available: <method_bullets> <subpixels_description> ddof : int, optional The delta degrees of freedom used when computing the ``var`` and ``std`` properties. The divisor used in the calculation is ``N - ddof``, where ``N`` is the number of unmasked pixels within the aperture. The default is ``ddof=0``, which gives the population variance and standard deviation. Use ``ddof=1`` to obtain the sample (unbiased) variance and standard deviation. This keyword affects only the ``var`` and ``std`` properties. All other properties are unaffected, including the ``mean_err`` and ``median_err`` standard errors, which are always computed using the sample standard deviation. Apertures with ``N <= ddof`` unmasked pixels have an undefined ``var`` and ``std`` and are set to NaN. local_bkg : float, `~numpy.ndarray`, `~astropy.units.Quantity`, or `None` The per-pixel local background values to subtract from the data before performing measurements. If input as an array, the order of ``local_bkg`` values corresponds to the order of the input ``aperture`` positions. ``local_bkg`` must have the same length as the input ``aperture`` or must be a scalar value, which will be broadcast to all apertures. If `None`, then no local background subtraction is performed. If the input ``data`` has units, then ``local_bkg`` must be a `~astropy.units.Quantity` with the same units. <segmentation_descriptions> Notes ----- ``data`` should be background-subtracted for accurate source properties. In addition to global background subtraction, local background subtraction can be performed using the ``local_bkg`` keyword values. `~regions.Region` objects are converted to `Aperture` objects using the :func:`region_to_aperture` function. The returned statistics are measured for the pixels within the input aperture at its input position. This class does not change the position of the input aperture. This class returns the centroid value of the pixels within the input aperture, but the input aperture is not recentered at the measured centroid position when making the measurements. If desired, you can create a new `Aperture` object using the measured centroid and then re-run `~photutils.aperture.ApertureStats`. All properties other than the sum-related ones described below are calculated using the "center" aperture-mask method, which assigns aperture weights of either 0 or 1, so the ``data`` pixel values are used directly and without weighting. This choice reflects a fundamental limitation because, unlike the mean or variance, order statistics (``min``, ``max``, ``median``) and robust estimators (``mad_std``, ``biweight_location``, ``biweight_midvariance``) have no standard, unambiguous definition for pixels with fractional (partial) aperture weights. Accordingly, these quantities cannot be rigorously computed from a weighted aperture footprint. The input ``sum_method`` and ``subpixels`` keywords are used to determine the aperture-mask method only for the sum-related properties: ``sum``, ``sum_err``, ``sum_aper_area``, ``data_sum_cutout``, and ``error_sum_cutout`` (also listed in the ``SUM_FOOTPRINT_PROPERTIES`` class attribute). All other properties, including ``mean``, ``median``, ``std``, and the morphological properties, always use the "center" aperture-mask method regardless of ``sum_method``. The default is ``sum_method='exact'``, which produces exact aperture-weighted photometry. The sum-related properties have their own separate `sum_flags` quality flags, distinct from the `flags` property used by all other properties. To check for any quality issue across both footprints, combine the two flag columns with a bitwise OR, e.g., ``aperstats.flags | aperstats.sum_flags``. The calculated statistics are always float64, regardless of the input ``data`` dtype (`~astropy.units.Quantity` values with float64 dtype if the input ``data`` has units). Examples -------- >>> from photutils.datasets import make_4gaussians_image >>> from photutils.aperture import CircularAperture, ApertureStats >>> data = make_4gaussians_image() >>> aper = CircularAperture((150, 25), 8) >>> aperstats = ApertureStats(data, aper) >>> print(aperstats.x_centroid) 149.99080259251238 >>> print(aperstats.y_centroid) 24.97484633000507 >>> print(aperstats.centroid) [149.99080259 24.97484633] >>> print(aperstats.mean, aperstats.median) 47.76300955780609 31.913789514433084 >>> print(aperstats.std) 39.193655383492974 >>> print(aperstats.sum) 9286.709206410273 >>> print(aperstats.sum_aper_area) 201.0619298297468 pix2 >>> # More than one aperture position >>> aper2 = CircularAperture(((150, 25), (90, 60)), 10) >>> aperstats2 = ApertureStats(data, aper2) >>> print(aperstats2.x_centroid) [149.98470724 89.97893946] >>> print(aperstats2.sum) [10177.62548482 36653.97704059] """ # Properties computed using the ``sum_method`` aperture-mask # footprint (set by the ``sum_method``/``subpixels`` keywords). # All other properties use the "center" aperture-mask method. SUM_FOOTPRINT_PROPERTIES = ('sum', 'sum_err', 'sum_aper_area', 'data_sum_cutout', 'error_sum_cutout', 'sum_flags') def __init__(self, data, aperture, *, error=None, mask=None, wcs=None, sigma_clip=None, sum_method='exact', subpixels=5, ddof=0, local_bkg=None, segmentation_image=None, labels=None, mask_method='none'): if isinstance(data, NDData): data, error, mask, wcs = self._unpack_nddata(data, error, mask, wcs) inputs = (data, error, local_bkg) names = ('data', 'error', 'local_bkg') inputs, unit = process_quantities(inputs, names) (data, error, local_bkg) = inputs self._data = self._validate_array(data, 'data', shape=False) self._data_unit = unit self._input_aperture = self._validate_aperture(aperture) aperture_meta = _aperture_metadata(aperture) # use input aperture if isinstance(aperture, SkyAperture) and wcs is None: msg = 'A wcs is required when using a SkyAperture' raise ValueError(msg) # Convert region to aperture if necessary if not isinstance(aperture, Aperture): aperture = region_to_aperture(aperture) self.aperture = aperture self._error = self._validate_array(error, 'error') self._mask = self._validate_array(mask, 'mask') self._wcs = wcs if sigma_clip is not None and not isinstance(sigma_clip, SigmaClip): msg = 'sigma_clip must be a SigmaClip instance' raise TypeError(msg) self.sigma_clip = sigma_clip self.sum_method = sum_method self.subpixels = subpixels if (isinstance(ddof, bool) or not isinstance(ddof, (int, np.integer)) or ddof < 0): msg = 'ddof must be a non-negative integer' raise ValueError(msg) self.ddof = ddof self._local_bkg = np.zeros(self.n_apertures) # no local bkg if local_bkg is not None: local_bkg = np.atleast_1d(local_bkg) if local_bkg.ndim != 1: msg = 'local_bkg must be a 1D array' raise ValueError(msg) n_local_bkg = len(local_bkg) if n_local_bkg not in (1, self.n_apertures): msg = ('local_bkg must be scalar or have the same length ' 'as the input aperture') raise ValueError(msg) local_bkg = np.broadcast_to(local_bkg, self.n_apertures) if np.any(~np.isfinite(local_bkg)): msg = ('local_bkg must not contain any non-finite ' '(e.g., inf or NaN) values') raise ValueError(msg) self._local_bkg = local_bkg # always an iterable self._ids = np.arange(self.n_apertures) + 1 self.default_columns = ['id', 'x_centroid', 'y_centroid', 'sky_centroid', 'sum', 'sum_err', 'sum_aper_area', 'sum_flags', 'center_aper_area', 'min', 'max', 'mean', 'median', 'mode', 'std', 'mad_std', 'var', 'biweight_location', 'biweight_midvariance', 'fwhm', 'semimajor_axis', 'semiminor_axis', 'orientation', 'eccentricity', 'flags'] self.meta = _get_meta() self.meta.update(aperture_meta) # Validate the segmentation-masking inputs and resolve the # per-aperture source labels self._mask_method = mask_method seg_positions = np.atleast_2d(self._pixel_aperture.positions) (self._segmentation, self._seg_labels) = process_segmentation_inputs( segmentation_image, labels, mask_method, seg_positions, self._data.shape) @staticmethod def _unpack_nddata(data, error, mask, wcs): nddata_attr = {'error': error, 'mask': mask, 'wcs': wcs} for key, value in nddata_attr.items(): if value is not None: msg = (f'The {key!r} keyword will be ignored. Its value ' 'is obtained from the input NDData object.') warnings.warn(msg, AstropyUserWarning) mask = data.mask wcs = data.wcs if isinstance(data.uncertainty, StdDevUncertainty): if data.uncertainty.unit is None: error = data.uncertainty.array else: error = data.uncertainty.array * data.uncertainty.unit if data.unit is not None: data = u.Quantity(data.data, unit=data.unit) else: data = data.data return data, error, mask, wcs @staticmethod def _validate_aperture(aperture): try: from regions import Region aper_types = (Aperture, Region) except ImportError: aper_types = Aperture if not isinstance(aperture, aper_types): msg = 'aperture must be an Aperture or Region object' raise TypeError(msg) return aperture def _validate_array(self, array, name, *, ndim=2, shape=True): if name == 'mask' and array is np.ma.nomask: array = None if array is not None: array = np.asanyarray(array) if array.ndim != ndim: msg = f'{name} must be a {ndim}D array' raise ValueError(msg) if shape and array.shape != self._data.shape: msg = f'data and {name} must have the same shape' raise ValueError(msg) return array @property def _lazyproperties(self): """ A list of all class lazyproperties (even in superclasses). The result is cached on the class to avoid repeated introspection via `inspect.getmembers`. """ cls = self.__class__ attr = '_cached_lazyproperties' # Subclasses get their own lazyproperty list if attr not in cls.__dict__: def islazyproperty(obj): return isinstance(obj, lazyproperty) setattr(cls, attr, [i[0] for i in inspect.getmembers( cls, predicate=islazyproperty)]) return getattr(cls, attr) @property def properties(self): """ A sorted list of the built-in source properties. """ lazyproperties = [name for name in self._lazyproperties if not name.startswith('_')] # isscalar and n_apertures are scalar values for the whole # object, not per-source values, so they are not valid table # columns lazyproperties.remove('isscalar') lazyproperties.remove('n_apertures') lazyproperties.sort() return lazyproperties def __getitem__(self, index): if self.isscalar: msg = (f'A scalar {self.__class__.__name__!r} object cannot ' 'be indexed') raise TypeError(msg) newcls = object.__new__(self.__class__) # Attributes defined in __init__ that are copied directly to the # new class init_attr = ('_data', '_data_unit', '_error', '_mask', '_wcs', 'sigma_clip', 'sum_method', 'subpixels', 'ddof', 'default_columns', 'meta', '_segmentation', '_mask_method') for attr in init_attr: setattr(newcls, attr, getattr(self, attr)) # aperture determines isscalar (needed below) newcls.aperture = self.aperture[index] # Keep _ids as a 1D array so a scalar instance's id is always # backed by a length-1 iterable (see the id property). newcls._ids = np.atleast_1d(self._ids[index]) # Slice the per-aperture segmentation labels if self._seg_labels is None: newcls._seg_labels = None else: newcls._seg_labels = np.atleast_1d(self._seg_labels[index]) # Slice evaluated lazyproperty objects keys = set(self.__dict__.keys()) & set(self._lazyproperties) keys.add('_local_bkg') # iterable defined in __init__ # The packed gather buffers and their reductions are not # per-source sliceable; the sliced object recomputes them # lazily from its sliced inputs. keys.discard('_batch_inputs') keys.discard('_fast_gather') keys.discard('_fast_sum') keys.discard('_sorted_values') keys.discard('_order_stats') keys.discard('_mean_var') keys.discard('_mad') keys.discard('_biweight') keys.discard('_gini') for key in keys: value = self.__dict__[key] # Do not insert attributes that are always scalar (e.g., # isscalar, n_apertures), i.e., not an array/list for each # source if np.isscalar(value): continue try: # Keep most _<attrs> as length-1 iterables if (newcls.isscalar and key.startswith('_') and key != '_pixel_aperture'): if isinstance(value, np.ndarray): val = value[:, np.newaxis][index] else: val = [value[index]] else: val = value[index] except TypeError: # Apply fancy indices (e.g., array/list or bool mask) to # lists. # See https://numpy.org/doc/stable/release/1.20.0-notes.html # #arraylike-objects-which-do-not-define-len-and-getitem arr = np.empty(len(value), dtype=object) arr[:] = list(value) val = arr[index].tolist() newcls.__dict__[key] = val return newcls def __str__(self): cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>' with np.printoptions(threshold=25, edgeitems=5): fmt = [f'Length: {self.n_apertures}'] return f'{cls_name}\n' + '\n'.join(fmt) def __repr__(self): return self.__str__() def __len__(self): if self.isscalar: msg = f'Scalar {self.__class__.__name__!r} object has no len()' raise TypeError(msg) return self.n_apertures def __iter__(self): for item in range(len(self)): yield self.__getitem__(item) # Remove in 4.0 def __getattr__(self, name): return deprecated_getattr(self, name, _DEPRECATED_ATTRIBUTES, since='3.0', until='4.0') def __getattribute__(self, name): # Collapse the leading position axis of the public per-source # output attributes to a scalar when a single scalar aperture # position is input (e.g., ``CircularAperture((10, 20), r=5)``). # The scalar conversion is applied centrally here instead of # being repeated on each individual property (see ``_array`` for # the array-preserving accessor used by the table-building and # flag-decoding machinery). value = super().__getattribute__(name) if (not name.startswith('_') and name not in _SCALAR_EXCLUDE and isinstance(value, (np.ndarray, SkyCoord, list, tuple)) and self.isscalar): try: if len(value) == 1: return value[0] except TypeError: # value has no len pass return value def _array(self, name): """ Return the full (never scalar-collapsed) value for a public output attribute, regardless of whether the instance is scalar. This bypasses the scalar conversion performed in ``__getattribute__`` so that the table-building and flag-decoding machinery always operates on arrays. """ return object.__getattribute__(self, name) @lazyproperty def isscalar(self): """ Whether the instance is scalar (e.g., a single aperture position). """ return self._pixel_aperture.isscalar
[docs] def copy(self): """ Return a deep copy of this object. Returns ------- result : `ApertureStats` A deep copy of this object. """ return deepcopy(self)
@lazyproperty def _null_object(self): """ Return `None` values. """ return np.array([None] * self.n_apertures) @lazyproperty def _null_value(self): """ Return np.nan values. """ values = np.empty(self.n_apertures) values.fill(np.nan) return values @property def id(self): """ The aperture identification number(s). """ return self._ids @property @deprecated('3.1', alternative="the 'id' attribute", until='4.0') def ids(self): """ The aperture identification number(s). .. deprecated:: 3.1 Use the `id` attribute instead. """ return self.id
[docs] def select_id(self, id_num): """ Return a new `ApertureStats` object for the input ID number only. Parameters ---------- id_num : int The aperture ID number. Returns ------- result : `ApertureStats` A new `ApertureStats` object containing only the source with the input ID number. """ return self.select_ids(id_num)
[docs] def select_ids(self, id_nums): """ Return a new `ApertureStats` object for the input ID numbers only. Parameters ---------- id_nums : list, tuple, or `~numpy.ndarray` of int The aperture ID number(s). Returns ------- result : `ApertureStats` A new `ApertureStats` object containing only the sources with the input ID numbers. """ for id_num in np.atleast_1d(id_nums): if id_num not in self._array('id'): msg = f'{id_num} is not a valid source ID number' raise ValueError(msg) sorter = np.argsort(self.id) indices = sorter[np.searchsorted(self.id, id_nums, sorter=sorter)] return self[indices]
[docs] @deprecated_positional_kwargs(since='3.0', until='4.0') def to_table(self, *, columns=None): """ Create a `~astropy.table.QTable` of source properties. Parameters ---------- columns : str, list of str, `None`, optional Names of columns, in order, to include in the output `~astropy.table.QTable`. The allowed column names are any of the `ApertureStats` properties. If ``columns`` is `None`, then a default list of scalar-valued properties (as defined by the ``default_columns`` attribute) will be used. Returns ------- table : `~astropy.table.QTable` A table of sources properties with one row per source. Raises ------ ValueError If any name in ``columns`` is not a valid (or deprecated) column name. """ if columns is None: table_columns = self.default_columns else: # id is not included in self.properties because it is not # a lazyproperty allowed_columns = set(self.properties) | set(self.default_columns) # Remove 2D cutout images from the allowed columns allowed_columns = {col for col in allowed_columns if '_cutout' not in col} deprecated_names = _DEPRECATED_ATTRIBUTES.copy() # These are not valid column names, but are deprecated # attributes that are still accessible in 3.x. They will be # removed in 4.0. invalid = ('data_sumcutout', 'error_sumcutout', 'get_id', 'get_ids') for name in invalid: deprecated_names.pop(name) table_columns = validate_table_columns( columns, allowed_columns, deprecated_names=deprecated_names) # Replace with QTable in 4.0 tbl = create_empty_deprecated_qtable( _DEPRECATED_ATTRIBUTES, since='3.0', until='4.0') tbl.meta.update(self.meta) # keep tbl.meta type # Evaluate the flag columns last (while preserving the # requested column order in the output table). The ``flags`` # column reports the ``'singular_covariance'`` bit only if a # covariance-derived shape property has already been computed # (see `flags`). Evaluating the flag columns after all other # columns ensures that any shape columns present in the same # table are computed first, so the table's ``flags`` column # consistently reflects the other columns it is shown alongside. flag_columns = {'flags', 'sum_flags'} eval_order = ([col for col in table_columns if col not in flag_columns] + [col for col in table_columns if col in flag_columns]) values_map = {} for column in eval_order: values = getattr(self, column) # Column assignment requires an object with a length if self.isscalar: values = (values,) values_map[column] = values for column in table_columns: # Use the canonical (non-deprecated) column name so that # assigning into ``tbl`` does not trigger a second, # redundant deprecation warning (the deprecated attribute # access above already warned once). canonical_column = _DEPRECATED_ATTRIBUTES.get(column, column) tbl[canonical_column] = values_map[column] return tbl
@lazyproperty def n_apertures(self): """ The number of positions for the input aperture. """ if self.isscalar: return 1 return len(self._pixel_aperture) @lazyproperty def _pixel_aperture(self): """ The input aperture as a PixelAperture. """ if isinstance(self.aperture, SkyAperture): return self.aperture.to_pixel(self._wcs) return self.aperture @lazyproperty def _batch_inputs(self): """ The validated inputs for the fast Cython batch driver, or `None`. Returns a tuple of the contiguous arrays and scalar parameters shared by the center-value and ``sum_method`` gathers (see `_fast_gather` and `_fast_sum`), together with the fast sigma-clip specification (`None` when no clipping is performed). `None` is returned (and the mask-based code path is used) when the aperture or inputs are not supported by the batch driver, for example when an unsupported ``sigma_clip`` is used, the aperture does not opt in to the batch driver, or the data/mask dtypes are not supported. The set of supported inputs matches `~photutils.aperture.PixelAperture._batch_photometry`. """ # A non-None sigma_clip is only supported when it maps to the # fast clipping kernel; otherwise fall back to the mask path. clip_spec = self._fast_clip_spec() if self.sigma_clip is not None and clip_spec is None: return None aper = self._pixel_aperture # Use the batch driver only if the aperture's own class defines # the _batch_shape_params hook (see _batch_photometry). if '_batch_shape_params' not in type(aper).__dict__: return None spec = aper._batch_shape_params() if spec is None: return None data = self._data error = self._error def _supported(arr): return (type(arr) is np.ndarray and arr.dtype.kind in 'fiub' and arr.dtype.itemsize <= 8) if not _supported(data) or (error is not None and not _supported(error)): return None mask = self._mask if mask is not None and (not isinstance(mask, np.ndarray) or mask.dtype != bool or mask.shape != data.shape): return None # Fold non-finite ``data`` values into the mask plane so the # batch kernels can skip the per-pixel finiteness test (and # defer the pixel-value load to contributing pixels only, like # the ``photometry`` method). This matches the mask-based # path, which masks non-finite data before any segmentation # correction. The plane distinguishes the two causes for the # flag counts: bit 1 (value 1) marks input-masked pixels and bit # 2 (value 2) marks non-finite data pixels. Any nonzero value # excludes the pixel. plane = None if mask is not None: plane = mask.astype(np.uint8) if data.dtype.kind == 'f': nonfinite = ~np.isfinite(data) if nonfinite.any(): if plane is None: plane = np.zeros(data.shape, dtype=np.uint8) plane[nonfinite] = 2 else: plane[nonfinite & (plane == 0)] = 2 mask = plane if mask is not None: mask = np.ascontiguousarray(mask) seg_arr = None labels_arr = None seg_code = 0 if self._segmentation is not None and self._mask_method != 'none': seg_arr = np.ascontiguousarray(self._segmentation, dtype=np.intp) labels_arr = np.ascontiguousarray(self._seg_labels, dtype=np.intp) seg_code = SEG_METHOD_CODES[self._mask_method] shape_code, params = spec sum_use_exact, sum_subpixels = aper._translate_mask_method( self.sum_method, self.subpixels) ext_x, ext_y = aper._xy_extents if error is not None: error = np.ascontiguousarray(error, dtype=np.float64) return (np.ascontiguousarray(data, dtype=np.float64), error, mask, np.ascontiguousarray(aper._positions, dtype=np.float64), shape_code, np.array(params, dtype=np.float64), float(ext_x), float(ext_y), sum_use_exact, sum_subpixels, np.ascontiguousarray(self._local_bkg, dtype=np.float64), seg_arr, labels_arr, seg_code, clip_spec) @lazyproperty def _fast_gather(self): """ The fast Cython "center"-method value gather, or `None`. When supported, this walks each aperture bounding box once and returns a packed buffer of the unmasked "center"-method pixel values (and their cutout coordinates). The higher-tier statistics (order statistics and image moments) are reduced lazily from this buffer. The ``sum_method`` aperture sum, error variance, and area are computed separately and lazily by `_fast_sum`, so they are not paid for unless requested. When a supported ``sigma_clip`` is set, the packed center buffer is sigma-clipped per source before being returned, so the downstream reductions operate on the clipped data transparently. `None` is returned (and the mask-based code path is used) when the fast batch driver is unavailable (see `_batch_inputs`). The result is a tuple ``(values, local_x, local_y, starts, counts, sum_aper, var_aper, sum_area, overlap, ...)`` (a 14-tuple shared with `_fast_sum`, whose last entry is the per-source flag-count array). Only the center-value entries (indices 0-4), ``overlap`` (index 8), and the flag counts (index 13) are populated here. The ``sum_method`` entries are `None`. """ inputs = self._batch_inputs if inputs is None: return None (data, _error, mask, positions, shape_code, params, ext_x, ext_y, _sum_use_exact, _sum_subpixels, local_bkg, seg_arr, labels_arr, seg_code, clip_spec) = inputs (values, lx, ly, starts, counts, overlap, flag_counts) = batch_aperture_gather( data, mask, positions, shape_code, params, ext_x, ext_y, local_bkg, seg_arr, labels_arr, seg_code) gather = (values, lx, ly, starts, counts, None, None, None, overlap, None, None, None, None, flag_counts) if clip_spec is not None: gather = self._apply_center_clip(gather, clip_spec) return gather @lazyproperty def _fast_sum(self): """ The fast Cython ``sum_method`` aperture gather, or `None`. When supported, this walks each aperture bounding box once and returns the ``sum_method`` aperture sum, error variance, and area. This is computed only when the ``sum``, ``sum_err``, or ``sum_aper_area`` properties are requested, so the more expensive exact/subpixel overlap fractions are not evaluated for the value-statistics-only use cases. When a supported ``sigma_clip`` is set, the packed ``sum_method`` members are sigma-clipped per source and the aperture sum, variance, and area are recomputed over the survivors. `None` is returned (and the mask-based code path is used) when the fast batch driver is unavailable (see `_batch_inputs`). The result is a tuple with the same layout as `_fast_gather`; only the ``sum_aper`` (index 5), ``var_aper`` (index 6), ``sum_area`` (index 7), and ``overlap`` (index 8) entries are populated. """ inputs = self._batch_inputs if inputs is None: return None (data, error, mask, positions, shape_code, params, ext_x, ext_y, sum_use_exact, sum_subpixels, local_bkg, seg_arr, labels_arr, seg_code, clip_spec) = inputs emit_sum = 1 if clip_spec is not None else 0 (sums, sum_var, area, overlap, starts, sum_values, sum_fracs, sum_errsq, scounts, flag_counts) = batch_aperture_sums( data, error, mask, positions, shape_code, params, ext_x, ext_y, sum_use_exact, sum_subpixels, seg_arr, labels_arr, seg_code, local_bkg, emit_sum) gather = (None, None, None, starts, None, sums, sum_var, area, overlap, sum_values, sum_fracs, sum_errsq, scounts, flag_counts) if clip_spec is not None: gather = self._apply_sum_clip(gather, clip_spec) return gather def _fast_clip_spec(self): """ The fast sigma-clip parameters, or `None`. Returns ``(sigma_lower, sigma_upper, maxiters, cenfunc_code, stdfunc_code)`` when ``sigma_clip`` is a `SigmaClip` instance supported by the fast clipping kernel, and `None` otherwise (in which case the mask-based path is used). The fast path supports the string ``cenfunc`` values 'median'/'mean', the string ``stdfunc`` values 'std'/'mad_std', and no spatial growing. """ sc = self.sigma_clip if sc is None or not isinstance(sc, SigmaClip): return None if not (isinstance(sc.cenfunc, str) and sc.cenfunc in ('median', 'mean')): return None if not (isinstance(sc.stdfunc, str) and sc.stdfunc in ('std', 'mad_std')): return None if sc.grow: # False or 0 is supported; spatial growing is not return None maxiters = sc.maxiters if maxiters is None or maxiters == np.inf: maxiters = -1 else: maxiters = int(maxiters) cenfunc_code = 0 if sc.cenfunc == 'median' else 1 stdfunc_code = 0 if sc.stdfunc == 'std' else 1 return (float(sc.sigma_lower), float(sc.sigma_upper), maxiters, cenfunc_code, stdfunc_code) def _apply_center_clip(self, gather, clip_spec): """ Sigma-clip the packed center buffer and return a gather tuple with the same layout whose center buffer reflects the per-source clipped data. """ sigma_lower, sigma_upper, maxiters, cenfunc, stdfunc = clip_spec (values, local_x, local_y, starts, counts, _sum, _var, _area, overlap, _sv, _sf, _se, _sc, flag_counts) = gather (cvalues, clx, cly, cstarts, ccounts) = batch_sigma_clip_center( values, local_x, local_y, starts, counts, sigma_lower, sigma_upper, maxiters, cenfunc, stdfunc) return (cvalues, clx, cly, cstarts, ccounts, _sum, _var, _area, overlap, None, None, None, None, flag_counts) def _apply_sum_clip(self, gather, clip_spec): """ Sigma-clip the packed ``sum_method`` member buffers and return a gather tuple with the same layout whose aperture sum, variance, and area reflect the per-source clipped data. """ sigma_lower, sigma_upper, maxiters, cenfunc, stdfunc = clip_spec (values, local_x, local_y, starts, counts, _sum, _var, _area, overlap, sum_values, sum_fracs, sum_errsq, sum_counts, flag_counts) = gather has_error = 1 if self._error is not None else 0 (csum, cvar, carea) = batch_sigma_clip_sum( sum_values, sum_fracs, sum_errsq, starts, sum_counts, sigma_lower, sigma_upper, maxiters, cenfunc, stdfunc, has_error) return (values, local_x, local_y, starts, counts, csum, cvar, carea, overlap, None, None, None, None, flag_counts) @lazyproperty def _sorted_values(self): """ The packed per-source ascending-sorted center pixel values, or `None`. When the fast gather path is active (see `_fast_gather`), each source's packed pixel values are sorted once. The sorted buffer is cached and shared by the order statistics (``min``, ``max``, ``median``), ``mad_std``, and the biweight estimators, so the per-source sort is performed only once. `None` is returned when the fast path is unavailable. """ gather = self._fast_gather if gather is None: return None values, starts, counts = gather[0], gather[3], gather[4] return (batch_sort_values(values, starts, counts), starts, counts) @lazyproperty def _order_stats(self): """ The per-source ``(min, max, median)`` arrays, or `None`. Reduced from the cached sorted buffer (see `_sorted_values`). `None` when the fast path is unavailable. """ sorted_values = self._sorted_values if sorted_values is None: return None return batch_order_stats(*sorted_values) @lazyproperty def _mean_var(self): """ The per-source ``(mean, var)`` arrays, or `None`. The population (``ddof=0``) variance is returned. Computed directly from the packed center buffer (no sort required). `None` when the fast path is unavailable. """ gather = self._fast_gather if gather is None: return None values, starts, counts = gather[0], gather[3], gather[4] return batch_mean_var(values, starts, counts) @lazyproperty def _mad(self): """ The per-source unscaled median absolute deviation, or `None`. Reduced from the cached sorted buffer (see `_sorted_values`). `None` when the fast path is unavailable. """ sorted_values = self._sorted_values if sorted_values is None: return None return batch_mad(*sorted_values) @lazyproperty def _biweight(self): """ The per-source ``(biweight_location, biweight_midvariance)`` arrays, or `None`. Reused from the cached sorted buffer, median (`_order_stats`), and unscaled MAD (`_mad`). `None` when the fast path is unavailable. """ sorted_values = self._sorted_values if sorted_values is None: return None _, _, median = self._order_stats return batch_biweight(*sorted_values, median, self._mad) @lazyproperty def _gini(self): """ The per-source Gini coefficient, or `None`. Computed from the packed center buffer (the absolute values are sorted internally). `None` when the fast path is unavailable. """ gather = self._fast_gather if gather is None: return None values, starts, counts = gather[0], gather[3], gather[4] return batch_gini(values, starts, counts) def _finalize_value_stat(self, fast_result, stat_func, *, square_unit=False, apply_unit=True): """ Return a per-source value statistic, using the fast Cython reduction when available and otherwise the mask-based per-source code path. Parameters ---------- fast_result : `~numpy.ndarray` or `None` The per-source statistic from the fast reduction, or `None` to use the mask-based fallback. stat_func : callable The fallback callable applied to each source's 1D array of unmasked pixel values when the fast path is unavailable. square_unit : bool, optional Whether the statistic has squared data units (e.g. variance). apply_unit : bool, optional Whether to apply the data unit at all (`False` for dimensionless statistics such as the Gini coefficient). """ if fast_result is not None: result = fast_result.copy() else: result = np.array([stat_func(arr) for arr in self._data_values_center]) if apply_unit: unit = self._data_unit if unit is not None: if square_unit: unit = unit**2 result <<= unit return result @lazyproperty def _aperture_masks_center(self): """ The aperture masks (`ApertureMask`) generated with the 'center' method, always as an iterable. """ aperture_masks = self._pixel_aperture.to_mask(method='center') if self.isscalar: aperture_masks = (aperture_masks,) return aperture_masks @lazyproperty def _aperture_masks(self): """ The aperture masks (`ApertureMask`) generated with the ``sum_method`` method, always as an iterable. """ aperture_masks = self._pixel_aperture.to_mask(method=self.sum_method, subpixels=self.subpixels) if self.isscalar: aperture_masks = (aperture_masks,) return aperture_masks @lazyproperty def _overlap_slices(self): """ The aperture mask overlap slices with the data, always as an iterable. The overlap slices are the same for all aperture mask methods. """ overlap_slices = [] for apermask in self._aperture_masks_center: (slc_large, slc_small) = apermask.get_overlap_slices( self._data.shape) overlap_slices.append((slc_large, slc_small)) return overlap_slices @lazyproperty def _data_cutouts(self): """ The local-background-subtracted unmasked data cutouts using the aperture bounding box, always as an iterable. """ cutouts = [] for (slices, local_bkg) in zip(self._overlap_slices, self._local_bkg, strict=True): if slices[0] is None: cutout = None # no aperture overlap with the data else: # Copy is needed to preserve input data because masks are # applied to these cutouts later cutout = (self._data[slices[0]].astype(float, copy=True) - local_bkg) cutouts.append(cutout) return cutouts def _make_aperture_cutouts(self, aperture_masks, *, count_clipped=True): """ Make aperture-weighted cutouts for the data and variance, and cutouts for the total mask and aperture mask weights. Parameters ---------- aperture_masks : list of `ApertureMask` A list of `ApertureMask` objects. count_clipped : bool, optional Whether to count the number of sigma-clipped pixels per source. Only `_footprint_flag_inputs` for the "center" footprint uses this count, so it is skipped (left at 0) for the ``sum_method`` footprint to avoid the wasted computation. Returns ------- result : list of `~numpy.ndarray` A list of cutout arrays for the data, variance, mask and weight arrays for each source (aperture position), followed by the overlap indicator, per-source flag counts (see the ``FLAG_COL_*`` constants in `photutils.aperture._batch_photometry`, with semantics identical to the batch drivers), and the number of sigma-clipped pixels (always 0 when ``count_clipped`` is `False`). """ data_cutouts = [] variance_cutouts = [] mask_cutouts = [] weight_cutouts = [] overlaps = [] flag_counts = [] n_clipped = [] positions = np.atleast_2d(self._pixel_aperture.positions) for idx, (data_cutout, apermask, slices) in enumerate( zip(self._data_cutouts, aperture_masks, self._overlap_slices, strict=True)): fc_row = np.zeros(8, dtype=np.intp) n_clipped_ = 0 slc_large, slc_small = slices if slc_large is None: # aperture does not overlap the data overlap = False data_cutout = np.array([np.nan]) variance_cutout = np.array([np.nan]) mask_cutout = np.array([False]) weight_cutout = np.array([np.nan]) else: # Create a mask of non-finite ``data`` values combined # with the input ``mask`` array nonfinite_mask = ~np.isfinite(data_cutout) if self._mask is not None: user_mask = self._mask[slc_large] data_mask = nonfinite_mask | user_mask else: user_mask = None data_mask = nonfinite_mask error_cutout = (None if self._error is None else self._error[slc_large]) # Apply segmentation-based masking and/or symmetric # neighbor correction exclude = None affected = None if (self._segmentation is not None and self._mask_method != 'none'): segm_cutout = self._segmentation[slc_large] cutout_xycen = (positions[idx, 0] - slc_large[1].start, positions[idx, 1] - slc_large[0].start) (data_cutout, error_cutout, exclude, affected) = make_segmentation_exclusion( self._mask_method, segm_cutout, self._seg_labels[idx], data=data_cutout, error=error_cutout, base_mask=data_mask, cutout_xycen=cutout_xycen) pre_seg_mask = data_mask data_mask = data_mask | exclude overlap = True aperweight_cutout = apermask.data[slc_small] weight_cutout = aperweight_cutout * ~data_mask # Per-source pixel counts for the quality flags, # matching the batch-driver semantics (computed before # any sigma clipping) weighted = aperweight_cutout > 0 fc_row[FLAG_COL_NPIX] = np.count_nonzero(weighted) fc_row[FLAG_COL_BBOX_CLIPPED] = ( aperweight_cutout.shape != apermask.data.shape) if user_mask is not None: fc_row[FLAG_COL_MASKED] = np.count_nonzero( weighted & user_mask) fc_row[FLAG_COL_NONFINITE_DATA] = np.count_nonzero( weighted & nonfinite_mask & ~user_mask) else: fc_row[FLAG_COL_NONFINITE_DATA] = np.count_nonzero( weighted & nonfinite_mask) if affected is not None: pre_valid = weighted & ~pre_seg_mask fc_row[FLAG_COL_SEG] = np.count_nonzero( affected & pre_valid) if self._mask_method == 'correct': # In 'correct' mode, the excluded pixels are # exactly the uncorrectable neighbor pixels fc_row[FLAG_COL_UNCORRECTED] = np.count_nonzero( exclude & pre_valid) fc_row[FLAG_COL_VALID] = np.count_nonzero( weighted & ~data_mask) if error_cutout is not None: fc_row[FLAG_COL_NONFINITE_ERROR] = np.any( ~np.isfinite(error_cutout) & weighted & ~data_mask) # Apply the aperture mask; for "exact" and "subpixel" # this is an expanded boolean mask using the aperture # mask zero values mask_cutout = (aperweight_cutout == 0) | data_mask data_cutout = data_cutout.copy() if self.sigma_clip is None: # data_cutout will have zeros where mask_cutout is True data_cutout *= ~mask_cutout else: # To input a mask, SigmaClip needs a MaskedArray data_cutout_ma = np.ma.masked_array(data_cutout, mask=mask_cutout) data_sigclip = self.sigma_clip(data_cutout_ma) # Define a mask of only the sigma-clipped pixels sigclip_mask = data_sigclip.mask & ~mask_cutout if count_clipped: n_clipped_ = np.count_nonzero(sigclip_mask) weight_cutout *= ~sigclip_mask mask_cutout = data_sigclip.mask data_cutout = data_sigclip.filled(0.0) # Need to apply the aperture weights data_cutout *= aperweight_cutout if self._error is None: variance_cutout = None else: # Apply the exact weights and total mask; # error_cutout will have zeros where mask_cutout is True variance = error_cutout**2 variance_cutout = (variance * aperweight_cutout * ~mask_cutout) data_cutouts.append(data_cutout) variance_cutouts.append(variance_cutout) mask_cutouts.append(mask_cutout) weight_cutouts.append(weight_cutout) overlaps.append(overlap) flag_counts.append(fc_row) n_clipped.append(n_clipped_) # Use zip (instead of np.transpose) because these may contain # arrays that have different shapes return list(zip(data_cutouts, variance_cutouts, mask_cutouts, weight_cutouts, overlaps, flag_counts, n_clipped, strict=True)) @lazyproperty def _aperture_cutouts_center(self): """ Aperture-weighted cutouts for the data, variance, total mask, and aperture weights using the "center" aperture mask method. """ return self._make_aperture_cutouts(self._aperture_masks_center) @lazyproperty def _aperture_cutouts(self): """ Aperture-weighted cutouts for the data, variance, total mask, and aperture weights using the input ``sum_method`` aperture mask method. """ # The sigma-clipped pixel count is not tracked for this # footprint: only the "center" footprint's flags use it (see # `_footprint_flag_inputs`), so counting it here would be wasted # computation. return self._make_aperture_cutouts(self._aperture_masks, count_clipped=False) @lazyproperty def _mask_cutout_center(self): """ Boolean mask cutouts representing the total mask. The total mask is combination of the input ``mask``, non-finite ``data`` values, the cutout aperture mask using the "center" method, and the sigma-clip mask. """ return list(zip(*self._aperture_cutouts_center, strict=True))[2] @lazyproperty def _mask_cutout(self): """ Boolean mask cutouts representing the total mask. The total mask is combination of the input ``mask``, non-finite ``data`` values, the cutout aperture mask using the ``sum_method`` method, and the sigma-clip mask. """ return list(zip(*self._aperture_cutouts, strict=True))[2] def _make_masked_array_center(self, array): """ Return a list of cutout masked arrays using the ``_mask_cutout`` mask. Units are not applied. """ return [np.ma.masked_array(arr, mask=mask) for arr, mask in zip(array, self._mask_cutout_center, strict=True)] def _make_masked_array(self, array): """ Return a list of cutout masked arrays using the ``_mask_sumcutout`` mask. Units are not applied. """ return [np.ma.masked_array(arr, mask=mask) for arr, mask in zip(array, self._mask_cutout, strict=True)] @lazyproperty def data_cutout(self): """ A 2D aperture-weighted cutout from the data using the aperture mask with the "center" method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ return self._make_masked_array_center( list(zip(*self._aperture_cutouts_center, strict=True))[0]) @lazyproperty def data_sum_cutout(self): """ A 2D aperture-weighted cutout from the data using the aperture mask with the input ``sum_method`` method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ return self._make_masked_array(list(zip(*self._aperture_cutouts, strict=True))[0]) @lazyproperty def _variance_cutout_center(self): """ A 2D aperture-weighted variance cutout using the aperture mask with the input "center" method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ if self._error is None: return self._null_object return self._make_masked_array_center( list(zip(*self._aperture_cutouts_center, strict=True))[1]) @lazyproperty def _variance_cutout(self): """ A 2D aperture-weighted variance cutout using the aperture mask with the input ``sum_method`` method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ if self._error is None: return self._null_object return self._make_masked_array(list(zip(*self._aperture_cutouts, strict=True))[1]) @lazyproperty def error_sum_cutout(self): """ A 2D aperture-weighted error cutout using the aperture mask with the input ``sum_method`` method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ if self._error is None: return self._null_object return [np.sqrt(var) for var in self._variance_cutout] @lazyproperty def _weight_cutout_center(self): """ A 2D `~numpy.ma.MaskedArray` cutout from the aperture mask weights array using the aperture bounding box. The aperture mask weights are for the "center" method. The mask is `True` for pixels outside the aperture mask, pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), and sigma-clipped pixels. """ return self._make_masked_array_center( list(zip(*self._aperture_cutouts_center, strict=True))[3]) @lazyproperty def _weight_cutout(self): """ A 2D `~numpy.ma.MaskedArray` cutout from the aperture mask weights array using the aperture bounding box. The aperture mask weights are for the ``sum_method`` method. The mask is `True` for pixels outside the aperture mask, pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), and sigma-clipped pixels. """ return self._make_masked_array(list(zip(*self._aperture_cutouts, strict=True))[3]) @lazyproperty def _moment_data_cutout(self): """ A list of 2D `~numpy.ndarray` cutouts from the data. Masked pixels are set to zero in these arrays (zeros do not contribute to the image moments). The aperture mask weights are for the "center" method. These arrays are used to derive moment-based properties. """ data = deepcopy(self._array('data_cutout')) cutouts = [] for arr in data: if arr.size == 1 and np.isnan(arr[0]): # no aperture overlap arr_ = np.empty((2, 2)) arr_.fill(np.nan) else: arr_ = arr.data arr_[arr.mask] = 0.0 cutouts.append(arr_) return cutouts @lazyproperty def _all_masked(self): """ True if all pixels within the aperture are masked. """ return np.array([np.all(mask) for mask in self._mask_cutout_center]) @lazyproperty def _overlap(self): """ True if there is no overlap of the aperture with the data. """ return list(zip(*self._aperture_cutouts_center, strict=True))[4] def _footprint_flag_inputs(self, footprint): """ The per-source flag inputs for the given aperture footprint. Parameters ---------- footprint : {'center', 'sum'} The aperture footprint: the "center" mask method used by the value statistics, or the ``sum_method`` mask method used by the sum properties. Returns ------- flag_counts : `~numpy.ndarray` The per-source flag counts (see the ``FLAG_COL_*`` constants in `photutils.aperture._batch_photometry`). overlap : `~numpy.ndarray` (bool) Whether the aperture bounding box overlaps the data. n_kept : `~numpy.ndarray` or `None` For the center footprint, the per-source number of valid pixels remaining after sigma clipping; `None` for the sum footprint. """ if footprint == 'center': gather = self._fast_gather if gather is not None: return (gather[13], np.asarray(gather[8]), np.asarray(gather[4])) cutouts = self._aperture_cutouts_center else: gather = self._fast_sum if gather is not None: return gather[13], np.asarray(gather[8]), None cutouts = self._aperture_cutouts flag_counts = np.array([cut[5] for cut in cutouts]) overlap = np.array([cut[4] for cut in cutouts]) n_kept = None if footprint == 'center': n_clipped = np.array([cut[6] for cut in cutouts]) n_kept = flag_counts[:, FLAG_COL_VALID] - n_clipped return flag_counts, overlap, n_kept def _footprint_flags(self, footprint): """ The per-source flag bits for the given aperture footprint. The bounding-box-clipped candidate sources are resolved to the precise outside-weight test using per-source aperture masks. """ flag_counts, overlap, _ = self._footprint_flag_inputs(footprint) if footprint == 'center': method, subpixels = 'center', 1 else: method, subpixels = self.sum_method, self.subpixels candidates = flag_counts[:, FLAG_COL_BBOX_CLIPPED].astype(bool) w_out = self._pixel_aperture._resolve_outside_weights( self._data.shape, method=method, subpixels=subpixels, candidates=candidates) return _counts_to_flag_bits(flag_counts, overlap, w_out) @lazyproperty def _base_flags(self): """ The count-based value-statistics flag bits (1D int array). These are the flag bits that do not depend on any lazily computed covariance property: the "center"-method footprint bits plus the sigma-clip and ``ddof`` bits. The ``singular_covariance`` bit is folded in by the `flags` property only if a covariance-derived property has been computed. """ # The gather kernel and the center-method cutouts do not # evaluate error values, so the non-finite-error bit is defined # by the sum footprint only (see `sum_flags`). flags = (self._footprint_flags('center') & ~APERTURE_FLAGS.NON_FINITE_ERROR) flag_counts, _, n_kept = self._footprint_flag_inputs('center') n_valid = flag_counts[:, FLAG_COL_VALID] if self.sigma_clip is not None: n_clipped = n_valid - n_kept flags[n_clipped > 0] |= APERTURE_FLAGS.SIGMA_CLIPPED flags[(n_valid > 0) & (n_kept == 0)] |= APERTURE_FLAGS.ALL_CLIPPED if self.ddof > 0: w_in = flag_counts[:, FLAG_COL_NPIX] flags[(w_in > 0) & (n_kept <= self.ddof)] |= APERTURE_FLAGS.TOO_FEW_PIXELS return flags @lazyproperty def _singular_covariance_mask(self): """ Boolean mask (1D) marking sources with a singular or nearly singular covariance matrix. A source is flagged when the minor-axis variance (the smaller eigenvalue of its normalized second-moment covariance matrix) falls below ``1/12``. This flags both unresolved, nearly point-like sources, where the covariance determinant drops below ``(1/12)**2``, and rank-1 degenerate sources, where one axis is unresolved while the other is extended (which the determinant alone would miss). Sources with undefined moments (no overlap or fully masked) have a non-finite determinant and are not flagged here. They are already reported by the overlap and masking bits. """ covar = self._raw_covariance with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) covar_det = np.linalg.det(covar) # Smaller eigenvalue (the minor-axis variance) of each 2x2 # symmetric covariance matrix, via the closed form # lambda = tr/2 -/+ sqrt((tr/2)**2 - det). The discriminant # ((lambda1 - lambda2)/2)**2 is non-negative for a real # symmetric matrix; clip tiny negative rounding to zero. half_trace = 0.5 * (covar[:, 0, 0] + covar[:, 1, 1]) disc = np.maximum(half_trace**2 - covar_det, 0.0) min_eigval = half_trace - np.sqrt(disc) # ``1/12`` is the variance of a uniform distribution across a # single pixel, and hence the smallest second moment a resolved # source can have given finite pixel size. The determinant test # (det < (1/12)**2) flags the isotropic case where both axes are # unresolved; the eigenvalue test (min_eigval < 1/12) # additionally flags rank-1 degeneracy (one unresolved axis) and # covariance matrices that are not positive semidefinite # (det < 0). delta = 1.0 / 12 finite = np.isfinite(covar_det) & np.isfinite(min_eigval) return finite & ((covar_det < delta**2) | (min_eigval < delta))
[docs] @_UncachedLazyProperty @_update_method_subpixels_docstring def flags(self): # numpydoc ignore: RT01 """ The bitwise quality flags for the value statistics. The flags are evaluated on the "center"-method footprint used by the value statistics (e.g., ``mean``, ``median``, ``std``). The sum properties (``sum``, ``sum_err``, and ``sum_aper_area``) have their own separate `sum_flags`. The ``'sigma_clipped'``, ``'all_clipped'``, and ``'too_few_pixels'`` flags are evaluated on this footprint. To check for any quality issue across both footprints, combine the two flag columns with a bitwise OR (e.g., ``flags | sum_flags``). The ``'singular_covariance'`` bit is special. It reports whether a source's covariance matrix is singular or nearly singular, a condition that is only knowable once a covariance-derived shape property (e.g., ``semimajor_axis``, ``orientation``, ``eccentricity``) has been computed. To avoid forcing that computation, the bit is included only if such a property has already been evaluated on this object; otherwise it is omitted. This means the value of ``flags`` reflects the measurements requested so far, so accessing a shape property and then re-reading ``flags`` may set additional bits. The default `to_table` always evaluates the shape properties, so its ``flags`` column always reflects the ``'singular_covariance'`` bit. See `~photutils.aperture.decode_aperture_flags` for decoding flag values. The flags are: <flag_descriptions> """ # A fresh array is returned on every access (never mutating the # cached `_base_flags`), so concurrent readers and previously # returned arrays are never modified in place. flags = self._base_flags.copy() if '_covariance' in self.__dict__: flags[self._singular_covariance_mask] |= ( APERTURE_FLAGS.SINGULAR_COVARIANCE) return flags
@lazyproperty @_update_method_subpixels_docstring def sum_flags(self): # numpydoc ignore: RT01 """ The bitwise quality flags for the sum properties. The flags are evaluated on the ``sum_method`` footprint used by the sum properties (``sum``, ``sum_err``, and ``sum_aper_area``). The value statistics have their own separate `flags`. The ``'non_finite_error'`` flag is evaluated on this footprint. The ``'sigma_clipped'``, ``'all_clipped'``, and ``'too_few_pixels'`` flags apply only to the value statistics and are never set here. To check for any quality issue across both footprints, combine the two flag columns with a bitwise OR (e.g., ``flags | sum_flags``). See `~photutils.aperture.decode_aperture_flags` for decoding flag values. The flags are: <flag_descriptions> """ return self._footprint_flags('sum')
[docs] def decode_flags(self, *, column='flags', return_bit_values=False): """ Decode the source quality flags into individual components. This is a convenience method that calls `~photutils.aperture.decode_aperture_flags` with the `flags` or `sum_flags` property. Parameters ---------- column : {'flags', 'sum_flags'}, optional Which quality flags to decode: ``'flags'`` for the value statistics (default) or ``'sum_flags'`` for the sum properties. return_bit_values : bool, optional If `True`, return the decoded bit flags (integers) instead of the flag names (strings). Returns ------- decoded : list of list of str or list of list of int A list of the active flag names (or bit values) for each source. See Also -------- photutils.aperture.decode_aperture_flags Examples -------- >>> import numpy as np >>> from photutils.aperture import ApertureStats, CircularAperture >>> data = np.ones((25, 25)) >>> mask = np.zeros(data.shape, dtype=bool) >>> mask[12, 12] = True >>> aper = CircularAperture([(12.0, 12.0), (0.0, 12.0)], r=3.0) >>> aperstats = ApertureStats(data, aper, mask=mask) >>> for names in aperstats.decode_flags(): ... print(names) ['masked_pixels'] ['partial_overlap'] """ if column not in ('flags', 'sum_flags'): msg = "column must be 'flags' or 'sum_flags'" raise ValueError(msg) return decode_aperture_flags(self._array(column), return_bit_values=return_bit_values)
def _get_values(self, array): """ Get a 1D array of unmasked aperture-weighted values from the input array. An array with a single NaN is returned for completely-masked sources. """ if self.isscalar: array = (array,) return [arr.compressed() if len(arr.compressed()) > 0 else np.array([np.nan]) for arr in array] @lazyproperty def _data_values_center(self): """ A 1D array of unmasked aperture-weighted data values using the "center" method. An array with a single NaN is returned for completely-masked sources. """ return self._get_values(self.data_cutout) @lazyproperty def moments(self): """ Spatial moments up to 3rd order of the source. """ gather = self._fast_gather if gather is not None: values, lx, ly, starts, counts = gather[:5] overlap = gather[8] zeros = np.zeros(self.n_apertures) mom = batch_moments(values, lx, ly, starts, counts, zeros, zeros) # No-overlap sources have NaN moments (the mask-based path # uses an all-NaN cutout); all-masked overlapping sources # have zero moments (an all-zero cutout). mom[~overlap] = np.nan return mom return np.array([_image_moments(arr, order=3) for arr in self._moment_data_cutout]) @lazyproperty def moments_central(self): """ Central moments (translation invariant) of the source up to 3rd order. """ cutout_centroid = self._array('cutout_centroid') gather = self._fast_gather if gather is not None: values, lx, ly, starts, counts = gather[:5] cen_x = np.ascontiguousarray(cutout_centroid[:, 0]) cen_y = np.ascontiguousarray(cutout_centroid[:, 1]) mom = batch_moments(values, lx, ly, starts, counts, cen_x, cen_y) # Empty sources (no overlap or fully masked) have a NaN # centroid, so their central moments are NaN (matching the # mask-based path). mom[counts == 0] = np.nan return mom return np.array([_image_moments(arr, center=(xcen_, ycen_), order=3) for arr, xcen_, ycen_ in zip(self._moment_data_cutout, cutout_centroid[:, 0], cutout_centroid[:, 1], strict=True)]) @lazyproperty def cutout_centroid(self): """ The ``(x, y)`` coordinate, relative to the cutout data, of the centroid within the aperture. The centroid is computed as the center of mass of the unmasked pixels within the aperture. """ moments = self._array('moments') # Ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) y_centroid = moments[:, 1, 0] / moments[:, 0, 0] x_centroid = moments[:, 0, 1] / moments[:, 0, 0] return np.transpose((x_centroid, y_centroid)) @lazyproperty def centroid(self): """ The ``(x, y)`` coordinate of the centroid. The centroid is computed as the center of mass of the unmasked pixels within the aperture. """ origin = np.transpose((self.bbox_xmin, self.bbox_ymin)) return self.cutout_centroid + origin @lazyproperty def x_centroid(self): """ The ``x`` coordinate of the centroid. The centroid is computed as the center of mass of the unmasked pixels within the aperture. """ return np.transpose(self._array('centroid'))[0] @lazyproperty def y_centroid(self): """ The ``y`` coordinate of the centroid. The centroid is computed as the center of mass of the unmasked pixels within the aperture. """ return np.transpose(self._array('centroid'))[1] @lazyproperty def sky_centroid(self): """ The sky coordinate of the centroid of the unmasked pixels within the aperture, returned as a `~astropy.coordinates.SkyCoord` object. The output coordinate frame is the same as the input ``wcs``. `None` if ``wcs`` is not input. """ if self._wcs is None: return self._null_object return self._wcs.pixel_to_world(self.x_centroid, self.y_centroid) @lazyproperty def sky_centroid_icrs(self): """ The sky coordinate in the International Celestial Reference System (ICRS) frame of the centroid of the unmasked pixels within the aperture, returned as a `~astropy.coordinates.SkyCoord` object. `None` if ``wcs`` is not input. """ if self._wcs is None: return self._null_object return self.sky_centroid.icrs @lazyproperty def _bbox(self): """ The `~photutils.aperture.BoundingBox` of the aperture, always as an iterable. """ apertures = self._pixel_aperture if self.isscalar: apertures = (apertures,) return [aperture.bbox for aperture in apertures] @lazyproperty def bbox(self): """ The `~photutils.aperture.BoundingBox` of the aperture. Note that the aperture bounding box is calculated using the exact size of the aperture, which may be slightly larger than the aperture mask calculated using the "center" method. """ return self._bbox @lazyproperty def _bbox_bounds(self): """ The bounding box x and y minimum and maximum bounds. """ bbox = self._array('bbox') return np.array([(bbox_.ixmin, bbox_.ixmax - 1, bbox_.iymin, bbox_.iymax - 1) for bbox_ in bbox]) @lazyproperty def bbox_xmin(self): """ The minimum ``x``-pixel index of the bounding box. """ return np.transpose(self._bbox_bounds)[0] @lazyproperty def bbox_xmax(self): """ The maximum ``x``-pixel index of the bounding box. Note that this value is inclusive, unlike numpy slice indices. """ return np.transpose(self._bbox_bounds)[1] @lazyproperty def bbox_ymin(self): """ The minimum ``y``-pixel index of the bounding box. """ return np.transpose(self._bbox_bounds)[2] @lazyproperty def bbox_ymax(self): """ The maximum ``y``-pixel index of the bounding box. Note that this value is inclusive, unlike numpy slice indices. """ return np.transpose(self._bbox_bounds)[3] @lazyproperty def _center_npixels(self): """ The number of unmasked pixels within each aperture using the "center" mask method. The result is a `~numpy.ndarray` of per-source pixel counts. Sources with no unmasked pixels are set to NaN. """ gather = self._fast_gather if gather is not None: counts = gather[4] npix = counts.astype(float) npix[counts == 0] = np.nan return npix npix = np.array([np.sum(weight.filled(0.0)) for weight in self._weight_cutout_center]) npix[self._all_masked] = np.nan return npix @lazyproperty def _sem(self): """ The standard error of the mean for each aperture. The result is a `~numpy.ndarray` (without data units). It is computed as ``s / sqrt(N)``, where ``s`` is the sample (``N - 1``) standard deviation and ``N`` is the number of unmasked pixels within the aperture. Sources with fewer than two unmasked pixels are set to NaN. """ stats = self._mean_var if stats is not None: var = stats[1] else: var = np.array([np.var(values) for values in self._data_values_center]) npix = self._center_npixels sem = np.full(self.n_apertures, np.nan) mask = npix >= 2 # var is the population (ddof=0) variance, so var / (N - 1) # equals the squared standard error of the mean. sem[mask] = np.sqrt(var[mask] / (npix[mask] - 1.0)) return sem @lazyproperty def center_aper_area(self): """ The total area of the unmasked pixels within the aperture using the "center" aperture mask method. """ return self._center_npixels * (u.pix**2) @lazyproperty def sum_aper_area(self): """ The total area of the unmasked pixels within the aperture using the input ``sum_method`` aperture mask method. """ gather = self._fast_sum if gather is not None: area, overlap = gather[7].copy(), gather[8] area[overlap & (area == 0)] = np.nan return area << (u.pix**2) areas = np.array([np.sum(weight.filled(0.0)) for weight in self._weight_cutout]) # NaN only when no sum-method pixel survives. The center-method # ``_all_masked`` flag must not be used here: when ``sum_method`` # is not "center", unmasked boundary pixels can carry a nonzero # fractional area even if every center-method pixel is masked # (and their values then also contribute to ``sum``). areas[areas == 0] = np.nan return areas << (u.pix**2) @lazyproperty def sum(self): r""" The sum of the unmasked ``data`` values within the aperture. .. math:: F = \sum_{i \in A} I_i where :math:`F` is ``sum``, :math:`I_i` is the background-subtracted ``data``, and :math:`A` are the unmasked pixels in the aperture. Non-finite pixel values (NaN and inf) are excluded (automatically masked). """ gather = self._fast_sum if gather is not None: result, area, overlap = gather[5].copy(), gather[7], gather[8] # No sum-method survivors -> NaN (matches the mask-based # path, which returns NaN for an all-masked aperture). result[overlap & (area == 0)] = np.nan if self._data_unit is not None: result <<= self._data_unit return result if self.sum_method == 'center': return self._finalize_value_stat(None, np.sum) data_values = self._get_values(self.data_sum_cutout) result = np.array([np.sum(arr) for arr in data_values]) if self._data_unit is not None: result <<= self._data_unit return result @lazyproperty def sum_err(self): r""" The uncertainty of `sum`, propagated from the input ``error`` array. ``sum_err`` is the quadrature sum of the total errors over the unmasked pixels within the aperture: .. math:: \Delta F = \sqrt{\sum_{i \in A} \sigma_{\mathrm{tot}, i}^2} where :math:`\Delta F` is the `sum_err`, :math:`\sigma_{\mathrm{tot, i}}` are the pixel-wise total errors (``error``), and :math:`A` are the unmasked pixels in the aperture. Pixel values that are masked in the input ``data``, including any non-finite pixel values (NaN and inf) that are automatically masked, are also masked in the error array. """ if self._error is None: err = self._null_value else: gather = self._fast_sum if gather is not None: variance = gather[6].copy() area = gather[7] overlap = gather[8] variance[overlap & (area == 0)] = np.nan err = np.sqrt(variance) else: if self.sum_method == 'center': variance = self._variance_cutout_center else: variance = self._variance_cutout var_values = [arr.compressed() if len(arr.compressed()) > 0 else np.array([np.nan]) for arr in variance] err = np.sqrt([np.sum(arr) for arr in var_values]) if self._data_unit is not None: err <<= self._data_unit return err @lazyproperty def min(self): """ The minimum of the unmasked pixel values within the aperture. """ fast = None if self._order_stats is None else self._order_stats[0] return self._finalize_value_stat(fast, np.min) @lazyproperty def max(self): """ The maximum of the unmasked pixel values within the aperture. """ fast = None if self._order_stats is None else self._order_stats[1] return self._finalize_value_stat(fast, np.max) @lazyproperty def mean(self): """ The mean of the unmasked pixel values within the aperture. """ fast = None if self._mean_var is None else self._mean_var[0] return self._finalize_value_stat(fast, np.mean) @lazyproperty def mean_err(self): r""" The standard error of the `mean`. ``mean_err`` is the standard deviation of the sampling distribution of the mean: .. math:: \sigma_{\bar{x}} = \frac{s}{\sqrt{N}} where :math:`s` is the sample standard deviation (computed with ``N - 1`` in the denominator) and :math:`N` is the number of unmasked pixels within the aperture (`center_aper_area`). Apertures with fewer than two unmasked pixels have an undefined standard error and are set to NaN. """ result = self._sem.copy() if self._data_unit is not None: result <<= self._data_unit return result @lazyproperty def median(self): """ The median of the unmasked pixel values within the aperture. """ fast = None if self._order_stats is None else self._order_stats[2] return self._finalize_value_stat(fast, np.median) @lazyproperty def median_err(self): r""" The standard error of the `median`. ``median_err`` is the large-sample approximation of the standard error of the median: .. math:: \sigma_{\mathrm{med}} \approx \sqrt{\frac{\pi}{2}} \ \frac{s}{\sqrt{N}} where :math:`s` is the sample standard deviation (computed with ``N - 1`` in the denominator) and :math:`N` is the number of unmasked pixels within the aperture (`center_aper_area`). This approximation assumes that the pixel values are approximately normally distributed. Apertures with fewer than two unmasked pixels have an undefined standard error and are set to NaN. """ result = np.sqrt(np.pi / 2.0) * self._sem if self._data_unit is not None: result <<= self._data_unit return result @lazyproperty def mode(self): """ The mode of the unmasked pixel values within the aperture. The mode is estimated as ``(3 * median) - (2 * mean)``. """ return 3.0 * self.median - 2.0 * self.mean @lazyproperty def _variance(self): """ The variance of the unmasked pixel values within each aperture as a plain `~numpy.ndarray` (without data units). The population (``ddof=0``) variance is computed using the fast Cython reduction when available and otherwise the mask-based per-source code path. When ``self.ddof`` is nonzero, the result is rescaled by ``N / (N - ddof)``, where ``N`` is the number of unmasked pixels within the aperture. Apertures with ``N <= ddof`` unmasked pixels are set to NaN. """ fast = None if self._mean_var is None else self._mean_var[1] var = self._finalize_value_stat(fast, np.var, apply_unit=False) if self.ddof == 0: return var npix = self._center_npixels result = np.full(self.n_apertures, np.nan) mask = npix > self.ddof result[mask] = (var[mask] * npix[mask] / (npix[mask] - self.ddof)) return result @lazyproperty def std(self): """ The standard deviation of the unmasked pixel values within the aperture. The divisor used in the calculation is ``N - ddof``, where ``N`` is the number of unmasked pixels within the aperture and ``ddof`` is the value of the ``ddof`` keyword (default 0). """ result = np.sqrt(self._variance) if self._data_unit is not None: result <<= self._data_unit return result @lazyproperty def mad_std(self): r""" The standard deviation calculated using the `median absolute deviation (MAD) <https://en.wikipedia.org/wiki/Median_absolute_deviation>`_. The standard deviation estimator is given by: .. math:: \sigma \approx \frac{\textrm{MAD}}{\Phi^{-1}(3/4)} \approx 1.4826 \ \textrm{MAD} where :math:`\Phi^{-1}(P)` is the normal inverse cumulative distribution function evaluated at probability :math:`P = 3/4`. """ fast = None if self._mad is None else self._mad * _MAD_STD_SCALE return self._finalize_value_stat(fast, mad_std) @lazyproperty def var(self): """ The variance of the unmasked pixel values within the aperture. The divisor used in the calculation is ``N - ddof``, where ``N`` is the number of unmasked pixels within the aperture and ``ddof`` is the value of the ``ddof`` keyword (default 0). """ result = self._variance.copy() if self._data_unit is not None: result <<= self._data_unit**2 return result @lazyproperty def biweight_location(self): """ The biweight location of the unmasked pixel values within the aperture. The tuning constant is fixed at ``c=6``, the default value used by `astropy.stats.biweight_location`. """ fast = None if self._biweight is None else self._biweight[0] return self._finalize_value_stat(fast, biweight_location) @lazyproperty def biweight_midvariance(self): """ The biweight midvariance of the unmasked pixel values within the aperture. The tuning constant is fixed at ``c=9``, the default value used by `astropy.stats.biweight_midvariance`. """ fast = None if self._biweight is None else self._biweight[1] return self._finalize_value_stat(fast, biweight_midvariance, square_unit=True) @lazyproperty def inertia_tensor(self): """ The inertia tensor of the source for the rotation around its center of mass. """ moments = self._array('moments_central') mu_02 = moments[:, 0, 2] mu_11 = -moments[:, 1, 1] mu_20 = moments[:, 2, 0] tensor = np.array([mu_02, mu_11, mu_11, mu_20]).swapaxes(0, 1) return tensor.reshape((tensor.shape[0], 2, 2)) * u.pix**2 @lazyproperty def _raw_covariance(self): """ The raw ``(N, 2, 2)`` covariance matrix of the 2D Gaussian function that has the same normalized second-order moments as the source, before any regularization. This unregularized matrix is shared by `_covariance` (which regularizes a copy) and `_singular_covariance_mask` (which tests it for singularity). Callers that modify the matrix in place must operate on a copy so the cached value is not corrupted. """ moments = self._array('moments_central') # Ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) mu_norm = moments / moments[:, 0, 0][:, np.newaxis, np.newaxis] covar = np.array([mu_norm[:, 0, 2], mu_norm[:, 1, 1], mu_norm[:, 1, 1], mu_norm[:, 2, 0]]).swapaxes(0, 1) return covar.reshape((covar.shape[0], 2, 2)) @lazyproperty def _covariance(self): """ The covariance matrix of the 2D Gaussian function that has the same second-order moments as the source, always as an iterable. """ # Copy so the regularization below does not mutate the cached # raw covariance shared with `_singular_covariance_mask`. covar = self._raw_covariance.copy() # Regularize the covariance matrix for "infinitely" thin # detections by incrementally increasing the diagonal elements # by 1/12, the variance of a uniform distribution across a # single pixel (the smallest second moment a resolved source can # have given finite pixel size). delta = 1.0 / 12 delta2 = delta**2 # Ignore RuntimeWarning from NaN values in covar with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) covar_det = np.linalg.det(covar) covar_trace = covar[:, 0, 0] + covar[:, 1, 1] # A valid covariance is positive semidefinite (det >= 0 # and trace >= 0). Any matrix that is not (e.g., from # net-negative flux weighting) has an undefined shape and is # set to NaN. bad = (covar_det < 0) | (covar_trace < 0) covar[bad] = np.nan # Regularize "infinitely" thin detections by adding 1/12 # (delta) to each diagonal. A single bump is sufficient. # For a positive semidefinite matrix the bumped determinant # exceeds the raw determinant by delta times the trace plus # delta squared. Since the raw determinant and trace are # both non-negative, the result is at least delta squared, # which equals the delta2 threshold, so it clears the # threshold in a single step. idx = np.where(covar_det < delta2)[0] covar[idx, 0, 0] += delta covar[idx, 1, 1] += delta return covar @lazyproperty def covariance(self): """ The covariance matrix of the 2D Gaussian function that has the same second-order moments as the source. """ return self._covariance * (u.pix**2) @lazyproperty def covariance_eigvals(self): """ The two eigenvalues of the `covariance` matrix in decreasing order. """ eigvals = np.empty((self.n_apertures, 2)) eigvals.fill(np.nan) # np.linalg.eigvalsh requires finite input values idx = np.unique(np.where(np.isfinite(self._covariance))[0]) eigvals[idx] = np.linalg.eigvalsh(self._covariance[idx]) # Check for negative variance # (just in case covariance matrix is not positive semidefinite) idx2 = np.unique(np.where(eigvals < 0)[0]) eigvals[idx2] = (np.nan, np.nan) # Sort each eigenvalue pair in descending order # (eigvalsh returns values in ascending order) eigvals = np.fliplr(eigvals) return eigvals * u.pix**2 @lazyproperty def semimajor_axis(self): """ The 1-sigma standard deviation along the semimajor axis of the 2D Gaussian function that has the same second-order central moments as the source. """ eigvals = self._array('covariance_eigvals') return np.sqrt(eigvals[:, 0]) @lazyproperty def semiminor_axis(self): """ The 1-sigma standard deviation along the semiminor axis of the 2D Gaussian function that has the same second-order central moments as the source. """ eigvals = self._array('covariance_eigvals') return np.sqrt(eigvals[:, 1]) @lazyproperty def fwhm(self): r""" The circularized full width at half maximum (FWHM) of the 2D Gaussian function that has the same second-order central moments as the source. .. math:: \mathrm{FWHM} & = 2 \sqrt{2 \ln(2)} \sqrt{0.5 (a^2 + b^2)} \\ & = 2 \sqrt{\ln(2) \ (a^2 + b^2)} where :math:`a` and :math:`b` are the 1-sigma lengths of the semimajor (`semimajor_axis`) and semiminor (`semiminor_axis`) axes, respectively. """ return 2.0 * np.sqrt(np.log(2.0) * (self.semimajor_axis**2 + self.semiminor_axis**2)) @lazyproperty def orientation(self): """ The angle between the ``x`` axis and the major axis of the 2D Gaussian function that has the same second-order moments as the source. The angle increases in the counter-clockwise direction and is in the range (-90, 90] degrees. """ covar = self._covariance orient_radians = 0.5 * np.arctan2(2.0 * covar[:, 0, 1], (covar[:, 0, 0] - covar[:, 1, 1])) return np.rad2deg(orient_radians) * u.deg @lazyproperty def eccentricity(self): r""" The eccentricity of the 2D Gaussian function that has the same second-order moments as the source. The eccentricity is the fraction of the distance along the semimajor axis at which the focus lies. .. math:: e = \sqrt{1 - \frac{b^2}{a^2}} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ semimajor_var, semiminor_var = np.transpose(self.covariance_eigvals) return np.sqrt(1.0 - (semiminor_var / semimajor_var)) @lazyproperty def elongation(self): r""" The ratio of the lengths of the semimajor and semiminor axes. .. math:: \mathrm{elongation} = \frac{a}{b} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ return self.semimajor_axis / self.semiminor_axis @lazyproperty def ellipticity(self): r""" 1.0 minus the ratio of the lengths of the semimajor and semiminor axes (or 1.0 minus the `elongation`). .. math:: \mathrm{ellipticity} = 1 - \frac{b}{a} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ return 1.0 - (self.semiminor_axis / self.semimajor_axis) @lazyproperty def covariance_xx(self): r""" The ``(0, 0)`` element of the `covariance` matrix, representing :math:`\sigma_x^2`, in units of pixel**2. """ return self._covariance[:, 0, 0] * u.pix**2 @lazyproperty def covariance_yy(self): r""" The ``(1, 1)`` element of the `covariance` matrix, representing :math:`\sigma_y^2`, in units of pixel**2. """ return self._covariance[:, 1, 1] * u.pix**2 @lazyproperty def covariance_xy(self): r""" The ``(0, 1)`` and ``(1, 0)`` elements of the `covariance` matrix, representing :math:`\sigma_x \sigma_y`, in units of pixel**2. """ return self._covariance[:, 0, 1] * u.pix**2 @lazyproperty def ellipse_cxx(self): r""" Coefficient for ``x**2`` in the generalized ellipse equation in units of pixel**(-2). The ellipse is defined as .. math:: cxx (x - \bar{x})^2 + cxy (x - \bar{x}) (y - \bar{y}) + cyy (y - \bar{y})^2 = R^2 where :math:`R` is a parameter which scales the ellipse (in units of the axes lengths). The isophotal limit of a source is well represented by :math:`R \approx 3`. """ return ((np.cos(self.orientation) / self.semimajor_axis)**2 + (np.sin(self.orientation) / self.semiminor_axis)**2) @lazyproperty def ellipse_cyy(self): r""" Coefficient for ``y**2`` in the generalized ellipse equation in units of pixel**(-2). The ellipse is defined as .. math:: cxx (x - \bar{x})^2 + cxy (x - \bar{x}) (y - \bar{y}) + cyy (y - \bar{y})^2 = R^2 where :math:`R` is a parameter which scales the ellipse (in units of the axes lengths). The isophotal limit of a source is well represented by :math:`R \approx 3`. """ return ((np.sin(self.orientation) / self.semimajor_axis)**2 + (np.cos(self.orientation) / self.semiminor_axis)**2) @lazyproperty def ellipse_cxy(self): r""" Coefficient for ``x * y`` in the generalized ellipse equation in units of pixel**(-2). The ellipse is defined as .. math:: cxx (x - \bar{x})^2 + cxy (x - \bar{x}) (y - \bar{y}) + cyy (y - \bar{y})^2 = R^2 where :math:`R` is a parameter which scales the ellipse (in units of the axes lengths). The isophotal limit of a source is well represented by :math:`R \approx 3`. """ return (2.0 * np.cos(self.orientation) * np.sin(self.orientation) * ((1.0 / self.semimajor_axis**2) - (1.0 / self.semiminor_axis**2))) @lazyproperty def gini(self): r""" The `Gini coefficient <https://en.wikipedia.org/wiki/Gini_coefficient>`_ of the unmasked pixel values within the aperture. The Gini coefficient of the distribution of absolute flux values is calculated using the prescription from `Lotz et al. 2004 <https://ui.adsabs.harvard.edu/abs/2004AJ....128..163L/abstract>`_ (Eq. 6) as: .. math:: G = \frac{1}{\overline{|x|} \, n \, (n - 1)} \sum^{n}_{i} (2i - n - 1) \left | x_i \right | where :math:`\overline{|x|}` is the mean of the absolute value of all pixel values :math:`x_i`. If the sum of all pixel values is zero, the Gini coefficient is zero. Negative pixel values are used via their absolute value. Invalid values (NaN and inf) in the input are automatically excluded from the calculation. If only a single finite pixel remains after filtering, the Gini coefficient is 0.0. """ return self._finalize_value_stat(self._gini, gini_func, apply_unit=False)