# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
Tools for centroiding sources.
"""
import inspect
import warnings
import numpy as np
from astropy.nddata import overlap_slices
from astropy.utils.exceptions import AstropyUserWarning
from photutils.centroids._utils import _process_data_mask
from photutils.utils._deprecation import (deprecated_positional_kwargs,
deprecated_renamed_argument)
from photutils.utils._parameters import as_pair
from photutils.utils._quantity_helpers import process_quantities
from photutils.utils._repr import make_repr
from photutils.utils._round import round_half_away
__all__ = ['CentroidQuadratic', 'centroid_com', 'centroid_quadratic',
'centroid_sources']
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def centroid_com(data, mask=None):
"""
Calculate the centroid of an array as the flux-weighted
center of mass derived from `image moments
<https://en.wikipedia.org/wiki/Image_moment>`_.
Non-finite values (e.g., NaN or inf) in the ``data`` array are
automatically masked. The final mask is a logical OR combination
of the input ``mask``, the automatically generated mask for
non-finite values, and the mask of the input ``data`` if it is a
`~numpy.ma.MaskedArray`. The centroid is calculated using only the
unmasked data values.
Parameters
----------
data : array_like
The input n-dimensional array. ``data`` can be a
`~numpy.ma.MaskedArray`. The image should be a
background-subtracted cutout image containing a single
source. The source should be significantly stronger than the
background noise. If the data contains nearly equal positive and
negative values (i.e., the sum is close to zero), the centroid
calculation will be numerically unstable and may produce
undefined results that fall outside the array bounds.
mask : bool `~numpy.ndarray`, optional
A boolean mask, with the same shape as ``data``, where a `True`
value indicates the corresponding element of ``data`` is masked.
If ``data`` is a `~numpy.ma.MaskedArray`, its mask will be
combined (using bitwise OR) with the input ``mask``.
Returns
-------
centroid : `~numpy.ndarray`
The coordinates of the centroid in pixel order (e.g., ``(x, y)``
or ``(x, y, z)``), not numpy axis order. If the absolute value
of the sum of the (unmasked) data is smaller than 1e-30 (i.e.,
consistent with zero), then a `~numpy.ndarray` of NaN values
will be returned. If the sum is close to zero, the centroid may
be poorly defined and fall outside the array bounds.
Notes
-----
The centroid is calculated as:
.. math::
x_c = \\frac{\\sum x_i I_i}{\\sum I_i}, \\quad
y_c = \\frac{\\sum y_i I_i}{\\sum I_i}
where :math:`I_i` is the intensity at pixel :math:`(x_i, y_i)`.
Examples
--------
>>> import numpy as np
>>> from photutils.datasets import make_4gaussians_image
>>> from photutils.centroids import centroid_com
>>> data = make_4gaussians_image()
>>> data -= np.median(data[0:30, 0:125])
>>> data = data[40:80, 70:110]
>>> x1, y1 = centroid_com(data)
>>> print(np.array((x1, y1)))
[19.9796724 20.00992593]
.. plot::
import matplotlib.pyplot as plt
import numpy as np
from photutils.centroids import centroid_com
from photutils.datasets import make_4gaussians_image
data = make_4gaussians_image()
data -= np.median(data[0:30, 0:125])
data = data[40:80, 70:110]
xycen = centroid_com(data)
fig, ax = plt.subplots(figsize=(8, 8))
ax.imshow(data, origin='lower')
ax.scatter(*xycen, color='red', marker='+', s=100, label='Centroid')
ax.legend()
"""
(data,), _ = process_quantities((data,), ('data',))
data = _process_data_mask(data, mask, ndim=None, fill_value=0.0)
total = np.sum(data)
if abs(total) < 1.e-30:
return np.full(data.ndim, np.nan)
indices = np.ogrid[tuple(slice(0, i) for i in data.shape)]
# Output array is reversed to give (x, y) order (e.g., for 2D data)
return np.array([np.sum(indices[axis] * data) / total
for axis in range(data.ndim)])[::-1]
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
@deprecated_renamed_argument('xpeak', None, '3.0', until='4.0')
@deprecated_renamed_argument('ypeak', None, '3.0', until='4.0')
@deprecated_renamed_argument('search_boxsize', None, '3.0', until='4.0')
def centroid_quadratic(data, mask=None, fit_boxsize=5, xpeak=None,
ypeak=None, search_boxsize=None):
"""
Calculate the centroid of a 2D array by fitting a 2D quadratic
polynomial.
Non-finite values (e.g., NaN or inf) in the ``data`` array are
automatically masked. The final mask is a logical OR combination
of the input ``mask``, the automatically generated mask for
non-finite values, and the mask of the input ``data`` if it is a
`~numpy.ma.MaskedArray`. The centroid is calculated using only the
unmasked data values.
A second degree 2D polynomial is fit within a small region of the
data defined by ``fit_boxsize`` to calculate the centroid position.
The initial center of the fitting box can be specified using the
``xpeak`` and ``ypeak`` keywords. If both ``xpeak`` and ``ypeak``
are `None`, then the box will be centered at the position of the
maximum value in the input ``data``.
If ``xpeak`` and ``ypeak`` are specified, the ``search_boxsize``
optional keyword can be used to further refine the initial center of
the fitting box by searching for the position of the maximum pixel
within a box of size ``search_boxsize``.
`Vakili & Hogg (2016) <https://arxiv.org/abs/1610.05873>`_
demonstrate that 2D quadratic centroiding comes very
close to saturating the `Cramér-Rao lower bound
<https://en.wikipedia.org/wiki/Cram%C3%A9r%E2%80%93Rao_bound>`_ in a
wide range of conditions.
Parameters
----------
data : 2D array_like
The 2D image data. ``data`` can be a `~numpy.ma.MaskedArray`.
The image should be a background-subtracted cutout image
containing a single source.
mask : 2D bool `~numpy.ndarray`, optional
A boolean mask, with the same shape as ``data``, where a `True`
value indicates the corresponding element of ``data`` is masked.
Masked data are excluded from calculations. If ``data`` is
a `~numpy.ma.MaskedArray`, its mask will be combined (using
bitwise OR) with the input ``mask``.
fit_boxsize : int or tuple of int, optional
The size (in pixels) of the box used to define the fitting
region. If ``fit_boxsize`` has two elements, they must be in
``(ny, nx)`` order. If ``fit_boxsize`` is a scalar then a square
box of size ``fit_boxsize`` will be used. ``fit_boxsize`` must
have odd values for both axes.
xpeak, ypeak : float or `None`, optional
The initial guess of the position of the centroid. If either
``xpeak`` or ``ypeak`` is `None` then the position of the
maximum value in the input ``data`` will be used as the initial
guess.
.. deprecated:: 3.0
The ``xpeak`` and ``ypeak`` keywords are deprecated
and will be removed in a future version. Use
`~photutils.centroids.centroid_sources` to centroid sources
at specific positions.
search_boxsize : int or tuple of int, optional
The size (in pixels) of the box used to search for the maximum
pixel value if ``xpeak`` and ``ypeak`` are both specified. If
``search_boxsize`` has two elements, they must be in ``(ny,
nx)`` order. If ``search_boxsize`` is a scalar then a square
box of size ``search_boxsize`` will be used. ``search_boxsize``
must have odd values for both axes. This parameter is ignored
if either ``xpeak`` or ``ypeak`` is `None`. In that case, the
entire array is searched for the maximum value.
.. deprecated:: 3.0
The ``search_boxsize`` keyword is deprecated
and will be removed in a future version. Use
`~photutils.centroids.centroid_sources` to centroid sources
at specific positions.
Returns
-------
centroid : `~numpy.ndarray`
The ``x, y`` coordinates of the centroid.
Notes
-----
Use ``fit_boxsize = (3, 3)`` to match the work of `Vakili &
Hogg (2016) <https://arxiv.org/abs/1610.05873>`_ for their 2D
second-order polynomial centroiding method.
Because this centroid is based on fitting data, it can fail for many
reasons, returning (np.nan, np.nan):
* quadratic fit failed
* quadratic fit does not have a maximum
* quadratic fit maximum falls outside image
* not enough unmasked data points (6 are required)
A `ValueError` is raised if all data values are masked or
non-finite.
Also note that a fit is not performed if the maximum data value is
at the edge of the data. In this case, the position of the maximum
pixel will be returned.
References
----------
.. [1] Vakili and Hogg 2016, "Do fast stellar centroiding methods
saturate the Cramér-Rao lower bound?", `arXiv:1610.05873
<https://arxiv.org/abs/1610.05873>`_
Examples
--------
>>> import numpy as np
>>> from photutils.datasets import make_4gaussians_image
>>> from photutils.centroids import centroid_quadratic
>>> data = make_4gaussians_image()
>>> data -= np.median(data[0:30, 0:125])
>>> data = data[40:80, 70:110]
>>> x1, y1 = centroid_quadratic(data)
>>> print(np.array((x1, y1)))
[19.94009505 20.06884997]
.. plot::
import matplotlib.pyplot as plt
import numpy as np
from photutils.centroids import centroid_quadratic
from photutils.datasets import make_4gaussians_image
data = make_4gaussians_image()
data -= np.median(data[0:30, 0:125])
data = data[40:80, 70:110]
xycen = centroid_quadratic(data)
fig, ax = plt.subplots(figsize=(8, 8))
ax.imshow(data, origin='lower')
ax.scatter(*xycen, color='red', marker='+', s=100, label='Centroid')
ax.legend()
"""
(data,), _ = process_quantities((data,), ('data',))
if ((xpeak is None and ypeak is not None)
or (xpeak is not None and ypeak is None)):
msg = 'xpeak and ypeak must both be input or "None"'
raise ValueError(msg)
data = _process_data_mask(data, mask)
ny, nx = data.shape
if not np.any(np.isfinite(data)):
msg = 'All data values are masked or non-finite'
raise ValueError(msg)
fit_boxsize = as_pair('fit_boxsize', fit_boxsize, lower_bound=(0, 0),
upper_bound=data.shape, check_odd=True)
if np.prod(fit_boxsize) < 6:
msg = ('fit_boxsize is too small. 6 values are required to fit a '
'2D quadratic polynomial.')
raise ValueError(msg)
if xpeak is not None and ((xpeak < 0) or (xpeak > data.shape[1] - 1)):
msg = 'xpeak is outside the input data'
raise ValueError(msg)
if ypeak is not None and ((ypeak < 0) or (ypeak > data.shape[0] - 1)):
msg = 'ypeak is outside the input data'
raise ValueError(msg)
if xpeak is None or ypeak is None:
yidx, xidx = np.unravel_index(np.nanargmax(data), data.shape)
else:
xidx = round_half_away(xpeak)
yidx = round_half_away(ypeak)
if search_boxsize is not None:
search_boxsize = as_pair('search_boxsize', search_boxsize,
lower_bound=(0, 0),
upper_bound=data.shape, check_odd=True)
slc_data, _ = overlap_slices(data.shape, search_boxsize,
(yidx, xidx), mode='trim')
cutout = data[slc_data]
yidx, xidx = np.unravel_index(np.nanargmax(cutout), cutout.shape)
xidx += slc_data[1].start
yidx += slc_data[0].start
# Return the position of the maximum if it is at the edge of the
# data
if xidx in (0, nx - 1) or yidx in (0, ny - 1):
msg = ('maximum value is at the edge of the data and its '
'position was returned; no quadratic fit was performed')
warnings.warn(msg, AstropyUserWarning)
return np.array((xidx, yidx), dtype=float)
# Extract the fitting region
slc_data, _ = overlap_slices(data.shape, fit_boxsize, (yidx, xidx),
mode='trim')
xidx0, xidx1 = (slc_data[1].start, slc_data[1].stop)
yidx0, yidx1 = (slc_data[0].start, slc_data[0].stop)
# Shift the fitting box if it was clipped by the data edge
if (xidx1 - xidx0) < fit_boxsize[1]:
if xidx0 == 0:
xidx1 = min(nx, xidx0 + fit_boxsize[1])
if xidx1 == nx:
xidx0 = max(0, xidx1 - fit_boxsize[1])
if (yidx1 - yidx0) < fit_boxsize[0]:
if yidx0 == 0:
yidx1 = min(ny, yidx0 + fit_boxsize[0])
if yidx1 == ny:
yidx0 = max(0, yidx1 - fit_boxsize[0])
cutout = data[yidx0:yidx1, xidx0:xidx1].ravel()
if np.count_nonzero(~np.isnan(cutout)) < 6:
msg = ('at least 6 unmasked data points are required to '
'perform a 2D quadratic fit')
warnings.warn(msg, AstropyUserWarning)
return np.array((np.nan, np.nan))
# Fit a 2D quadratic polynomial to the fitting region. The fit
# coordinates are centered on the peak pixel to keep the design
# matrix well conditioned. With absolute coordinates the condition
# number grows as ~coordinate**4 and the fit fails for sources at
# large pixel coordinates (e.g., in large mosaic images).
xi = np.arange(xidx0, xidx1) - xidx
yi = np.arange(yidx0, yidx1) - yidx
x, y = np.meshgrid(xi, yi)
x = x.ravel()
y = y.ravel()
# Pre-allocate coefficient matrix for optimization
coeff_matrix = np.empty((x.size, 6), dtype=float)
coeff_matrix[:, 0] = 1
coeff_matrix[:, 1] = x
coeff_matrix[:, 2] = y
coeff_matrix[:, 3] = x * y
coeff_matrix[:, 4] = x * x
coeff_matrix[:, 5] = y * y
# Include only finite values in the fit.
finite_mask = np.isfinite(cutout)
if not np.all(finite_mask):
coeff_matrix = coeff_matrix[finite_mask]
cutout = cutout[finite_mask]
try:
c = np.linalg.lstsq(coeff_matrix, cutout, rcond=None)[0]
except np.linalg.LinAlgError:
msg = 'quadratic fit failed'
warnings.warn(msg, AstropyUserWarning)
return np.array((np.nan, np.nan))
# Analytically find the maximum of the polynomial
_, c10, c01, c11, c20, c02 = c
det = 4 * c20 * c02 - c11**2
# If the determinant is <= 0, the surface has a saddle point. If
# the determinant is > 0, the surface has a minimum or maximum. The
# curvature is negative (maximum) if c20 < 0 and c02 < 0. However,
# if det > 0, then 4 * c20 * c02 > c11**2 >= 0, so c20 and c02 must
# have the same sign. Therefore, we only need to check if c20 > 0
# (or c02 > 0) to determine if the surface has a minimum.
if det <= 0 or c20 > 0:
msg = 'quadratic fit does not have a maximum'
warnings.warn(msg, AstropyUserWarning)
return np.array((np.nan, np.nan))
# Add back the peak-pixel offset to convert the analytic maximum
# from fit coordinates to data coordinates
xm = (c01 * c11 - 2.0 * c02 * c10) / det + xidx
ym = (c10 * c11 - 2.0 * c20 * c01) / det + yidx
if 0.0 < xm < (nx - 1.0) and 0.0 < ym < (ny - 1.0):
xycen = np.array((xm, ym), dtype=float)
else:
msg = 'quadratic polynomial maximum value falls outside of the image'
warnings.warn(msg, AstropyUserWarning)
return np.array((np.nan, np.nan))
return xycen
[docs]
class CentroidQuadratic:
"""
Class to calculate the centroid of a 2D array by fitting a 2D
quadratic polynomial.
This class provides a callable interface to the
`~photutils.centroids.centroid_quadratic` function, allowing a
centroid function with specific fit parameters to be defined and
reused. This is useful, for example, when using a customized
centroid function with `~photutils.centroids.centroid_sources`.
Parameters
----------
fit_boxsize : int or tuple of int, optional
The size (in pixels) of the box used to define the fitting
region. If ``fit_boxsize`` has two elements, they must be in
``(ny, nx)`` order. If ``fit_boxsize`` is a scalar then a square
box of size ``fit_boxsize`` will be used. ``fit_boxsize`` must
have odd values for both axes.
Examples
--------
>>> import numpy as np
>>> from photutils.datasets import make_4gaussians_image
>>> from photutils.centroids import CentroidQuadratic
>>> data = make_4gaussians_image()
>>> data -= np.median(data[0:30, 0:125])
>>> data = data[40:80, 70:110]
>>> centroid_func = CentroidQuadratic(fit_boxsize=5)
>>> x1, y1 = centroid_func(data)
>>> print(np.array((x1, y1)))
[19.94009505 20.06884997]
Using with `~photutils.centroids.centroid_sources`::
>>> from photutils.centroids import centroid_sources
>>> data = make_4gaussians_image()
>>> data -= np.median(data[0:30, 0:125])
>>> x_init = (25, 91, 151, 160)
>>> y_init = (40, 61, 24, 71)
>>> centroid_func = CentroidQuadratic(fit_boxsize=3)
>>> x, y = centroid_sources(data, x_init, y_init, box_size=25,
... centroid_func=centroid_func)
"""
def __init__(self, *, fit_boxsize=5):
self.fit_boxsize = fit_boxsize
def __repr__(self):
return make_repr(self, ['fit_boxsize'])
def __str__(self):
return make_repr(self, ['fit_boxsize'], long=True)
[docs]
def __call__(self, data, *, mask=None):
"""
Calculate the centroid.
Non-finite values (e.g., NaN or inf) in the ``data`` array
are automatically masked. The automatically masked values are
combined (using bitwise OR) with the input ``mask``. If ``data``
is a `~numpy.ma.MaskedArray`, its mask will also be combined
(using bitwise OR) with the input ``mask``.
Parameters
----------
data : 2D array_like
The 2D image data. ``data`` can be a
`~numpy.ma.MaskedArray`. The image should be a
background-subtracted cutout image containing a single
source.
mask : 2D bool `~numpy.ndarray`, optional
A boolean mask, with the same shape as ``data``, where a
`True` value indicates the corresponding element of ``data``
is masked. If ``data`` is a `~numpy.ma.MaskedArray`, its
mask will be combined (using bitwise OR) with the input
``mask``. Masked data are excluded from calculations.
Returns
-------
centroid : `~numpy.ndarray`
The ``x, y`` coordinates of the centroid.
Notes
-----
Unlike `~photutils.centroids.centroid_1dg` and
`~photutils.centroids.centroid_2dg`, this method does not
support an error array.
"""
kwargs = {'mask': mask,
'fit_boxsize': self.fit_boxsize,
}
return centroid_quadratic(data, **kwargs)
[docs]
@deprecated_positional_kwargs(since='3.0', until='4.0')
def centroid_sources(data, xpos, ypos, box_size=11, footprint=None,
mask=None, centroid_func=centroid_com, **kwargs):
"""
Calculate the centroid of sources at the defined positions in a 2D
array using a specified centroid function.
A cutout image centered on each input position will be used to
calculate the centroid position. The cutout image is defined either
using the ``box_size`` or ``footprint`` keyword. The ``footprint``
keyword can be used to create a non-rectangular cutout image.
Masks and non-finite values are handled by the input
``centroid_func``. When using a centroid function provided by
Photutils, non-finite values (e.g., NaN or inf) in the ``data``
array are automatically masked. The ``centroid_1dg`` and
``centroid_2dg`` functions also automatically mask any pixels with
non-finite ``error`` array values. The final mask is a logical OR
combination of the input ``mask``, the automatically generated
mask(s) for non-finite values, and the mask of the input ``data`` if
it is a `~numpy.ma.MaskedArray`. The centroid is calculated using
only the unmasked data values.
Parameters
----------
data : 2D array_like
The 2D image data. ``data`` can be a `~numpy.ma.MaskedArray`.
The image should be background-subtracted.
xpos, ypos : float or array_like of float
The initial ``x`` and ``y`` pixel position(s) of the center
position. A cutout image centered on this position will be used
to calculate the centroid.
box_size : int or array_like of int, optional
The size of the cutout image along each axis. If ``box_size`` is
a number, then a square cutout of ``box_size`` will be created.
If ``box_size`` has two elements, they must be in ``(ny, nx)``
order. ``box_size`` must have odd values for both axes. Either
``box_size`` or ``footprint`` must be defined. If they are both
defined, then ``footprint`` overrides ``box_size``.
footprint : bool `~numpy.ndarray`, optional
A 2D boolean array where `True` values describe the local
footprint region to cutout. ``footprint`` can be used to create
a non-rectangular cutout image, in which case the input ``xpos``
and ``ypos`` represent the center of the minimal bounding box
for the input ``footprint``. ``box_size=(n, m)`` is equivalent
to ``footprint=np.ones((n, m))``. Either ``box_size`` or
``footprint`` must be defined. If they are both defined, then
``footprint`` overrides ``box_size``. The same ``footprint`` is
used for all sources.
mask : 2D bool `~numpy.ndarray`, optional
A 2D boolean array with the same shape as ``data``, where a
`True` value indicates the corresponding element of ``data`` is
masked. If ``data`` is a `~numpy.ma.MaskedArray`, its mask will
be combined (using bitwise OR) with the input ``mask``.
centroid_func : callable, optional
A callable object (e.g., function or class) that is used to
calculate the centroid of a 2D array. The ``centroid_func``
must accept a 2D `~numpy.ndarray`, have a ``mask`` keyword and
optionally an ``error`` keyword. A callable whose signature
accepts arbitrary keyword arguments (``**kwargs``) is assumed to
handle a ``mask`` keyword. The callable object must return two
scalar values representing the (x, y) centroid. The default is
`~photutils.centroids.centroid_com`.
**kwargs : dict, optional
Any additional keyword arguments accepted by the
``centroid_func``. A `TypeError` is raised for keyword arguments
not accepted by the ``centroid_func``.
Returns
-------
xcentroid, ycentroid : `~numpy.ndarray`
The ``x`` and ``y`` pixel position(s) of the centroids. NaNs
will be returned where the centroid failed. This is usually due
to a ``box_size`` that is too small when using a fitting-based
centroid function (e.g., `centroid_1dg`, `centroid_2dg`, or
`centroid_quadratic`).
Examples
--------
>>> import numpy as np
>>> from photutils.centroids import centroid_2dg, centroid_sources
>>> from photutils.datasets import make_4gaussians_image
>>> data = make_4gaussians_image()
>>> data -= np.median(data[0:30, 0:125])
>>> x_init = (25, 91, 151, 160)
>>> y_init = (40, 61, 24, 71)
>>> x, y = centroid_sources(data, x_init, y_init, box_size=25,
... centroid_func=centroid_2dg)
>>> print(x)
[ 24.96807828 89.98684636 149.96545721 160.18810915]
>>> print(y)
[40.03657613 60.01836631 24.96777946 69.80208702]
.. plot::
import matplotlib.pyplot as plt
import numpy as np
from photutils.centroids import centroid_2dg, centroid_sources
from photutils.datasets import make_4gaussians_image
data = make_4gaussians_image()
data -= np.median(data[0:30, 0:125])
x_init = (25, 91, 151, 160)
y_init = (40, 61, 24, 71)
x, y = centroid_sources(data, x_init, y_init, box_size=25,
centroid_func=centroid_2dg)
fig, ax = plt.subplots(figsize=(8, 4))
ax.imshow(data, origin='lower')
ax.scatter(x, y, marker='+', s=80, color='red', label='Centroids')
ax.legend()
fig.tight_layout()
"""
if np.ndim(data) != 2:
msg = 'data must be a 2D array'
raise ValueError(msg)
xpos = np.atleast_1d(xpos)
ypos = np.atleast_1d(ypos)
if xpos.ndim != 1:
msg = 'xpos must be a 1D array'
raise ValueError(msg)
if ypos.ndim != 1:
msg = 'ypos must be a 1D array'
raise ValueError(msg)
if len(xpos) != len(ypos):
msg = 'xpos and ypos must have the same length'
raise ValueError(msg)
if not (np.all(np.isfinite(xpos)) and np.all(np.isfinite(ypos))):
msg = 'xpos and ypos must contain only finite values'
raise ValueError(msg)
if (xpos.min() < 0 or ypos.min() < 0
or xpos.max() > data.shape[1] - 1
or ypos.max() > data.shape[0] - 1):
msg = 'xpos, ypos values contain points outside the input data'
raise ValueError(msg)
if footprint is None:
if box_size is None:
msg = 'box_size or footprint must be defined'
raise ValueError(msg)
box_size = as_pair('box_size', box_size, lower_bound=(0, 0),
check_odd=True)
footprint = np.ones(box_size, dtype=bool)
else:
footprint = np.asanyarray(footprint, dtype=bool)
if footprint.ndim != 2:
msg = 'footprint must be a 2D array'
raise ValueError(msg)
if not np.any(footprint):
msg = 'footprint must contain at least one True value'
raise ValueError(msg)
if mask is not None and mask.shape != data.shape:
msg = 'mask and data must have the same shape'
raise ValueError(msg)
# Setting error to None is equivalent to no error array, so allow it
# even for centroid functions that do not accept an error keyword
if kwargs.get('error') is None:
kwargs.pop('error', None)
# Allow arbitrary keyword arguments (**kwargs)
spec = inspect.signature(centroid_func)
accepts_var_keyword = any(param.kind == inspect.Parameter.VAR_KEYWORD
for param in spec.parameters.values())
if 'mask' not in spec.parameters and not accepts_var_keyword:
msg = "The input 'centroid_func' must have a 'mask' keyword."
raise ValueError(msg)
if not accepts_var_keyword:
unknown_keys = set(kwargs) - set(spec.parameters)
if unknown_keys:
msg = ('Unrecognized keyword argument(s) for the input '
f"'centroid_func': {sorted(unknown_keys)}")
raise TypeError(msg)
centroid_kwargs = dict(kwargs)
# Save the original error array so that each source independently
# slices the full-image array
error_array = centroid_kwargs.pop('error', None)
if error_array is not None and np.shape(error_array) != data.shape:
msg = 'error and data must have the same shape'
raise ValueError(msg)
# Extract xpeak/ypeak so the original absolute coordinates are
# available for every source. The per-source function below re-adds
# them with the correct cutout offset.
# Remove this block once xpeak and ypeak are fully deprecated.
xpeak_orig = centroid_kwargs.pop('xpeak', None)
ypeak_orig = centroid_kwargs.pop('ypeak', None)
inverted_footprint = np.logical_not(footprint)
def _centroid_source(xypos):
"""
Compute the centroid of the source at the given (x, y)
position.
"""
xp, yp = xypos
slices_large, slices_small = overlap_slices(data.shape,
footprint.shape, (yp, xp))
data_cutout = data[slices_large]
# Trim footprint mask if it has only partial overlap on the data
footprint_mask = inverted_footprint[slices_small]
if mask is not None:
# Combine the input mask cutout and footprint mask
mask_cutout = np.logical_or(mask[slices_large], footprint_mask)
else:
mask_cutout = footprint_mask
if np.all(mask_cutout):
msg = (f'The cutout for the source at ({xp}, {yp}) is completely '
'masked. Please check your input mask and footprint. '
'Also note that footprint must be a small, local '
'footprint.')
raise ValueError(msg)
# Build the per-source keyword arguments from a local copy so
# that no shared state is mutated across sources
src_kwargs = dict(centroid_kwargs)
src_kwargs['mask'] = mask_cutout
if error_array is not None:
src_kwargs['error'] = error_array[slices_large]
# Add xpeak/ypeak with the offset relative to this source's
# cutout.
# Remove this block once xpeak and ypeak are fully deprecated.
if xpeak_orig is not None and ypeak_orig is not None:
src_kwargs['xpeak'] = xpeak_orig - slices_large[1].start
src_kwargs['ypeak'] = ypeak_orig - slices_large[0].start
try:
xcen, ycen = centroid_func(data_cutout, **src_kwargs)
except (ValueError, TypeError) as exc:
msg = f'Centroid failed for source at ({xp}, {yp}): {exc}'
warnings.warn(msg, AstropyUserWarning)
xcen, ycen = np.nan, np.nan
return (xcen + slices_large[1].start,
ycen + slices_large[0].start)
results = [_centroid_source(xypos)
for xypos in zip(xpos, ypos, strict=True)]
results = np.array(results, dtype=float)
return results[:, 0], results[:, 1]