# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
Circular and circular-annulus apertures in both pixel and sky
coordinates.
"""
import math
import astropy.units as u
import numpy as np
from astropy.coordinates import Angle
from astropy.utils import lazyproperty
from photutils.aperture._batch_photometry import (SHAPE_CIRCLE,
SHAPE_CIRCULAR_ANNULUS)
from photutils.aperture.attributes import (PixelPositions, PositiveScalar,
PositiveScalarAngle,
SkyCoordPositions)
from photutils.aperture.core import (PixelAperture, SkyAperture,
_update_method_subpixels_docstring)
from photutils.aperture.mask import ApertureMask
from photutils.aperture.polygon import PolygonAperture, SkyPolygonAperture
from photutils.geometry import circular_overlap_grid
from photutils.utils._deprecation import deprecated
from photutils.utils._wcs_helpers import (pixel_to_sky_mean_scale,
sky_to_pixel_mean_scale)
__all__ = [
'CircularAnnulus',
'CircularAperture',
'CircularMaskMixin',
'SkyCircularAnnulus',
'SkyCircularAperture',
]
def _circular_polygon_offsets(r, n_vertices):
"""
Compute the vertex offsets that approximate a circle of radius ``r``
using ``n_vertices`` equally spaced vertices.
``r`` may be a plain number (pixel offsets) or a
`~astropy.units.Quantity` (angular offsets); the returned offsets
carry the same type.
Parameters
----------
r : float or `~astropy.units.Quantity`
The radius of the circle.
n_vertices : int
The number of polygon vertices used to approximate the circle.
Returns
-------
offsets : 2D `~numpy.ndarray`
The vertex offsets that approximate the circle. The shape is
``(n_vertices, 2)``, where the second axis corresponds to the
``(x, y)`` offsets. The offsets are in the same units as the
input ``r``.
"""
n_vertices = int(n_vertices)
if n_vertices < 3:
msg = f'n_vertices must be at least 3, got {n_vertices}'
raise ValueError(msg)
theta = np.linspace(0.0, 2.0 * np.pi, n_vertices, endpoint=False)
return np.column_stack([np.cos(theta), np.sin(theta)]) * r
[docs]
@deprecated('3.0')
class CircularMaskMixin: # pragma: no cover
"""
Mixin class to create masks for circular and circular-annulus
aperture objects.
.. deprecated:: 3.0
"""
[docs]
@_update_method_subpixels_docstring
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)
if hasattr(self, 'r'):
radius = self.r
elif hasattr(self, 'r_out'): # annulus
radius = self.r_out
else:
msg = 'Cannot determine the aperture radius'
raise ValueError(msg)
masks = []
for bbox, edges in zip(self._bbox, self._centered_edges, strict=True):
ny, nx = bbox.shape
mask = circular_overlap_grid(edges[0], edges[1], edges[2],
edges[3], nx, ny, radius, use_exact,
subpixels)
# Subtract the inner circle for an annulus
if hasattr(self, 'r_in'):
mask -= circular_overlap_grid(edges[0], edges[1], edges[2],
edges[3], nx, ny, self.r_in,
use_exact, subpixels)
masks.append(ApertureMask(mask, bbox))
if self.isscalar:
return masks[0]
return masks
[docs]
class CircularAperture(PixelAperture):
"""
A circular aperture defined in pixel coordinates.
The aperture has a single fixed size/shape, but it can have multiple
positions (see the ``positions`` input).
Parameters
----------
positions : array_like
The pixel coordinates of the aperture center(s) in one of the
following formats:
* single ``(x, y)`` pair as a tuple, list, or `~numpy.ndarray`
* tuple, list, or `~numpy.ndarray` of ``(x, y)`` pairs
r : float
The radius of the circle in pixels.
Raises
------
ValueError : `ValueError`
If the input radius, ``r``, is negative.
Examples
--------
>>> from photutils.aperture import CircularAperture
>>> aper = CircularAperture([10.0, 20.0], 3.0)
>>> aper = CircularAperture((10.0, 20.0), 3.0)
>>> pos1 = (10.0, 20.0) # (x, y)
>>> pos2 = (30.0, 40.0)
>>> pos3 = (50.0, 60.0)
>>> aper = CircularAperture([pos1, pos2, pos3], 3.0)
>>> aper = CircularAperture((pos1, pos2, pos3), 3.0)
"""
_params = ('positions', 'r')
positions = PixelPositions('The center pixel position(s).')
r = PositiveScalar('The radius in pixels.')
def __init__(self, positions, r):
self.positions = positions
self.r = r
@lazyproperty
def _xy_extents(self):
return self.r, self.r
def _batch_shape_params(self):
return SHAPE_CIRCLE, (self.r,)
@lazyproperty
def area(self):
"""
The exact geometric area of the aperture shape.
"""
return math.pi * self.r**2
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.
"""
import matplotlib.patches as mpatches
xy_positions, patch_kwargs = self._define_patch_params(origin=origin,
**kwargs)
patches = [mpatches.Circle(xy_position, self.r, **patch_kwargs)
for xy_position in xy_positions]
if self.isscalar:
return patches[0]
return patches
def _compute_overlap(self, edges, nx, ny, use_exact, subpixels):
"""
Compute the overlap of the aperture on the pixel grid.
Parameters
----------
edges : list of 4 1D `~numpy.ndarray`
The edges of the pixel grid in the form of
``[x_edges, y_edges, x_centers, y_centers]``.
nx, ny : int
The number of pixels in the x and y directions.
use_exact : bool
Whether to use the exact method for calculating the overlap.
subpixels : int
The number of subpixels to use in each dimension for the
subpixel method.
Returns
-------
overlap : 2D `~numpy.ndarray`
The overlap of the aperture on the pixel grid. The values
will be between 0 and 1, where 0 means no overlap and 1
means full overlap.
"""
return circular_overlap_grid(edges[0], edges[1], edges[2],
edges[3], nx, ny, self.r,
use_exact, subpixels)
[docs]
def to_sky(self, wcs):
"""
Convert the aperture to a `SkyCircularAperture` 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 : `SkyCircularAperture` object
A `SkyCircularAperture` object.
Notes
-----
The aperture shape parameters are converted using the local
WCS pixel scale evaluated at the first aperture position.
Because aperture objects require scalar shape parameters, only
a single reference position is used for the conversion. For
apertures with multiple positions used with a WCS that has
spatially-varying distortions, this may produce inaccurate
results for positions far from the first position.
"""
xpos, ypos = np.transpose(self.positions)
positions = wcs.pixel_to_world(xpos, ypos)
first_pos = np.atleast_2d(self.positions)[0]
_, mean_scale = pixel_to_sky_mean_scale(
(float(first_pos[0]), float(first_pos[1])), wcs)
r = Angle(self.r * mean_scale, 'arcsec')
return SkyCircularAperture(positions=positions, r=r)
[docs]
def to_polygon(self, *, n_vertices=100):
"""
Return a `~photutils.aperture.PolygonAperture` that
approximates this circular aperture.
Parameters
----------
n_vertices : int, optional
The number of polygon vertices used to approximate the
circle. Must be at least 3. Default is 100.
Returns
-------
aperture : `~photutils.aperture.PolygonAperture`
A polygon aperture that approximates the circle.
"""
offsets = _circular_polygon_offsets(self.r, n_vertices)
return PolygonAperture._from_convex_offsets(self.positions, offsets)
[docs]
class CircularAnnulus(PixelAperture):
"""
A circular annulus aperture defined in pixel coordinates.
The aperture has a single fixed size/shape, but it can have multiple
positions (see the ``positions`` input).
Parameters
----------
positions : array_like
The pixel coordinates of the aperture center(s) in one of the
following formats:
* single ``(x, y)`` pair as a tuple, list, or `~numpy.ndarray`
* tuple, list, or `~numpy.ndarray` of ``(x, y)`` pairs
r_in : float
The inner radius of the circular annulus in pixels.
r_out : float
The outer radius of the circular annulus in pixels.
Raises
------
ValueError : `ValueError`
If inner radius (``r_in``) is greater than outer radius
(``r_out``).
ValueError : `ValueError`
If inner radius (``r_in``) is negative.
Examples
--------
>>> from photutils.aperture import CircularAnnulus
>>> aper = CircularAnnulus([10.0, 20.0], 3.0, 5.0)
>>> aper = CircularAnnulus((10.0, 20.0), 3.0, 5.0)
>>> pos1 = (10.0, 20.0) # (x, y)
>>> pos2 = (30.0, 40.0)
>>> pos3 = (50.0, 60.0)
>>> aper = CircularAnnulus([pos1, pos2, pos3], 3.0, 5.0)
>>> aper = CircularAnnulus((pos1, pos2, pos3), 3.0, 5.0)
"""
_params = ('positions', 'r_in', 'r_out')
positions = PixelPositions('The center pixel position(s).')
r_in = PositiveScalar('The inner radius in pixels.')
r_out = PositiveScalar('The outer radius in pixels.')
def __init__(self, positions, r_in, r_out):
if not r_out > r_in:
msg = "'r_out' must be greater than 'r_in'"
raise ValueError(msg)
self.positions = positions
self.r_in = r_in
self.r_out = r_out
@lazyproperty
def _xy_extents(self):
return self.r_out, self.r_out
def _batch_shape_params(self):
return SHAPE_CIRCULAR_ANNULUS, (self.r_in, self.r_out)
@lazyproperty
def area(self):
"""
The exact geometric area of the aperture shape.
"""
return math.pi * (self.r_out**2 - self.r_in**2)
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.
"""
import matplotlib.patches as mpatches
xy_positions, patch_kwargs = self._define_patch_params(origin=origin,
**kwargs)
patches = []
for xy_position in xy_positions:
patch_inner = mpatches.Circle(xy_position, self.r_in)
patch_outer = mpatches.Circle(xy_position, self.r_out)
path = self._make_annulus_path(patch_inner, patch_outer)
patches.append(mpatches.PathPatch(path, **patch_kwargs))
if self.isscalar:
return patches[0]
return patches
def _compute_overlap(self, edges, nx, ny, use_exact, subpixels):
"""
Compute the overlap of the aperture on the pixel grid.
Parameters
----------
edges : list of 4 1D `~numpy.ndarray`
The edges of the pixel grid in the form of
``[x_edges, y_edges, x_centers, y_centers]``.
nx, ny : int
The number of pixels in the x and y directions.
use_exact : bool
Whether to use the exact method for calculating the overlap.
subpixels : int
The number of subpixels to use in each dimension for the
subpixel method.
Returns
-------
overlap : 2D `~numpy.ndarray`
The overlap of the aperture on the pixel grid. The values
will be between 0 and 1, where 0 means no overlap and 1
means full overlap.
"""
overlap = circular_overlap_grid(edges[0], edges[1], edges[2],
edges[3], nx, ny, self.r_out,
use_exact, subpixels)
overlap -= circular_overlap_grid(edges[0], edges[1], edges[2],
edges[3], nx, ny, self.r_in,
use_exact, subpixels)
return overlap
[docs]
def to_sky(self, wcs):
"""
Convert the aperture to a `SkyCircularAnnulus` 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 : `SkyCircularAnnulus` object
A `SkyCircularAnnulus` object.
Notes
-----
The aperture shape parameters are converted using the local
WCS pixel scale evaluated at the first aperture position.
Because aperture objects require scalar shape parameters, only
a single reference position is used for the conversion. For
apertures with multiple positions used with a WCS that has
spatially-varying distortions, this may produce inaccurate
results for positions far from the first position.
"""
xpos, ypos = np.transpose(self.positions)
positions = wcs.pixel_to_world(xpos, ypos)
first_pos = np.atleast_2d(self.positions)[0]
_, mean_scale = pixel_to_sky_mean_scale(
(float(first_pos[0]), float(first_pos[1])), wcs)
r_in = Angle(self.r_in * mean_scale, 'arcsec')
r_out = Angle(self.r_out * mean_scale, 'arcsec')
return SkyCircularAnnulus(positions=positions, r_in=r_in, r_out=r_out)
[docs]
class SkyCircularAperture(SkyAperture):
"""
A circular aperture defined in sky coordinates.
The aperture has a single fixed size/shape, but it can have multiple
positions (see the ``positions`` input).
Parameters
----------
positions : `~astropy.coordinates.SkyCoord`
The celestial coordinates of the aperture center(s). This can be
either scalar coordinates or an array of coordinates.
r : scalar `~astropy.units.Quantity`
The radius of the circle in angular units.
Examples
--------
>>> from astropy.coordinates import SkyCoord
>>> import astropy.units as u
>>> from photutils.aperture import SkyCircularAperture
>>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg')
>>> aper = SkyCircularAperture(positions, 0.5*u.arcsec)
"""
_params = ('positions', 'r')
positions = SkyCoordPositions('The center position(s) in sky coordinates.')
r = PositiveScalarAngle('The radius in angular units.')
def __init__(self, positions, r):
self.positions = positions
self.r = r
[docs]
def to_pixel(self, wcs):
"""
Convert the aperture to a `CircularAperture` 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 : `CircularAperture` object
A `CircularAperture` object.
Notes
-----
The aperture shape parameters are converted using the local
WCS pixel scale evaluated at the first aperture position.
Because aperture objects require scalar shape parameters, only
a single reference position is used for the conversion. For
apertures with multiple positions used with a WCS that has
spatially-varying distortions, this may produce inaccurate
results for positions far from the first position.
"""
xpos, ypos = wcs.world_to_pixel(self.positions)
positions = np.transpose((xpos, ypos))
skypos = self.positions if self.isscalar else self.positions[0]
_, mean_scale = sky_to_pixel_mean_scale(skypos, wcs)
r = self.r.to_value(u.arcsec) * mean_scale
return CircularAperture(positions=positions, r=r)
[docs]
def to_polygon(self, *, n_vertices=100):
"""
Return a `~photutils.aperture.SkyPolygonAperture` that
approximates this circular aperture.
Parameters
----------
n_vertices : int, optional
The number of polygon vertices used to approximate the
circle. Must be at least 3. Default is 100.
Returns
-------
aperture : `~photutils.aperture.SkyPolygonAperture`
A sky polygon aperture that approximates the circle.
"""
offsets = _circular_polygon_offsets(self.r, n_vertices)
return SkyPolygonAperture._from_convex_offsets(self.positions,
offsets)
[docs]
class SkyCircularAnnulus(SkyAperture):
"""
A circular annulus aperture defined in sky coordinates.
The aperture has a single fixed size/shape, but it can have multiple
positions (see the ``positions`` input).
Parameters
----------
positions : `~astropy.coordinates.SkyCoord`
The celestial coordinates of the aperture center(s). This can be
either scalar coordinates or an array of coordinates.
r_in : scalar `~astropy.units.Quantity`
The inner radius of the circular annulus in angular units.
r_out : scalar `~astropy.units.Quantity`
The outer radius of the circular annulus in angular units.
Examples
--------
>>> from astropy.coordinates import SkyCoord
>>> import astropy.units as u
>>> from photutils.aperture import SkyCircularAnnulus
>>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg')
>>> aper = SkyCircularAnnulus(positions, 0.5*u.arcsec, 1.0*u.arcsec)
"""
_params = ('positions', 'r_in', 'r_out')
positions = SkyCoordPositions('The center position(s) in sky coordinates.')
r_in = PositiveScalarAngle('The inner radius in angular units.')
r_out = PositiveScalarAngle('The outer radius in angular units.')
def __init__(self, positions, r_in, r_out):
if not r_out > r_in:
msg = "'r_out' must be greater than 'r_in'"
raise ValueError(msg)
self.positions = positions
self.r_in = r_in
self.r_out = r_out
[docs]
def to_pixel(self, wcs):
"""
Convert the aperture to a `CircularAnnulus` 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 : `CircularAnnulus` object
A `CircularAnnulus` object.
Notes
-----
The aperture shape parameters are converted using the local
WCS pixel scale evaluated at the first aperture position.
Because aperture objects require scalar shape parameters, only
a single reference position is used for the conversion. For
apertures with multiple positions used with a WCS that has
spatially-varying distortions, this may produce inaccurate
results for positions far from the first position.
"""
xpos, ypos = wcs.world_to_pixel(self.positions)
positions = np.transpose((xpos, ypos))
skypos = self.positions if self.isscalar else self.positions[0]
_, mean_scale = sky_to_pixel_mean_scale(skypos, wcs)
r_in = self.r_in.to_value(u.arcsec) * mean_scale
r_out = self.r_out.to_value(u.arcsec) * mean_scale
return CircularAnnulus(positions=positions, r_in=r_in, r_out=r_out)