Source code for photutils.aperture.photometry

# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
Tools for performing aperture photometry.
"""

import warnings

import astropy.units as u
import numpy as np
from astropy.coordinates import SkyCoord
from astropy.nddata import NDData, StdDevUncertainty
from astropy.table import QTable
from astropy.utils import lazyproperty
from astropy.utils.exceptions import AstropyUserWarning

from photutils.aperture._segmentation import process_segmentation_inputs
from photutils.aperture.converters import region_to_aperture
from photutils.aperture.core import (Aperture, SkyAperture, _aperture_metadata,
                                     _update_method_subpixels_docstring)
from photutils.aperture.flags import decode_aperture_flags
from photutils.utils._deprecation import (create_empty_deprecated_qtable,
                                          deprecated_positional_kwargs)
from photutils.utils._misc import _get_meta
from photutils.utils._parameters import validate_table_columns
from photutils.utils._quantity_helpers import process_quantities
from photutils.utils._repr import make_repr

__all__ = ['AperturePhotometry', 'aperture_photometry']


# Remove in 4.0
_DEPRECATED_COLUMNS: dict = {
    'xcenter': 'x_center',
    'ycenter': 'y_center',
}


[docs] @_update_method_subpixels_docstring class AperturePhotometry: # numpydoc ignore: PR01,PR02,PR04,PR07 """ Class to perform aperture photometry on 2D data. This class sums the (weighted) input ``data`` values within the given aperture(s) and provides the aperture fluxes, uncertainties, unmasked overlap areas, and bitwise quality flags as lazily-computed attributes. Use `to_table` to obtain the results as an `~astropy.table.QTable`. Note that this class returns the sum of the (weighted) 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 class. Parameters ---------- data : array_like, `~astropy.units.Quantity`, `~astropy.nddata.NDData` The 2D array on which to perform photometry. ``data`` should be background-subtracted. If ``data`` is a `~astropy.units.Quantity` array, then ``error`` (if input) must also be a `~astropy.units.Quantity` array with the same units. Non-finite ``data`` values (NaN and inf) are automatically masked. See the Notes section below for more information about `~astropy.nddata.NDData` input. apertures : `~photutils.aperture.Aperture`, supported `regions.Region`, \ list of `~photutils.aperture.Aperture` or `regions.Region` The aperture(s) to use for the photometry. If ``apertures`` is a list of `~photutils.aperture.Aperture` or `regions.Region`, then they all must have the same position(s). If ``apertures`` contains 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 : array_like or `~astropy.units.Quantity`, optional The pixel-wise Gaussian 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``. If a `~astropy.units.Quantity` array, then ``data`` must also be a `~astropy.units.Quantity` array with the same units. mask : array_like (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, 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`). If provided, the `sky_center` attribute and the ``'sky_center'`` column of `to_table` will contain the sky coordinates of the input aperture center(s). This keyword is required if the input ``apertures`` contains a `SkyAperture` or `~regions.SkyRegion`. <method_subpixels_descriptions> <segmentation_descriptions> See Also -------- photutils.aperture.ApertureStats : Per-source statistics (e.g., centroid, min, max, median, standard deviation, and morphological properties) of the pixels within an aperture. Notes ----- `~regions.Region` objects are converted to `Aperture` objects using the :func:`region_to_aperture` function. If the input ``data`` is a `~astropy.nddata.NDData` instance, then the ``error``, ``mask``, and ``wcs`` keyword inputs are ignored. Instead, these values should be defined as attributes in the `~astropy.nddata.NDData` object. In the case of ``error``, it must be defined in the ``uncertainty`` attribute with a `~astropy.nddata.StdDevUncertainty` instance. The measured `flux`, `flux_err`, `area`, and `flags` attributes (as well as `id`, `x_center`, `y_center`, and `sky_center`) are scalars if a single scalar aperture position is input (e.g., ``CircularAperture((10, 20), r=5)``). They are 1D arrays with one element per position if multiple positions are input (e.g., ``CircularAperture([(10, 20)], r=5)`` or ``CircularAperture([(10, 20), (30, 40)], r=5)``). If a list of apertures is input, the `flux`, `flux_err`, `area`, and `flags` attributes gain a trailing aperture axis, giving shape ``(n_apertures,)`` for a single scalar position and ``(n_positions, n_apertures)`` otherwise. Non-finite ``data`` values (NaN and inf) are automatically masked. Such pixels are excluded from the `flux`, `flux_err`, and `area` calculations and are indicated by the ``non_finite_data`` quality flag (see `~photutils.aperture.decode_aperture_flags`). This class should be treated as immutable after initialization (aside from the internal compute-once `~astropy.utils.decorators.lazyproperty` cache), so a single instance can be safely shared across threads. Do not reassign its attributes after construction. Examples -------- >>> import numpy as np >>> from photutils.datasets import make_4gaussians_image >>> from photutils.aperture import AperturePhotometry, CircularAperture >>> data = make_4gaussians_image() >>> error = 0.1 * np.ones(data.shape) # simple background-only error >>> aper = CircularAperture([(25, 40), (90, 60), (150, 25)], r=8) >>> phot = AperturePhotometry(data, aper, error=error) >>> print(phot.flux) [ 5853.59627292 28440.27461471 9286.70920641] >>> print(phot.flux_err) [1.41796308 1.41796308 1.41796308] >>> phot.to_table(columns=['id', 'flux', 'flux_err']) <QTable length=3> id flux flux_err int64 float64 float64 ----- ------------------ ----------------- 1 5853.596272924398 1.417963080724414 2 28440.274614708058 1.417963080724414 3 9286.709206410273 1.417963080724414 """ _repr_params = ('method', 'subpixels', 'mask_method') def __init__(self, data, apertures, *, error=None, mask=None, wcs=None, method='exact', subpixels=5, segmentation_image=None, labels=None, mask_method='none'): if isinstance(data, NDData): data, error, mask, wcs = self._unpack_nddata(data, error, mask, wcs) (data, error), unit = process_quantities( (data, error), ('data', 'error')) self._data = self._validate_array(data, 'data', shape=False) self._data_unit = unit self._error = self._validate_array(error, 'error') self._mask = self._validate_array(mask, 'mask') self._wcs = wcs self.method = method self.subpixels = subpixels self.mask_method = mask_method single_aperture = False if not isinstance(apertures, (list, tuple, np.ndarray)): single_aperture = True apertures = (apertures,) self._single_aperture = single_aperture # Create table metadata using the input apertures, not the # converted ones aper_meta = {} for i, aperture in enumerate(apertures): idx = '' if single_aperture else i aper_meta.update(_aperture_metadata(aperture, index=idx)) # Convert regions to apertures if necessary apertures = [region_to_aperture(aper) if not isinstance(aper, Aperture) else aper for aper in apertures] # Convert sky to pixel apertures self._skyaper = False self._sky_positions = None if isinstance(apertures[0], SkyAperture): if wcs is None: msg = ('A WCS transform must be defined by the input data ' 'or the wcs keyword when using a SkyAperture object.') raise ValueError(msg) self._skyaper = True self._sky_positions = apertures[0].positions apertures = [aper.to_pixel(wcs) for aper in apertures] # Compare positions in pixels to avoid comparing SkyCoord objects positions = apertures[0].positions for aper in apertures[1:]: if not np.array_equal(aper.positions, positions): msg = 'Input apertures must all have identical positions' raise ValueError(msg) self._pixel_apertures = apertures # Validate the segmentation-masking inputs and resolve the # per-aperture source labels once self.segmentation_image = segmentation_image self.labels = labels seg_positions = np.atleast_2d(apertures[0].positions) (self._segmentation, self._seg_labels) = process_segmentation_inputs( segmentation_image, labels, mask_method, seg_positions, self._data.shape) # Define output table metadata self.meta = _get_meta() calling_args = f"method='{method}', subpixels={subpixels}" self.meta['aperture_photometry_args'] = calling_args self.meta.update(aper_meta) default_columns = ['id', 'x_center', 'y_center'] if self._wcs is not None or self._skyaper: default_columns.append('sky_center') default_columns += ['flux', 'flux_err', 'area', 'flags'] self.default_columns = default_columns @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 is 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 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 def __repr__(self): return make_repr(self, self._repr_params) def __str__(self): return make_repr(self, self._repr_params, long=True) def __getattribute__(self, name): # Collapse the leading position axis of the public array-valued # output attributes to a scalar when a single scalar aperture # position is input (e.g., ``CircularAperture((10, 20), r=5)``). # These are the only public array-valued attributes, so the # scalar conversion is applied centrally here instead of being # repeated on each individual property. value = super().__getattribute__(name) if (not name.startswith('_') and name not in ('isscalar', 'n_positions') and isinstance(value, (np.ndarray, SkyCoord)) and self.isscalar): return value[0] return value def _array(self, name): """ Return the full (never scalar-collapsed) array 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 (i.e., a single aperture position). """ return self._pixel_apertures[0].isscalar @lazyproperty def _photometry_results(self): """ The per-aperture photometry result objects, one for each input aperture. """ return [aper._photometry( self._data, error=self._error, mask=self._mask, method=self.method, subpixels=self.subpixels, segmentation_image=self._segmentation, labels=self._seg_labels, mask_method=self.mask_method, mask_nonfinite=True) for aper in self._pixel_apertures] @lazyproperty def _positions(self): """ The aperture positions in pixels, always as a 2D array. """ return np.atleast_2d(self._pixel_apertures[0].positions) @lazyproperty def n_positions(self): """ The number of aperture positions. """ return self._positions.shape[0] @lazyproperty def id(self): """ The aperture identification number(s). """ return np.arange(self.n_positions) + 1 @lazyproperty def x_center(self): """ The ``x`` pixel coordinate(s) of the aperture center(s). """ return self._positions[:, 0] @lazyproperty def y_center(self): """ The ``y`` pixel coordinate(s) of the aperture center(s). """ return self._positions[:, 1] @lazyproperty def sky_center(self): """ The sky coordinates of the aperture center(s), or `None` if no ``wcs`` was input. """ if self._skyaper: pos = self._sky_positions if pos.isscalar: # Return a length-1 SkyCoord array return pos.reshape((-1,)) return pos if self._wcs is not None: return self._wcs.pixel_to_world(*np.transpose(self._positions)) return None def _stack(self, attr): """ Stack a per-aperture result attribute into a 1D array (single aperture) or a 2D ``(n_positions, n_apertures)`` array (list of apertures). """ values = [getattr(result, attr) for result in self._photometry_results] if self._single_aperture: return values[0] return np.stack(values, axis=1) @lazyproperty def flux(self): """ The sum of the (weighted) values within the aperture(s). The values are always float64, regardless of the input ``data`` dtype (a `~astropy.units.Quantity` if ``data`` has units). """ values = self._stack('flux') if self._data_unit is not None: values <<= self._data_unit return values @lazyproperty def flux_err(self): """ The uncertainty in the `flux` values. The values are always float64, regardless of the input ``error`` dtype (a `~astropy.units.Quantity` if ``data`` has units). If the input ``error`` is `None`, this is filled with NaN values. """ values = self._stack('flux_err') if self._data_unit is not None: values <<= self._data_unit return values @lazyproperty def area(self): """ The total unmasked overlap area of the aperture(s) (in ``pix**2``). This takes into account the aperture mask method, masked data pixels (``mask`` keyword), segmentation masking, and partial/no overlap of the aperture with the data. The value is NaN where an aperture does not overlap the data. """ values = [result.area for result in self._photometry_results] if self._single_aperture: return values[0] stacked = np.stack([value.value for value in values], axis=1) return u.Quantity(stacked, u.pix**2) @lazyproperty def flags(self): """ The bitwise quality flags for the aperture(s). See `~photutils.aperture.decode_aperture_flags` for decoding flag values. """ return self._stack('flags')
[docs] @deprecated_positional_kwargs(since='3.1', until='4.0') def to_table(self, *, columns=None): """ Create a `~astropy.table.QTable` of the aperture photometry results. 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 ``'id'``, ``'x_center'``, ``'y_center'``, ``'sky_center'``, ``'flux'``, ``'flux_err'``, ``'area'``, and ``'flags'``. If ``columns`` is `None`, then a default list of columns will be used (the ``default_columns`` attribute). If a list of apertures was input, then the ``'flux'``, ``'flux_err'``, ``'area'``, and ``'flags'`` columns will have a ``'_i'`` suffix (e.g., ``'flux_0'``), where ``i`` is the index of the aperture in the input list. Returns ------- table : `~astropy.table.QTable` A table of the aperture photometry results with one row per aperture position. Raises ------ ValueError If any name in ``columns`` is not a valid column name. """ allowed_columns = ('id', 'x_center', 'y_center', 'sky_center', 'flux', 'flux_err', 'area', 'flags') if columns is None: table_columns = self.default_columns else: table_columns = validate_table_columns(columns, allowed_columns) tbl = QTable() tbl.meta.update(self.meta) per_aperture = ('flux', 'flux_err', 'area', 'flags') for column in table_columns: values = self._array(column) if column in per_aperture and not self._single_aperture: for i in range(len(self._pixel_apertures)): name = f'{column}_{i}' tbl[name] = values[:, i] else: tbl[column] = values return tbl
[docs] def decode_flags(self, *, return_bit_values=False): """ Decode the aperture quality flags into individual components. This is a convenience method that calls `~photutils.aperture.decode_aperture_flags` with the `flags` attribute. Parameters ---------- 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 aperture. If a list of apertures was input (2D `flags`), the result is a nested list with the same ``(n_positions, n_apertures)`` shape as `flags`, so ``decoded[i][j]`` gives the active flags for position ``i`` and aperture ``j``. See Also -------- photutils.aperture.decode_aperture_flags """ return decode_aperture_flags(self._array('flags'), return_bit_values=return_bit_values)
[docs] @_update_method_subpixels_docstring @deprecated_positional_kwargs(since='3.0', until='4.0') def aperture_photometry(data, apertures, error=None, mask=None, method='exact', subpixels=5, wcs=None): # numpydoc ignore: PR01,PR02,PR04,PR07 """ Perform aperture photometry on the input data by summing the flux within the given aperture(s). .. note:: This is a legacy function. It is not deprecated, but new features are no longer added to it. The :class:`~photutils.aperture.AperturePhotometry` class is the recommended tool for aperture photometry. It provides additional outputs (e.g., aperture areas, quality flags) and options (e.g., segmentation-based neighbor masking). This function returns the sum of the (weighted) 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 : array_like, `~astropy.units.Quantity`, `~astropy.nddata.NDData` The 2D array on which to perform photometry. ``data`` should be background-subtracted. If ``data`` is a `~astropy.units.Quantity` array, then ``error`` (if input) must also be a `~astropy.units.Quantity` array with the same units. See the Notes section below for more information about `~astropy.nddata.NDData` input. apertures : `~photutils.aperture.Aperture`, supported `regions.Region`, \ list of `~photutils.aperture.Aperture` or `regions.Region` The aperture(s) to use for the photometry. If ``apertures`` is a list of `~photutils.aperture.Aperture` or `regions.Region`, then they all must have the same position(s). If ``apertures`` contains 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 : array_like or `~astropy.units.Quantity`, optional The pixel-wise Gaussian 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``. If a `~astropy.units.Quantity` array, then ``data`` must also be a `~astropy.units.Quantity` array with the same units. mask : array_like (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. <method_subpixels_descriptions> wcs : WCS object, 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`). If provided, the output table will include a ``'sky_center'`` column with the sky coordinates of the input aperture center(s). This keyword is required if the input ``apertures`` contains a `SkyAperture` or `~regions.SkyRegion`. Returns ------- table : `~astropy.table.QTable` A table of the photometry with the following columns: * ``'id'``: The source ID. * ``'x_center'``, ``'y_center'``: The ``x`` and ``y`` pixel coordinates of the input aperture center(s). * ``'sky_center'``: The sky coordinates of the input aperture center(s). Returned if a ``wcs`` is input. * ``'aperture_sum'``: The sum of the values within the aperture(s). The values are always float64, regardless of the input ``data`` dtype (a `~astropy.units.Quantity` with float64 values if ``data`` has units). * ``'aperture_sum_err'``: The corresponding uncertainty in the ``'aperture_sum'`` values (always float64). Returned only if the input ``error`` is not `None`. If multiple apertures are input, the ``'aperture_sum'`` and ``'aperture_sum_err'`` columns will have a ``'_i'`` suffix (e.g., ``'aperture_sum_0'``), where ``i`` is the index of the aperture in the input list. The table metadata includes the Astropy and Photutils version numbers and the `aperture_photometry` calling arguments. Notes ----- `~regions.Region` objects are converted to `Aperture` objects using the :func:`region_to_aperture` function. If the input ``data`` is a `~astropy.nddata.NDData` instance, then the ``error``, ``mask``, and ``wcs`` keyword inputs are ignored. Instead, these values should be defined as attributes in the `~astropy.nddata.NDData` object. In the case of ``error``, it must be defined in the ``uncertainty`` attribute with a `~astropy.nddata.StdDevUncertainty` instance. """ if isinstance(data, NDData): 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 is 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 aperture_photometry(data, apertures, error=error, mask=mask, method=method, subpixels=subpixels, wcs=wcs) single_aperture = False if not isinstance(apertures, (list, tuple, np.ndarray)): single_aperture = True apertures = (apertures,) # Create table metadata using the input apertures, not the converted # ones aper_meta = {} for i, aperture in enumerate(apertures): i = '' if single_aperture else i aper_meta.update(_aperture_metadata(aperture, index=i)) # Convert regions to apertures if necessary apertures = [region_to_aperture(aper) if not isinstance(aper, Aperture) else aper for aper in apertures] # Convert sky to pixel apertures skyaper = False if isinstance(apertures[0], SkyAperture): if wcs is None: msg = ('A WCS transform must be defined by the input data or ' 'the wcs keyword when using a SkyAperture object.') raise ValueError(msg) # Include SkyCoord position in the output table skyaper = True skycoord_pos = apertures[0].positions apertures = [aper.to_pixel(wcs) for aper in apertures] # Compare positions in pixels to avoid comparing SkyCoord objects positions = apertures[0].positions for aper in apertures[1:]: if not np.array_equal(aper.positions, positions): msg = 'Input apertures must all have identical positions' raise ValueError(msg) # Define output table meta data meta = _get_meta() calling_args = f"method='{method}', subpixels={subpixels}" meta['aperture_photometry_args'] = calling_args meta.update(aper_meta) # Replace with QTable in 4.0 tbl = create_empty_deprecated_qtable( _DEPRECATED_COLUMNS, since='3.0', until='4.0') tbl.meta.update(meta) # keep tbl.meta type positions = np.atleast_2d(apertures[0].positions) tbl['id'] = np.arange(positions.shape[0], dtype=int) + 1 xypos_pixel = np.transpose(positions) tbl['x_center'] = xypos_pixel[0] tbl['y_center'] = xypos_pixel[1] if skyaper: if skycoord_pos.isscalar: # Create length-1 SkyCoord array tbl['sky_center'] = skycoord_pos.reshape((-1,)) else: tbl['sky_center'] = skycoord_pos if wcs is not None and not skyaper: tbl['sky_center'] = wcs.pixel_to_world(*np.transpose(positions)) for i, aper in enumerate(apertures): result = aper._photometry( data, error=error, mask=mask, method=method, subpixels=subpixels) # Legacy column names sum_key = 'aperture_sum' sum_err_key = 'aperture_sum_err' if not single_aperture: sum_key += f'_{i}' sum_err_key += f'_{i}' tbl[sum_key] = result.flux if error is not None: tbl[sum_err_key] = result.flux_err return tbl