Source code for regions.shapes.circle

# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
This module defines circular regions in both pixel and sky coordinates.
"""

import math

import astropy.units as u
import numpy as np
from astropy.coordinates import Angle

from regions._geometry import circular_overlap_grid
from regions._utils.spherical_helpers import (
    get_circle_latitude_tangent_limits, get_circle_longitude_tangent_limits)
from regions._utils.wcs_helpers import (pixel_to_sky_mean_scale,
                                        pixel_to_sky_svd_scales,
                                        sky_to_pixel_mean_scale,
                                        sky_to_pixel_svd_scales)
from regions.core.attributes import (PositiveScalar, PositiveScalarAngle,
                                     RegionMetaDescr, RegionVisualDescr,
                                     ScalarPixCoord, ScalarSkyCoord)
from regions.core.bounding_box import RegionBoundingBox
from regions.core.core import PixelRegion, SkyRegion, SphericalSkyRegion
from regions.core.mask import RegionMask
from regions.core.metadata import RegionMeta, RegionVisual
from regions.core.pixcoord import PixCoord
from regions.shapes.ellipse import EllipsePixelRegion, EllipseSkyRegion
from regions.shapes.polygon import PolygonPixelRegion

__all__ = ['CirclePixelRegion', 'CircleSkyRegion', 'CircleSphericalSkyRegion']


[docs] class CirclePixelRegion(PixelRegion): """ A circle defined using pixel coordinates. Parameters ---------- center : `~regions.PixCoord` The center position. radius : float The radius in pixels. meta : `~regions.RegionMeta` or `dict`, optional A dictionary that stores the meta attributes of the region. visual : `~regions.RegionVisual` or `dict`, optional A dictionary that stores the visual meta attributes of the region. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from regions import CirclePixelRegion, PixCoord fig, ax = plt.subplots() reg = CirclePixelRegion(PixCoord(x=8, y=7), radius=3.5) patch = reg.plot(ax=ax, facecolor='none', edgecolor='red', lw=2, label='Circle') ax.legend(handles=(patch,), loc='upper center') ax.set_xlim(0, 15) ax.set_ylim(0, 15) ax.set_aspect('equal') """ _params = ('center', 'radius') _mpl_artist = 'Patch' center = ScalarPixCoord('The center pixel position as a |PixCoord|.') radius = PositiveScalar('The radius in pixels as a float.') meta = RegionMetaDescr('The meta attributes as a |RegionMeta|') visual = RegionVisualDescr('The visual attributes as a |RegionVisual|.') def __init__(self, center, radius, meta=None, visual=None): self.center = center self.radius = radius self.meta = meta or RegionMeta() self.visual = visual or RegionVisual() @property def area(self): return math.pi * self.radius ** 2 def _containment(self, pixcoord, covers=False): """ Helper method for circle containment checks. """ pixcoord = PixCoord._validate(pixcoord, name='pixcoord') sep = self.center.separation(pixcoord) if covers: return sep <= self.radius return sep < self.radius
[docs] def contains(self, pixcoord): return self._containment(pixcoord, covers=False)
[docs] def covers(self, pixcoord): return self._containment(pixcoord, covers=True)
[docs] def to_sky(self, wcs, *, as_ellipse=False): """ Return a sky region from this pixel region. 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`). as_ellipse : bool, optional If `True`, return an `~regions.EllipseSkyRegion` instead of a `~regions.CircleSkyRegion`. An ellipse is generally a better approximation when the WCS has distortions or different pixel scales along different axes. Default is `False`. Returns ------- region : `~regions.CircleSkyRegion` or `~regions.EllipseSkyRegion` The sky region. An ellipse is returned if ``as_ellipse`` is `True`. """ if as_ellipse: center, scale_major, scale_minor, angle = pixel_to_sky_svd_scales( (self.center.x, self.center.y), wcs) width = Angle(2 * self.radius * scale_major, 'arcsec') height = Angle(2 * self.radius * scale_minor, 'arcsec') # The helper returns a position angle (PA) from North; # regions measures the angle from the RA axis (90 deg # offset). angle = (angle + 90 * u.deg).wrap_at(360 * u.deg) return EllipseSkyRegion(center, width, height, angle=angle, meta=self.meta.copy(), visual=self.visual.copy()) center, mean_scale = pixel_to_sky_mean_scale( (self.center.x, self.center.y), wcs) radius = Angle(self.radius * mean_scale, 'arcsec') return CircleSkyRegion(center, radius, meta=self.meta.copy(), visual=self.visual.copy())
[docs] def to_spherical_sky(self, wcs=None, include_boundary_distortions=False, n_points=None): self._validate_planar_spherical_transform(wcs, include_boundary_distortions) if include_boundary_distortions: # Requires planar to spherical projection (using WCS) and discretization # Will require implementing discretization in pixel space # to get correct handling of distortions. raise NotImplementedError # ### Potential solution: # # Leverage polygon class to_spherical_sky() functionality without # # distortions, as the distortions were already computed in creating # # that polygon approximation # return self.discretize_boundary(n_points=npoints).to_spherical_sky( # wcs=wcs, include_boundary_distortions=False # ) return self.to_sky(wcs).to_spherical_sky()
@property def bounding_box(self): """ Bounding box (`~regions.RegionBoundingBox`). """ xmin = self.center.x - self.radius xmax = self.center.x + self.radius ymin = self.center.y - self.radius ymax = self.center.y + self.radius return RegionBoundingBox.from_float(xmin, xmax, ymin, ymax)
[docs] def to_mask(self, mode='center', subpixels=1): self._validate_mode(mode, subpixels) if mode == 'center': mode = 'subpixels' subpixels = 1 # Find bounding box and mask size bbox = self.bounding_box ny, nx = bbox.shape # Find position of pixel edges and recenter so that circle is at # origin xmin = float(bbox.ixmin) - 0.5 - self.center.x xmax = float(bbox.ixmax) - 0.5 - self.center.x ymin = float(bbox.iymin) - 0.5 - self.center.y ymax = float(bbox.iymax) - 0.5 - self.center.y use_exact = 0 if mode == 'subpixels' else 1 fraction = circular_overlap_grid(xmin, xmax, ymin, ymax, nx, ny, self.radius, use_exact, subpixels) return RegionMask(fraction, bbox=bbox)
[docs] def as_artist(self, origin=(0, 0), **kwargs): """ Return a matplotlib patch object for this region (`matplotlib.patches.Circle`). Parameters ---------- origin : array_like, optional The ``(x, y)`` pixel position of the origin of the displayed image. **kwargs : dict Any keyword arguments accepted by `~matplotlib.patches.Circle`. These keywords will override any visual meta attributes of this region. Returns ------- artist : `~matplotlib.patches.Circle` A matplotlib circle patch. """ from matplotlib.patches import Circle xy = self.center.x - origin[0], self.center.y - origin[1] radius = self.radius mpl_kwargs = self.visual.define_mpl_kwargs(self._mpl_artist) mpl_kwargs.update(kwargs) return Circle(xy=xy, radius=radius, **mpl_kwargs)
[docs] def rotate(self, center, angle): """ Rotate the region. Positive ``angle`` corresponds to counter-clockwise rotation. Parameters ---------- center : `~regions.PixCoord` The rotation center point. angle : `~astropy.coordinates.Angle` The rotation angle. Returns ------- region : `CirclePixelRegion` The rotated region (which is an independent copy). """ center = self.center.rotate(center, angle) return self.copy(center=center)
[docs] def to_polygon(self, n_points=100): """ Return a `~regions.PolygonPixelRegion` that approximates this circle. Parameters ---------- n_points : int, optional The number of polygon vertices. Default is 100. Returns ------- polygon : `~regions.PolygonPixelRegion` A polygon region approximating the circle. """ theta = np.linspace(0, 2 * np.pi, n_points, endpoint=False) x = self.radius * np.cos(theta) + self.center.x y = self.radius * np.sin(theta) + self.center.y vertices = PixCoord(x=x, y=y) return PolygonPixelRegion(vertices=vertices, meta=self.meta.copy(), visual=self.visual.copy())
[docs] class CircleSkyRegion(SkyRegion): """ A circle defined using sky coordinates. Parameters ---------- center : `~astropy.coordinates.SkyCoord` The center position. radius : `~astropy.units.Quantity` The radius in angular units. meta : `~regions.RegionMeta` or `dict`, optional A dictionary that stores the meta attributes of the region. visual : `~regions.RegionVisual` or `dict`, optional A dictionary that stores the visual meta attributes of the region. """ _params = ('center', 'radius') center = ScalarSkyCoord('The center position as a |SkyCoord|.') radius = PositiveScalarAngle('The radius as a |Quantity| angle.') meta = RegionMetaDescr('The meta attributes as a |RegionMeta|') visual = RegionVisualDescr('The visual attributes as a |RegionVisual|.') def __init__(self, center, radius, meta=None, visual=None): self.center = center self.radius = radius self.meta = meta or RegionMeta() self.visual = visual or RegionVisual()
[docs] def to_pixel(self, wcs, *, as_ellipse=False): """ Return a pixel region from this sky region. 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`). as_ellipse : bool, optional If `True`, return an `~regions.EllipsePixelRegion` instead of a `~regions.CirclePixelRegion`. An ellipse is generally a better approximation when the WCS has distortions or different pixel scales along different axes. Default is `False`. Returns ------- region : `~regions.CirclePixelRegion` or `~regions.EllipsePixelRegion` The pixel region. An ellipse is returned if ``as_ellipse`` is `True`. """ if as_ellipse: center, scale_major, scale_minor, angle = sky_to_pixel_svd_scales( self.center, wcs) radius_arcsec = self.radius.to(u.arcsec).value width = 2 * radius_arcsec * scale_major height = 2 * radius_arcsec * scale_minor return EllipsePixelRegion(PixCoord(*center), width, height, angle=angle, meta=self.meta.copy(), visual=self.visual.copy()) center, mean_scale = sky_to_pixel_mean_scale(self.center, wcs) radius = self.radius.to(u.arcsec).value * mean_scale return CirclePixelRegion(PixCoord(*center), radius, meta=self.meta.copy(), visual=self.visual.copy())
[docs] def to_polygon(self, wcs, n_points=100): """ Return a `~regions.PolygonSkyRegion` that approximates this circle. Parameters ---------- wcs : `~astropy.wcs.WCS` The WCS to use for the sky-to-pixel-to-sky conversion. n_points : int, optional The number of polygon vertices. Default is 100. Returns ------- polygon : `~regions.PolygonSkyRegion` A polygon region approximating the circle. """ return self.to_pixel(wcs).to_polygon(n_points=n_points).to_sky(wcs)
[docs] def to_spherical_sky(self, wcs=None, include_boundary_distortions=False, n_points=None): self._validate_planar_spherical_transform(wcs, include_boundary_distortions) if include_boundary_distortions: # Requires planar to spherical projection (using WCS) and discretization # Will require implementing discretization in pixel space # to get correct handling of distortions. raise NotImplementedError # ### Potential solution: # # Leverage polygon class to_spherical_sky() functionality without # # distortions, as the distortions were already computed in creating # # that polygon approximation # return self.to_pixel(wcs).discretize_boundary(n_points=n_points).to_spherical_sky( # wcs=wcs, include_boundary_distortions=False # ) return CircleSphericalSkyRegion( self.center, self.radius, meta=self.meta, visual=self.visual, )
[docs] class CircleSphericalSkyRegion(SphericalSkyRegion): """ Class for a circular sky region, where the circle is interpreted within a spherical geometry reference frame. Parameters ---------- center : `~astropy.coordinates.SkyCoord` The center position. radius : `~astropy.units.Quantity` The radius in angular units. meta : `~regions.RegionMeta` or `dict`, optional A dictionary that stores the meta attributes of the region. visual : `~regions.RegionVisual` or `dict`, optional A dictionary that stores the visual meta attributes of the region. """ _params = ('center', 'radius') center = ScalarSkyCoord('The center position as a |SkyCoord|.') radius = PositiveScalarAngle('The radius as a |Quantity| angle.') meta = RegionMetaDescr('The meta attributes as a |RegionMeta|') visual = RegionVisualDescr('The visual attributes as a |RegionVisual|.') def __init__(self, center, radius, meta=None, visual=None): self.center = center self.radius = radius self.meta = meta or RegionMeta() self.visual = visual or RegionVisual() def _containment(self, coord, covers=False): sep = self.center.separation(coord) if covers: return sep <= self.radius return sep < self.radius
[docs] def contains(self, coord): return self._containment(coord, covers=False)
[docs] def covers(self, coord): return self._containment(coord, covers=True)
@property def bounding_circle(self): return self.copy() @property def bounding_lonlat(self): lons_arr = get_circle_longitude_tangent_limits(self.center, self.radius) lats_arr = get_circle_latitude_tangent_limits(self.center, self.radius) lons_arr, lats_arr = self._validate_lonlat_bounds(lons_arr, lats_arr) return lons_arr, lats_arr
[docs] def transform_to(self, frame, merge_attributes=True): frame = self._validate_frame_transformation(frame) center_transf = self.center.transform_to( frame, merge_attributes=merge_attributes, ) return CircleSphericalSkyRegion( center_transf, self.radius.copy(), self.meta.copy(), self.visual.copy(), )
[docs] def discretize_boundary(self, n_points=100): # Avoid circular imports: from .polygon import PolygonSphericalSkyRegion theta = np.linspace(0, 1, num=n_points, endpoint=False) * 360 * u.deg # Need to invert order because of CW convention: bound_verts = self.center.directional_offset_by(theta[::-1], self.radius) return PolygonSphericalSkyRegion(bound_verts, meta=self.meta.copy(), visual=self.visual.copy())
[docs] def to_polygon(self, n_points=100): """ Return a `~regions.PolygonSphericalSkyRegion` that approximates this circle. Parameters ---------- n_points : int, optional The number of polygon vertices. Default is 100. Returns ------- polygon : `~regions.PolygonSphericalSkyRegion` A polygon region approximating the circle. """ return self.discretize_boundary(n_points=n_points)
[docs] def to_sky( self, wcs=None, include_boundary_distortions=False, n_points=None, ): self._validate_planar_spherical_transform(wcs, include_boundary_distortions) if include_boundary_distortions: # Requires spherical to planar projection (from WCS) and discretization # Use to_pixel(), then apply "small angle approx" to get planar sky. return self.to_pixel( include_boundary_distortions=include_boundary_distortions, wcs=wcs, n_points=n_points, ).to_sky(wcs) return CircleSkyRegion( self.center, self.radius, meta=self.meta, visual=self.visual, )
[docs] def to_pixel( self, wcs, include_boundary_distortions=False, n_points=None, ): self._validate_planar_spherical_transform(wcs, include_boundary_distortions) if include_boundary_distortions: from .polygon import PolygonPixelRegion disc_kwargs = {} if n_points is None else {'n_points': n_points} # Requires spherical to planar projection (from WCS) and discretization verts = wcs.world_to_pixel( self.discretize_boundary(**disc_kwargs).vertices, ) return PolygonPixelRegion( PixCoord(*verts), meta=self.meta.copy(), visual=self.visual.copy(), ) return self.to_sky().to_pixel(wcs)