# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
Base aperture classes.
"""
import abc
import inspect
import re
import textwrap
import warnings
from copy import deepcopy
from dataclasses import dataclass
import astropy.units as u
import numpy as np
from astropy.coordinates import SkyCoord
from astropy.utils import lazyproperty
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._segmentation import (SEG_METHOD_CODES,
make_segmentation_exclusion,
process_segmentation_inputs)
from photutils.aperture.bounding_box import BoundingBox
from photutils.aperture.flags import APERTURE_FLAGS, _counts_to_flag_bits
from photutils.aperture.mask import ApertureMask
from photutils.utils._deprecation import (deprecated,
deprecated_positional_kwargs)
from photutils.utils._flags import define_flag_docstring
__all__ = ['Aperture', 'PixelAperture', 'SkyAperture']
# Canonical descriptions of the ``method`` and ``subpixels`` parameters
# shared by many aperture and profile docstrings. The text is stored
# without leading indentation and is re-indented to match the
# placeholder by ``_update_method_subpixels_docstring``.
_METHOD_INTRO = """\
method : {'exact', 'center', 'subpixel'}, optional
The method used to determine the pixel weights (the fraction
of the pixel area covered by the aperture):"""
_METHOD_BULLETS = """\
* ``'exact'`` (default):
Calculates the exact geometric overlap area. Weights are
continuous in the range [0, 1].
* ``'center'``:
Binary weighting based on the pixel center. Weights are
either 0 or 1. A pixel is included only if its center lies
strictly inside the aperture; pixel centers lying exactly
on the aperture boundary are excluded (weight 0).
* ``'subpixel'``:
Approximates the overlap by averaging binary samples on a
subgrid. The number of samples is set by the ``subpixels``
parameter. Weights are discrete in the range [0, 1]. A
subpixel is included only if its center lies strictly
inside the aperture; subpixel centers lying exactly on the
aperture boundary are excluded (weight 0)."""
_SUBPIXELS_DOC = """\
subpixels : int, optional
The subsampling factor per axis used when
``method='subpixel'``. Each pixel is divided into a grid of
``subpixels**2`` subpixels to approximate the overlap. This
parameter is ignored for other methods."""
_METHOD_SUBPIXELS_DOC = (
_METHOD_INTRO + '\n\n'
+ textwrap.indent(_METHOD_BULLETS, ' ') + '\n\n'
+ _SUBPIXELS_DOC)
_SEGMENTATION_DOC = """\
segmentation_image : `~photutils.segmentation.SegmentationImage`, 2D \
array_like, or `None`, optional
A 2D segmentation image with the same shape as ``data``, where
background pixels have a value of 0 and sources are labeled with
positive integers. If input, neighboring sources can be masked or
corrected within each aperture according to the ``mask_method``
keyword. This keyword is required if ``mask_method`` is not
``'none'``. When ``segmentation_image`` is input, the ``labels``
keyword must also be provided to ensure the correct target source is
used for each aperture. If ``segmentation_image`` is `None`, then
the ``mask_method`` keyword is ignored and no neighboring source
masking or correction is performed.
labels : int, 1D array_like, or `None`, optional
The source label(s) in ``segmentation_image`` associated
with the aperture position(s). ``labels`` is required if
``segmentation_image`` is input and ``mask_method`` is not
``'none'``. ``labels`` must have the same length as the number of
aperture positions.
mask_method : {'none', 'mask', 'source_only', 'correct'}, optional
The method used to handle neighboring sources within each aperture
using the ``segmentation_image``:
* ``'none'`` (default):
The ``segmentation_image`` is ignored and all pixels within the
aperture are included.
* ``'mask'``:
Pixels belonging to neighboring sources (i.e., labeled but not
the target source) are excluded.
* ``'source_only'``:
Only pixels belonging to the target source are included; both
neighboring sources and background pixels are excluded.
* ``'correct'``:
Pixels belonging to neighboring sources are replaced by the
values of the pixels mirrored across the aperture center. If a
mirror pixel is unavailable, the pixel is excluded."""
# Bullet list of the aperture quality flags, generated from the central
# flag registry, used in docstrings via the ``flag_descriptions``
# placeholder.
_FLAG_DESCRIPTIONS_DOC = '\n'.join(define_flag_docstring(APERTURE_FLAGS))
# Mapping of placeholder tags to their replacement text. Each tag must
# appear alone on its own line in a docstring; the leading indentation
# of the placeholder is applied to the inserted text.
_DOC_PLACEHOLDERS = {
'method_subpixels_descriptions': _METHOD_SUBPIXELS_DOC,
'method_bullets': _METHOD_BULLETS,
'subpixels_description': _SUBPIXELS_DOC,
'segmentation_descriptions': _SEGMENTATION_DOC,
'flag_descriptions': _FLAG_DESCRIPTIONS_DOC,
}
_DOC_PLACEHOLDER_RE = re.compile(
r'^([ \t]*)<(' + '|'.join(_DOC_PLACEHOLDERS) + r')>[ \t]*$',
re.MULTILINE)
def _update_method_subpixels_docstring(obj):
"""
Decorator to insert standard ``method``, ``subpixels``, and related
parameter descriptions into a docstring.
The following placeholders are supported, each of which must appear
alone on its own line. The leading indentation of the placeholder is
applied to the inserted text, so the same source descriptions can be
used at any docstring indentation level.
* ``<method_subpixels_descriptions>`` : the full ``method`` and
``subpixels`` parameter descriptions.
* ``<method_bullets>`` : only the ``'exact'``, ``'center'``, and
``'subpixel'`` bullet list, e.g., for a parameter that uses a
custom name and introduction.
* ``<subpixels_description>`` : only the ``subpixels`` parameter
description.
Parameters
----------
obj : function or type
The function or class whose docstring will be updated.
Returns
-------
obj : function or type
The input ``obj`` with its ``__doc__`` updated in place.
"""
docstring = obj.__doc__
if docstring is None:
return obj
def replace(match):
indent = match.group(1)
return textwrap.indent(_DOC_PLACEHOLDERS[match.group(2)], indent)
obj.__doc__ = _DOC_PLACEHOLDER_RE.sub(replace, docstring)
return obj
@dataclass(frozen=True)
class _ApertureResults:
"""
The results of `PixelAperture._photometry`.
Attributes
----------
flux : `~numpy.ndarray` or `~astropy.units.Quantity`
The sum of the data within each aperture.
flux_err : `~numpy.ndarray` or `~astropy.units.Quantity`
The uncertainty in the sum of the data within each aperture.
area : `~astropy.units.Quantity`
The total unmasked overlap area of each aperture (in pix**2).
flags : `~numpy.ndarray`
The bitwise quality flags for each aperture. See
`~photutils.aperture.decode_aperture_flags` for decoding flag
values.
"""
flux: np.ndarray
flux_err: np.ndarray
area: np.ndarray
flags: np.ndarray
[docs]
class Aperture(metaclass=abc.ABCMeta):
"""
Abstract base class for all apertures.
"""
_params = ()
def __len__(self):
if self.isscalar:
msg = f'A scalar {self.__class__.__name__!r} object has no len()'
raise TypeError(msg)
return self.shape[0]
def __getitem__(self, index):
if self.isscalar:
msg = (f'A scalar {self.__class__.__name__!r} object cannot be '
'indexed')
raise TypeError(msg)
kwargs = {}
for param in self._params:
if param == 'positions':
# Slice the positions array
kwargs[param] = getattr(self, param)[index]
else:
kwargs[param] = getattr(self, param)
return self.__class__(**kwargs)
def __iter__(self):
for i in range(len(self)):
yield self.__getitem__(i)
def _positions_str(self, *, prefix=None):
if isinstance(self, PixelAperture):
return np.array2string(self.positions, separator=', ',
prefix=prefix)
if isinstance(self, SkyAperture):
return repr(self.positions)
msg = 'Aperture must be a subclass of PixelAperture or SkyAperture'
raise TypeError(msg)
def __repr__(self):
prefix = f'{self.__class__.__name__}'
cls_info = []
for param in self._params:
if param == 'positions':
cls_info.append(self._positions_str(prefix=prefix))
else:
cls_info.append(f'{param}={getattr(self, param)}')
cls_info = ', '.join(cls_info)
return f'<{prefix}({cls_info})>'
def __str__(self):
cls_info = [('Aperture', self.__class__.__name__)]
for param in self._params:
if param == 'positions':
prefix = 'positions'
cls_info.append((prefix,
self._positions_str(prefix=prefix + ': ')))
else:
cls_info.append((param, getattr(self, param)))
fmt = [f'{key}: {val}' for key, val in cls_info]
return '\n'.join(fmt)
def __eq__(self, other):
"""
Equality operator for `Aperture`.
All Aperture properties are compared for strict equality except
for Quantity parameters, which allow for different units if they
are directly convertible.
"""
if not isinstance(other, self.__class__):
return False
self_params = list(self._params)
other_params = list(other._params)
# Check that both have identical parameters
if self_params != other_params:
return False
# Now check the parameter values.
# Note that Quantity comparisons allow for different units if they
# are directly convertible (e.g., 1.0 * u.deg == 60.0 * u.arcmin)
try:
for param in self_params:
# np.any is used for SkyCoord array comparisons
if np.any(getattr(self, param) != getattr(other, param)):
return False
except TypeError:
# TypeError is raised from SkyCoord comparison when they do
# not have equivalent frames. Here return False instead of
# the TypeError.
return False
return True
def __ne__(self, other):
"""
Inequality operator for `Aperture`.
"""
return not self == other
@property
def _lazyproperties(self):
"""
A list of all class lazyproperties (even in superclasses).
"""
def islazyproperty(obj):
return isinstance(obj, lazyproperty)
return [i[0] for i in inspect.getmembers(self.__class__,
predicate=islazyproperty)]
[docs]
def copy(self):
"""
Make a deep copy of this object.
Returns
-------
result : `Aperture`
A deep copy of the Aperture object.
"""
params_copy = {}
for param in list(self._params):
params_copy[param] = deepcopy(getattr(self, param))
return self.__class__(**params_copy)
[docs]
@abc.abstractmethod
def positions(self):
"""
The aperture positions, as an array of (x, y) coordinates or a
`~astropy.coordinates.SkyCoord`.
"""
@lazyproperty
def shape(self):
"""
The shape of the instance.
"""
if isinstance(self.positions, SkyCoord):
return self.positions.shape
return self.positions.shape[:-1]
@lazyproperty
def isscalar(self):
"""
Whether the instance is scalar (i.e., a single position).
"""
return self.shape == ()
[docs]
class PixelAperture(Aperture):
"""
Abstract base class for apertures defined in pixel coordinates.
"""
@lazyproperty
def _default_patch_properties(self):
"""
A dictionary of default matplotlib.patches.Patch properties.
"""
mpl_params = {}
# matplotlib.patches.Patch default is ``fill=True``
mpl_params['fill'] = False
return mpl_params
@staticmethod
def _translate_mask_method(method, subpixels):
"""
Translate the mask method and subpixels parameters to the values
used by the low-level `photutils.geometry` functions.
Parameters
----------
method : {'exact', 'center', 'subpixel'}
The mask method.
subpixels : int
The number of subpixels for the 'subpixel' method.
Returns
-------
use_exact : int
Whether to use exact method (1) or not (0).
subpixels : int
The number of subpixels for subpixel method.
"""
if method not in ('center', 'subpixel', 'exact'):
msg = f'Invalid mask method: {method}'
raise ValueError(msg)
if ((method == 'subpixel')
and (not isinstance(subpixels, int) or subpixels <= 0)):
msg = 'subpixels must be a strictly positive integer'
raise ValueError(msg)
if method == 'center':
use_exact = 0
subpixels = 1
elif method == 'subpixel':
use_exact = 0
elif method == 'exact':
use_exact = 1
subpixels = 1
return use_exact, subpixels
@property
@abc.abstractmethod
def _xy_extents(self):
"""
The (x, y) extents of the aperture measured from the center
position.
In other words, the (x, y) extents are half of the aperture
minimal bounding box size in each dimension.
"""
@lazyproperty
def _positions(self):
"""
The aperture positions, always as a 2D ndarray.
"""
return np.atleast_2d(self.positions)
@lazyproperty
def _bbox(self):
"""
The minimal bounding box for the aperture, always as a list of
`~photutils.aperture.BoundingBox` instances.
"""
x_delta, y_delta = self._xy_extents
xmin = self._positions[:, 0] - x_delta
xmax = self._positions[:, 0] + x_delta
ymin = self._positions[:, 1] - y_delta
ymax = self._positions[:, 1] + y_delta
return [BoundingBox.from_float(x0, x1, y0, y1)
for x0, x1, y0, y1 in zip(xmin, xmax, ymin, ymax, strict=True)]
@lazyproperty
def bbox(self):
"""
The minimal bounding box for the aperture.
If the aperture is scalar then a single
`~photutils.aperture.BoundingBox` is returned, otherwise a list
of `~photutils.aperture.BoundingBox` is returned.
"""
if self.isscalar:
return self._bbox[0]
return self._bbox
@lazyproperty
def _centered_edges(self):
"""
A list of ``(xmin, xmax, ymin, ymax)`` tuples, one for each
position, of the pixel edges after recentering the aperture at
the origin.
These pixel edges are used by the low-level `photutils.geometry`
functions.
"""
edges = []
for position, bbox in zip(self._positions, self._bbox, strict=True):
xmin = bbox.ixmin - 0.5 - position[0]
xmax = bbox.ixmax - 0.5 - position[0]
ymin = bbox.iymin - 0.5 - position[1]
ymax = bbox.iymax - 0.5 - position[1]
edges.append((xmin, xmax, ymin, ymax))
return edges
@property
@abc.abstractmethod
def area(self):
"""
The exact geometric area of the aperture shape.
Use the `area_overlap` method to return the area of overlap
between the data and the aperture, taking into account the
aperture mask method, masked data pixels (``mask`` keyword), and
partial/no overlap of the aperture with the data.
Returns
-------
area : float
The aperture area.
See Also
--------
area_overlap
"""
[docs]
@_update_method_subpixels_docstring
def area_overlap(self, data, *, mask=None, method='exact', subpixels=5):
# numpydoc ignore: PR01,PR02,PR04,PR07
"""
Return the area of overlap between the data and the aperture.
This method takes into account the aperture mask method, masked
data pixels (``mask`` keyword), and partial/no overlap of the
aperture with the data. In other words, it returns the area that
used to compute the aperture sum (assuming identical inputs).
Use the `area` method to calculate the exact analytical area of
the aperture shape.
Parameters
----------
data : array_like or `~astropy.units.Quantity`
A 2D array.
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 the area overlap.
<method_subpixels_descriptions>
Returns
-------
areas : float or array_like
The area (in pixels**2) of overlap between the data and the
aperture.
See Also
--------
area
"""
apermasks = self.to_mask(method=method, subpixels=subpixels)
if self.isscalar:
apermasks = (apermasks,)
if mask is not None:
mask = np.asarray(mask)
if mask.shape != data.shape:
msg = 'mask and data must have the same shape'
raise ValueError(msg)
areas = []
for apermask in apermasks:
slc_large, slc_small = apermask.get_overlap_slices(data.shape)
# If the aperture does not overlap the data, return np.nan
if slc_large is None:
area = np.nan
else:
aper_weights = apermask.data[slc_small]
if mask is not None:
aper_weights[mask[slc_large]] = 0.0
area = np.sum(aper_weights)
areas.append(area)
areas = np.array(areas)
if self.isscalar:
return areas[0]
return areas
[docs]
@_update_method_subpixels_docstring
@deprecated_positional_kwargs(since='3.0', until='4.0')
def to_mask(self, method='exact', subpixels=5):
"""
Return a mask for the aperture.
Parameters
----------
<method_subpixels_descriptions>
Returns
-------
mask : `~photutils.aperture.ApertureMask` or list of \
`~photutils.aperture.ApertureMask`
A mask for the aperture. If the aperture is scalar then
a single `~photutils.aperture.ApertureMask` is returned,
otherwise a list of `~photutils.aperture.ApertureMask` is
returned.
"""
use_exact, subpixels = self._translate_mask_method(method, subpixels)
masks = []
for bbox, edges in zip(self._bbox, self._centered_edges, strict=True):
ny, nx = bbox.shape
overlap = self._compute_overlap(
edges, nx, ny, use_exact, subpixels)
masks.append(ApertureMask(overlap, bbox))
if self.isscalar:
return masks[0]
return masks
@abc.abstractmethod
def _compute_overlap(self, edges, nx, ny, use_exact, subpixels):
"""
Compute the overlap of the aperture for a single position.
Parameters
----------
edges : tuple of float
The ``(xmin, xmax, ymin, ymax)`` pixel edges centered at
the origin.
nx, ny : int
The number of pixels in x and y.
use_exact : int
Whether to use exact method (1) or not (0).
subpixels : int
The number of subpixels for subpixel method.
Returns
-------
overlap : 2D `~numpy.ndarray`
The overlap array.
"""
def _mask_photometry(self, data, *, error, mask, method, subpixels,
segmentation=None, labels=None,
mask_method='none', mask_nonfinite=False):
"""
Perform aperture photometry using per-source aperture masks.
This is the fallback code path for apertures or inputs that are
not supported by the batch Cython driver. It also handles the
``mask_method='correct'`` segmentation masking for apertures
(e.g., `PolygonAperture`) or statistics (e.g., `ApertureStats`)
that do not use the batch driver.
Parameters
----------
data : `~numpy.ndarray`
The 2D array on which to perform photometry, with any units
already stripped.
error, mask, method, subpixels
See `photometry`. Any units must already be stripped from
``error``.
segmentation, labels, mask_method
The validated segmentation array, per-aperture source
labels, and masking method (see
`~photutils.aperture._segmentation.process_segmentation_inputs`).
mask_nonfinite : bool, optional
Whether to mask non-finite ``data`` values (matching
`ApertureStats`) instead of leaving them in the computation,
where they corrupt the sum (the 3.0.0 behavior, used by the
legacy `aperture_photometry` function). In both cases, the
non-finite pixels are flagged as ``non_finite_data``.
Returns
-------
aperture_sums, aperture_sum_errs, areas : `~numpy.ndarray`
The aperture sums, errors, and total unmasked overlap areas.
flag_counts : `~numpy.ndarray`
The per-source pixel counts for the quality flags, with one
row per source and the columns given by the ``FLAG_COL_*``
constants in `photutils.aperture._batch_photometry`,
with semantics identical to the batch driver (the
``FLAG_COL_BBOX_CLIPPED`` column is a candidate indicator
resolved by the caller, and the non-finite columns are 0/1
indicators).
overlap : `~numpy.ndarray` (bool)
Whether the aperture bounding box overlaps the data.
"""
apermasks = self.to_mask(method=method, subpixels=subpixels)
if self.isscalar:
apermasks = (apermasks,)
positions = np.atleast_2d(self.positions)
n_src = len(apermasks)
aperture_sums = []
aperture_sum_errs = []
areas = []
flag_counts = np.zeros((n_src, 8), dtype=np.intp)
overlap = np.zeros(n_src, dtype=bool)
with warnings.catch_warnings():
# Ignore multiplication with non-finite data values
warnings.simplefilter('ignore', RuntimeWarning)
for idx, apermask in enumerate(apermasks):
(slc_large,
aper_weights,
pixel_mask) = apermask._get_overlap_cutouts(data.shape,
mask=mask)
# No overlap of the aperture with the data
if slc_large is None:
aperture_sums.append(np.nan)
aperture_sum_errs.append(np.nan)
areas.append(np.nan)
continue
overlap[idx] = True
weighted = aper_weights > 0
w_in = np.count_nonzero(weighted)
flag_counts[idx, FLAG_COL_NPIX] = w_in
flag_counts[idx, FLAG_COL_BBOX_CLIPPED] = (
aper_weights.shape != apermask.data.shape)
if mask is not None:
flag_counts[idx, FLAG_COL_MASKED] = np.count_nonzero(
weighted & mask[slc_large])
data_cutout = data[slc_large]
error_cutout = None if error is None else error[slc_large]
# Non-finite ``data`` masking (used by the class
# path). Flag the contributing non-finite pixels, then
# exclude them *before* the segmentation handling so
# the segmentation counts and the 'correct' mirror
# replacement never use non-finite pixels, matching the
# batch kernel (mask plane bit 2) and `ApertureStats`.
# When ``mask_nonfinite`` is `False` (the legacy
# function), the non-finite pixels are left in the
# computation so they corrupt the sum.
nonfinite_data = ~np.isfinite(data_cutout)
if mask_nonfinite:
flag_counts[idx, FLAG_COL_NONFINITE_DATA] = np.any(
nonfinite_data & pixel_mask)
pixel_mask = pixel_mask & ~nonfinite_data
if segmentation is not None and mask_method != 'none':
segm_cutout = segmentation[slc_large]
base_mask = None if mask is None else mask[slc_large]
if mask_nonfinite:
# Fold the non-finite pixels into the base mask
# so that non-finite mirror pixels are treated
# as uncorrectable by the 'correct' method.
base_mask = (nonfinite_data if base_mask is None
else base_mask | nonfinite_data)
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(
mask_method, segm_cutout, labels[idx],
data=data_cutout, error=error_cutout,
base_mask=base_mask, cutout_xycen=cutout_xycen)
flag_counts[idx, FLAG_COL_SEG] = np.count_nonzero(
affected & pixel_mask)
if mask_method == 'correct':
# In 'correct' mode, the excluded pixels are
# exactly the uncorrectable neighbor pixels
flag_counts[idx, FLAG_COL_UNCORRECTED] = (
np.count_nonzero(exclude & pixel_mask))
pixel_mask = pixel_mask & ~exclude
flag_counts[idx, FLAG_COL_VALID] = np.count_nonzero(
pixel_mask)
if not mask_nonfinite:
flag_counts[idx, FLAG_COL_NONFINITE_DATA] = np.any(
nonfinite_data & pixel_mask)
if error is not None:
flag_counts[idx, FLAG_COL_NONFINITE_ERROR] = np.any(
~np.isfinite(error_cutout) & pixel_mask)
values = (data_cutout * aper_weights)[pixel_mask]
aperture_sums.append(values.sum())
areas.append(aper_weights[pixel_mask].sum())
if error is not None:
variance = (error_cutout**2 * aper_weights)[pixel_mask]
aperture_sum_errs.append(np.sqrt(variance.sum()))
else:
aperture_sum_errs.append(np.nan)
return (np.array(aperture_sums), np.array(aperture_sum_errs),
np.array(areas), flag_counts, overlap)
def _batch_shape_params(self):
"""
The aperture shape code and parameters for the batch Cython
photometry driver.
Returns
-------
spec : tuple or `None`
A ``(shape_code, params)`` tuple, where
``shape_code`` is one of the shape codes defined in
`photutils.aperture._batch_photometry` and ``params``
is a tuple of the aperture shape parameters expected by
`~photutils.aperture._batch_photometry.batch_aperture_sums`
for that shape. `None` is returned if batch photometry is
not supported for this aperture, in which case the slower
mask-based code path is used.
Notes
-----
The batch driver is used only if this hook is defined in the
aperture instance's own class (see `_batch_photometry`),
so subclasses must define this method (e.g., by calling
``super()``) to opt in to the batch code path.
"""
return
def _batch_photometry(self, data, *, error, mask, method, subpixels,
segmentation=None, labels=None,
mask_method='none', mask_nonfinite=False):
"""
Perform aperture photometry using the batch Cython driver.
The batch driver computes results identical to the mask-based
code path, but without creating per-source mask arrays or making
per-source Python calls.
Parameters
----------
data : `~numpy.ndarray`
The 2D array on which to perform photometry, with any units
already stripped.
error, mask, method, subpixels
See `photometry`. Any units must already be stripped from
``error``.
segmentation, labels, mask_method
The validated segmentation array, per-aperture source
labels, and masking method (see
`~photutils.aperture._segmentation.process_segmentation_inputs`).
mask_nonfinite : bool, optional
Whether to mask non-finite ``data`` values (matching
`ApertureStats`) instead of leaving them in the computation,
where they corrupt the sum (the 3.0.0 behavior, used by the
legacy `aperture_photometry` function). In both cases, the
non-finite pixels are flagged as ``non_finite_data``.
Returns
-------
result : tuple of `~numpy.ndarray` or `None`
A ``(aperture_sums, aperture_sum_errs, areas, flag_counts,
overlap)`` tuple, or `None` if the batch driver does not
support this aperture or these inputs (in which case
the caller should use the mask-based code path). The
``flag_counts`` columns are given by the ``FLAG_COL_*``
constants in `photutils.aperture._batch_photometry`; on this
code path the ``FLAG_COL_BBOX_CLIPPED`` column is only a
candidate indicator (bounding box clipped by a data edge)
that the caller must resolve to the precise outside-weight
test (see `_resolve_outside_weights`).
"""
# Use the batch driver only if the aperture's own class defines
# the _batch_shape_params hook. Subclasses that do not define
# it may override other behavior (e.g., to_mask) that the batch
# driver would not honor, so they use the mask-based code path.
if '_batch_shape_params' not in type(self).__dict__:
return None
spec = self._batch_shape_params()
if spec is None:
return None
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
if mask is not None and (not isinstance(mask, np.ndarray)
or mask.dtype != bool
or mask.shape != data.shape):
return None
# Build a uint8 mask plane for the batch kernels. Bit 1 (value
# 1) marks input-masked pixels and bit 2 (value 2) marks
# non-finite ``data`` pixels; any nonzero value excludes the
# pixel. Folding the non-finite pixels into the plane lets
# the class exclude them from the sum, area, and valid-pixel
# count while still flagging them as ``non_finite_data``
# (not ``masked_pixels``), matching `ApertureStats`. When
# ``mask_nonfinite`` is `False` (the legacy function), the
# non-finite pixels are left in the data so they corrupt the sum
# (the 3.0.0 behavior).
plane = None
if mask is not None:
plane = mask.astype(np.uint8)
if mask_nonfinite and 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 = None if plane is None else np.ascontiguousarray(plane)
seg_arr = None
labels_arr = None
seg_code = 0
if segmentation is not None and mask_method != 'none':
seg_arr = np.ascontiguousarray(segmentation, dtype=np.intp)
labels_arr = np.ascontiguousarray(labels, dtype=np.intp)
seg_code = SEG_METHOD_CODES[mask_method]
use_exact, subpixels = self._translate_mask_method(method, subpixels)
shape_code, params = spec
if error is not None:
error = np.ascontiguousarray(error, dtype=np.float64)
ext_x, ext_y = self._xy_extents
sums, sum_var, area, overlap, *_, fcounts = batch_aperture_sums(
np.ascontiguousarray(data, dtype=np.float64), error, mask,
np.ascontiguousarray(self._positions, dtype=np.float64),
shape_code, np.array(params, dtype=np.float64),
float(ext_x), float(ext_y), use_exact, subpixels,
seg_arr, labels_arr, seg_code)
if error is None:
# Match the mask-based path, which returns an all-NaN error
# array (with the same length as the fluxes) when error is
# not input.
errs = np.full(sums.shape, np.nan)
else:
errs = np.sqrt(sum_var)
return sums, errs, area, fcounts, overlap
@_update_method_subpixels_docstring
def _photometry(self, data, *, error=None, mask=None, method='exact',
subpixels=5, segmentation_image=None, labels=None,
mask_method='none', mask_nonfinite=False):
# numpydoc ignore: PR01,PR02,PR04,PR07
"""
Perform aperture photometry on the input data.
Parameters
----------
data : array_like or `~astropy.units.Quantity` instance
The 2D array on which to perform photometry. ``data`` should
be background subtracted.
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``.
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>
<segmentation_descriptions>
mask_nonfinite : bool, optional
Whether to mask non-finite ``data`` values (matching
`ApertureStats`) instead of leaving them in the computation,
where they corrupt the sum (the 3.0.0 behavior, used by the
legacy `aperture_photometry` function). In both cases, the
non-finite pixels are flagged as ``non_finite_data``.
Returns
-------
result : `_ApertureResults`
The aperture photometry results. It has the following
attributes:
- ``flux`` : `~numpy.ndarray` or `~astropy.units.Quantity`
The sum within each aperture. The values are always
float64, regardless of the input ``data`` dtype.
- ``flux_err`` : `~numpy.ndarray` or `~astropy.units.Quantity`
The errors on the sum within each aperture. The values
are always float64, regardless of the input ``error``
dtype.
- ``area`` : `~astropy.units.Quantity`
The total unmasked overlap area of each aperture (in
``pix**2``), taking into account the aperture mask
method, masked data pixels, segmentation masking, and
partial/no overlap of the aperture with the data. This
is equivalent to `area_overlap` computed with the same
inputs. The value is ``NaN`` where the aperture does not
overlap the data.
- ``flags`` : `~numpy.ndarray`
The bitwise quality flags for each aperture. See
`~photutils.aperture.decode_aperture_flags` for decoding
flag values. The flags are:
<flag_descriptions>
"""
data = np.asanyarray(data)
if data.ndim != 2:
msg = 'data must be a 2D array'
raise ValueError(msg)
if error is not None:
error = np.asanyarray(error)
if error.shape != data.shape:
msg = 'error and data must have the same shape'
raise ValueError(msg)
# Check Quantity inputs
unit = {getattr(arr, 'unit', None) for arr in (data, error)
if arr is not None}
if len(unit) > 1:
msg = ('If data or error has units, then they both must have '
'the same units')
raise ValueError(msg)
# Strip data and error units for performance
unit = unit.pop()
if unit is not None:
unit = data.unit
data = data.value
if error is not None:
error = error.value
segmentation, labels = process_segmentation_inputs(
segmentation_image, labels, mask_method,
np.atleast_2d(self.positions), data.shape)
result = self._batch_photometry(
data, error=error, mask=mask, method=method, subpixels=subpixels,
segmentation=segmentation, labels=labels,
mask_method=mask_method, mask_nonfinite=mask_nonfinite)
if result is not None:
flux, flux_err, area, fcounts, overlap = result
else:
(flux, flux_err, area, fcounts, overlap) = self._mask_photometry(
data, error=error, mask=mask, method=method,
subpixels=subpixels, segmentation=segmentation,
labels=labels, mask_method=mask_method,
mask_nonfinite=mask_nonfinite)
# Resolve the precise outside-weight test only for the sources
# whose bounding box is clipped by a data edge
candidates = fcounts[:, FLAG_COL_BBOX_CLIPPED].astype(bool)
w_out = self._resolve_outside_weights(
data.shape, method=method, subpixels=subpixels,
candidates=candidates)
flags = _counts_to_flag_bits(fcounts, overlap, w_out)
# Apply units
if unit is not None:
flux <<= unit
flux_err <<= unit
# The area always has units of pix**2, regardless of whether
# data/error have units (matches ApertureStats.sum_aper_area).
area <<= (u.pix**2)
return _ApertureResults(flux=flux, flux_err=flux_err, area=area,
flags=flags)
[docs]
@_update_method_subpixels_docstring
@deprecated(since='3.1', alternative='AperturePhotometry', until='4.0')
def do_photometry(self, data, error=None, mask=None, method='exact',
subpixels=5):
# numpydoc ignore: PR01,PR02,PR04,PR07
"""
Perform aperture photometry on the input data.
.. deprecated:: 3.1
Use `~photutils.aperture.AperturePhotometry` instead.
Parameters
----------
data : array_like or `~astropy.units.Quantity` instance
The 2D array on which to perform photometry. ``data`` should
be background subtracted.
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``.
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>
Returns
-------
aperture_sums : `~numpy.ndarray` or `~astropy.units.Quantity`
The sum within each aperture.
aperture_sum_errs : `~numpy.ndarray` or `~astropy.units.Quantity`
The errors on the sum within each aperture.
"""
result = self._photometry(data, error=error, mask=mask,
method=method, subpixels=subpixels)
return result.flux, result.flux_err
def _resolve_outside_weights(self, shape, *, method, subpixels,
candidates):
"""
Whether each aperture has nonzero mask weights outside the data.
The precise per-source test is evaluated (via per-source
aperture masks) only for the candidate sources, i.e., those
whose bounding box is clipped by a data edge; the result is
`False` for all other sources. Interior sources are never
candidates, so no aperture masks are built for them.
Parameters
----------
shape : tuple of int
The shape of the data array.
method, subpixels
See `photometry`.
candidates : `~numpy.ndarray` (bool)
Whether each aperture bounding box is clipped by a data
edge.
Returns
-------
w_out : `~numpy.ndarray` (bool)
Whether each aperture has one or more pixels with nonzero
aperture weight outside the data.
"""
# For the 'exact' method the minimal bounding box is tight (the
# aperture is tangent to each bbox side), so a bbox that is
# clipped by a data edge always leaves a positive-area sliver of
# the aperture outside the data. The precise outside-weight test
# therefore agrees exactly with the bbox-clipped candidates, and
# no per-source aperture masks need to be built.
if method == 'exact':
return candidates.copy()
w_out = np.zeros(candidates.shape, dtype=bool)
idx = np.flatnonzero(candidates)
if idx.size == 0:
return w_out
sub = self if self.isscalar else self[idx]
apermasks = sub.to_mask(method=method, subpixels=subpixels)
if sub.isscalar:
apermasks = [apermasks]
for i, apermask in zip(idx, apermasks, strict=True):
n_total = np.count_nonzero(apermask.data)
slc_large, slc_small = apermask.get_overlap_slices(shape)
if slc_large is None:
w_out[i] = n_total > 0
else:
n_in = np.count_nonzero(apermask.data[slc_small])
w_out[i] = n_total > n_in
return w_out
@staticmethod
def _make_annulus_path(patch_inner, patch_outer):
"""
Define a matplotlib annulus path from two patches.
This preserves the cubic Bézier curves (CURVE4) of the aperture
paths.
"""
import matplotlib.path as mpath
path_inner = patch_inner.get_path()
transform_inner = patch_inner.get_transform()
path_inner = transform_inner.transform_path(path_inner)
path_outer = patch_outer.get_path()
transform_outer = patch_outer.get_transform()
path_outer = transform_outer.transform_path(path_outer)
verts_inner = path_inner.vertices[:-1][::-1]
verts_inner = np.concatenate((verts_inner, [verts_inner[-1]]))
verts = np.vstack((path_outer.vertices, verts_inner))
codes = np.hstack((path_outer.codes, path_inner.codes))
return mpath.Path(verts, codes)
def _define_patch_params(self, *, origin=(0, 0), **kwargs):
"""
Define the aperture patch position and set any default
matplotlib patch keywords (e.g., ``fill=False``).
Parameters
----------
origin : array_like, optional
The ``(x, y)`` position of the origin of the displayed
image.
**kwargs : dict, optional
Any keyword arguments accepted by
`matplotlib.patches.Patch`.
Returns
-------
xy_positions : `~numpy.ndarray`
The aperture patch positions.
patch_params : dict
Any keyword arguments accepted by
`matplotlib.patches.Patch`.
"""
xy_positions = deepcopy(self._positions)
xy_positions[:, 0] -= origin[0]
xy_positions[:, 1] -= origin[1]
patch_params = self._default_patch_properties.copy()
patch_params.update(kwargs)
return xy_positions, patch_params
@abc.abstractmethod
def _to_patch(self, *, origin=(0, 0), **kwargs):
"""
Return a `~matplotlib.patches.Patch` for the aperture.
Parameters
----------
origin : array_like, optional
The ``(x, y)`` position of the origin of the displayed
image.
**kwargs : dict, optional
Any keyword arguments accepted by
`matplotlib.patches.Patch`.
Returns
-------
patch : `~matplotlib.patches.Patch` or list of \
`~matplotlib.patches.Patch`
A patch for the aperture. If the aperture is scalar then a
single `~matplotlib.patches.Patch` is returned, otherwise a
list of `~matplotlib.patches.Patch` is returned.
"""
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def plot(self, ax=None, origin=(0, 0), **kwargs):
"""
Plot the aperture on a matplotlib `~matplotlib.axes.Axes`
instance.
Parameters
----------
ax : `matplotlib.axes.Axes` or `None`, optional
The matplotlib axes on which to plot. If `None`, then the
current `~matplotlib.axes.Axes` instance is used.
origin : array_like, optional
The ``(x, y)`` position of the origin of the displayed
image.
**kwargs : dict, optional
Any keyword arguments accepted by
`matplotlib.patches.Patch`.
Returns
-------
patch : list of `~matplotlib.patches.Patch`
A list of matplotlib patches for the plotted aperture. The
patches can be used, for example, when adding a plot legend.
"""
import matplotlib.pyplot as plt
if ax is None:
ax = plt.gca()
patches = self._to_patch(origin=origin, **kwargs)
if self.isscalar:
patches = (patches,)
for patch in patches:
ax.add_patch(patch)
return patches
[docs]
@abc.abstractmethod
def to_sky(self, wcs):
"""
Convert the aperture to a `SkyAperture` object defined in
celestial coordinates.
Parameters
----------
wcs : WCS object
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`).
Returns
-------
aperture : `SkyAperture` object
A `SkyAperture` object.
"""
[docs]
class SkyAperture(Aperture):
"""
Abstract base class for all apertures defined in celestial
coordinates.
"""
[docs]
@abc.abstractmethod
def to_pixel(self, wcs):
"""
Convert the aperture to a `PixelAperture` object defined in
pixel coordinates.
Parameters
----------
wcs : WCS object
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`).
Returns
-------
aperture : `PixelAperture` object
A `PixelAperture` object.
"""
def _aperture_metadata(aperture, *, index=''):
"""
Return a dictionary of aperture metadata.
Parameters
----------
aperture : `Aperture`
An aperture object.
index : str, optional
A string that will be prepended to each metadata key.
Returns
-------
meta : dict
A dictionary of aperture metadata
"""
params = aperture._params
meta = {}
meta[f'aperture{index}'] = aperture.__class__.__name__
for param in params:
if param != 'positions':
meta[f'aperture{index}_{param}'] = getattr(aperture, param)
return meta