Source code for photutils.aperture.polygon

# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
Polygon apertures in both pixel and sky coordinates.
"""

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 SHAPE_POLYGON
from photutils.aperture.attributes import (ApertureAttribute, PixelPositions,
                                           SkyCoordPositions)
from photutils.aperture.core import PixelAperture, SkyAperture
from photutils.geometry._polygon_overlap import polygon_overlap_grid

__all__ = [
    'PolygonAperture',
    'SkyPolygonAperture',
]


def _signed_polygon_area(verts):
    """
    Calculate the signed area of a polygon (positive for
    counter-clockwise vertices) via the shoelace formula.

    Parameters
    ----------
    verts : `~numpy.ndarray`
        ``(n_vertices, 2)`` array of polygon vertices.

    Returns
    -------
    area : float
        The signed area of the polygon.
    """
    x = verts[:, 0]
    y = verts[:, 1]
    return 0.5 * float(np.sum(x * np.roll(y, -1) - np.roll(x, -1) * y))


def _orientation(a, b, c):
    """
    Return twice the signed area of the triangle ``a``, ``b``, ``c``.

    The result is positive if ``a -> b -> c`` traverses
    counter-clockwise, negative if clockwise, and zero if the three
    points are collinear.
    """
    return ((b[0] - a[0]) * (c[1] - a[1])
            - (b[1] - a[1]) * (c[0] - a[0]))


def _on_segment(a, b, c):
    """
    Return `True` if point ``c``, assumed collinear with ``a`` and
    ``b``, lies within the closed bounding box of the segment ``a b``.
    """
    return (min(a[0], b[0]) <= c[0] <= max(a[0], b[0])
            and min(a[1], b[1]) <= c[1] <= max(a[1], b[1]))


def _segments_intersect(p1, p2, p3, p4):
    """
    Return `True` if the closed line segments ``p1 p2`` and ``p3 p4``
    intersect, including the cases where they merely touch at a point or
    overlap collinearly.
    """
    d1 = _orientation(p3, p4, p1)
    d2 = _orientation(p3, p4, p2)
    d3 = _orientation(p1, p2, p3)
    d4 = _orientation(p1, p2, p4)

    # The segments straddle each other (proper crossing).
    if (((d1 > 0) and (d2 < 0)) or ((d1 < 0) and (d2 > 0))) and \
       (((d3 > 0) and (d4 < 0)) or ((d3 < 0) and (d4 > 0))):
        return True

    # Collinear / touching endpoints.
    if d1 == 0.0 and _on_segment(p3, p4, p1):
        return True
    if d2 == 0.0 and _on_segment(p3, p4, p2):
        return True
    if d3 == 0.0 and _on_segment(p1, p2, p3):
        return True
    return bool(d4 == 0.0 and _on_segment(p1, p2, p4))


def _polygon_is_simple(verts):
    """
    Return `True` if the closed polygon defined by ``verts`` is simple
    (no two non-adjacent edges intersect).

    Every pair of non-adjacent polygon edges is tested for intersection
    using orientation predicates, including the case where an endpoint
    of one edge lies on another edge. Adjacent edges (which share a
    vertex by construction) are not compared with each other, so a
    polygon that folds straight back onto an adjacent edge is not
    detected here; such degenerate cases are instead excluded by the
    zero-area check in `_validate_simple_polygon`.

    The cost is ``O(n^2)`` in the number of vertices ``n``. This is
    negligible for the polygon sizes used in aperture photometry, and
    the check is performed only once, when the attribute is set.

    Parameters
    ----------
    verts : `~numpy.ndarray`
        ``(n_vertices, 2)`` array of polygon vertices.

    Returns
    -------
    is_simple : bool
        `True` if the polygon is simple, `False` otherwise.
    """
    n = verts.shape[0]
    for i in range(n):
        a1 = verts[i]
        a2 = verts[(i + 1) % n]
        # Compare edge i only with later, non-adjacent edges. Edges i
        # and i+1 share a vertex, and (for i == 0) edges 0 and n-1 share
        # a vertex, so those pairs are skipped.
        for j in range(i + 2, n):
            if i == 0 and j == n - 1:
                continue
            b1 = verts[j]
            b2 = verts[(j + 1) % n]
            if _segments_intersect(a1, a2, b1, b2):
                return False
    return True


def _validate_simple_polygon(verts, *, name='vertex_offsets',
                             check_simple=True):
    """
    Verify that ``verts`` defines a simple, non-degenerate polygon.

    The polygon is required to be simple (i.e., non-self-intersecting),
    but it is not required to be convex. Self-intersection is checked
    explicitly with an ``O(n^2)`` edge-pair test (see
    `_polygon_is_simple`); this is inexpensive for the polygon sizes
    used in aperture photometry and runs only once, when the attribute
    is set.

    Parameters
    ----------
    verts : `~numpy.ndarray`
        ``(n_vertices, 2)`` array of polygon vertices.

    name : str
        Name to use in error messages.

    check_simple : bool, optional
        Whether to run the ``O(n^2)`` self-intersection test. Set to
        `False` only for offsets that are already known to define
        a simple polygon (e.g., a circle, ellipse, or rectangle
        discretized into vertices); the cheap shape, vertex-count,
        finiteness, and zero-area checks are still performed.

    Returns
    -------
    verts_ccw : `~numpy.ndarray`
        The vertices reordered to be counter-clockwise.
    """
    if verts.ndim != 2 or verts.shape[1] != 2:
        msg = (f'{name!r} must have shape (n_vertices, 2), '
               f'got {verts.shape}')
        raise ValueError(msg)

    n = verts.shape[0]
    if n < 3:
        msg = (f'{name!r} must define a polygon with at least 3 '
               f'vertices, got {n}')
        raise ValueError(msg)

    if not np.all(np.isfinite(verts)):
        msg = f'{name!r} must not contain any non-finite values'
        raise ValueError(msg)

    signed_area = _signed_polygon_area(verts)
    if signed_area == 0.0:
        msg = (f'{name!r} defines a degenerate polygon with zero '
               'area (vertices are collinear or coincident)')
        raise ValueError(msg)

    if check_simple and not _polygon_is_simple(verts):
        msg = (f'{name!r} must define a simple polygon, but its edges '
               'self-intersect')
        raise ValueError(msg)

    if signed_area < 0:
        verts = verts[::-1].copy()

    return verts


class PixelVertexOffsets(ApertureAttribute):
    """
    Validate and set ``vertex_offsets`` for `PolygonAperture`.

    The value is converted to an ``(n_vertices, 2)`` `~numpy.ndarray` of
    pixel offsets and reordered into counter-clockwise vertex order.
    """

    def __set__(self, instance, value):
        value = self._validate(value)
        if self.name in instance.__dict__:
            self._reset_lazyproperties(instance)
        instance.__dict__[self.name] = value

    def _validate(self, value):
        if isinstance(value, u.Quantity):
            msg = f'{self.name!r} must not be a Quantity'
            raise TypeError(msg)

        try:
            value = np.asarray(value, dtype=float)
        except (TypeError, ValueError) as exc:
            msg = (f'{self.name!r} must be convertible to a numeric '
                   f'(n_vertices, 2) array')
            raise TypeError(msg) from exc

        return _validate_simple_polygon(value, name=self.name)


class SkyVertexOffsets(ApertureAttribute):
    """
    Validate and set ``vertex_offsets`` for `SkyPolygonAperture`.

    The value must be an angular `~astropy.units.Quantity` of shape
    ``(n_vertices, 2)``. The two columns are the ``(d_lon, d_lat)``
    offsets relative to the corresponding ``positions`` coordinate.
    """

    def __set__(self, instance, value):
        value = self._validate(value)
        if self.name in instance.__dict__:
            self._reset_lazyproperties(instance)
        instance.__dict__[self.name] = value

    def _validate(self, value):
        if not isinstance(value, u.Quantity):
            msg = (f'{self.name!r} must be an angular Quantity with '
                   'shape (n_vertices, 2)')
            raise TypeError(msg)

        if not value.unit.is_equivalent(u.deg):
            msg = f'{self.name!r} must have angular units'
            raise u.UnitsError(msg)

        if value.ndim != 2 or value.shape[1] != 2:
            msg = (f'{self.name!r} must have shape (n_vertices, 2), '
                   f'got {value.shape}')
            raise ValueError(msg)

        # Validate using arcsec values; angular geometry on the local
        # tangent plane is preserved.
        verts = value.to_value(u.arcsec)
        verts = _validate_simple_polygon(verts, name=self.name)

        return (verts * u.arcsec).to(value.unit)


def _vertices_centroid(verts):
    """
    Return the area-weighted geometric center of a polygon.

    For a polygon with vertices ``v_i`` and area ``A``, the centroid is:

        C_x = (1/(6A)) sum_i (x_i + x_{i+1}) (x_i y_{i+1} - x_{i+1} y_i)
        C_y = (1/(6A)) sum_i (y_i + y_{i+1}) (x_i y_{i+1} - x_{i+1} y_i)
    """
    x = verts[:, 0]
    y = verts[:, 1]
    x1 = np.roll(x, -1)
    y1 = np.roll(y, -1)
    cross = x * y1 - x1 * y
    area = 0.5 * float(np.sum(cross))
    cx = float(np.sum((x + x1) * cross)) / (6.0 * area)
    cy = float(np.sum((y + y1) * cross)) / (6.0 * area)

    return np.array([cx, cy])


def _regular_polygon_offsets(n_vertices, radius, angle_rad):
    """
    Return the ``(n_vertices, 2)`` array of vertex offsets for a regular
    polygon with the given circumradius and rotation angle.

    The first vertex sits at angle ``pi/2 + angle_rad`` (i.e., directly
    above the center for ``angle_rad == 0``), and subsequent vertices
    are placed counter-clockwise at uniform angular spacing.
    """
    base = np.pi / 2.0 + float(angle_rad)
    step = 2.0 * np.pi / n_vertices
    thetas = base + np.arange(n_vertices) * step
    return np.column_stack([radius * np.cos(thetas),
                            radius * np.sin(thetas)])


def _is_regular_polygon(offsets, *, rtol=1.0e-7, atol=1.0e-10):
    """
    Return `True` if ``offsets`` defines a regular polygon centered at
    the origin (equal vertex distances and uniform angular spacing).
    """
    n = offsets.shape[0]
    radii = np.hypot(offsets[:, 0], offsets[:, 1])

    if not np.allclose(radii, radii[0], rtol=rtol, atol=atol):
        return False

    # Sort vertex angles in [0, 2*pi) and check uniform spacing.
    angles = np.mod(np.arctan2(offsets[:, 1], offsets[:, 0]), 2.0 * np.pi)
    angles_sorted = np.sort(angles)
    diffs = np.diff(np.concatenate([angles_sorted,
                                    angles_sorted[:1] + 2.0 * np.pi]))
    expected = 2.0 * np.pi / n

    return np.allclose(diffs, expected, rtol=rtol, atol=atol)


class _RegularPolygonGeometry:
    """
    Compute the geometric properties of a regular polygon from its
    vertex offsets.

    This small helper centralizes the regular-polygon math shared by
    `PolygonAperture` and `SkyPolygonAperture`, avoiding duplicated
    trigonometric formulas in the two classes.

    Parameters
    ----------
    offsets : `~numpy.ndarray`
        The ``(n_vertices, 2)`` array of vertex offsets relative to
        the polygon center, as plain numbers in the aperture's native
        length unit (pixels for `PolygonAperture`, arcseconds for
        `SkyPolygonAperture`).

    Notes
    -----
    The length properties (`outer_radius`, `inner_radius`,
    `side_length`) are returned as plain floats in the same length
    unit as ``offsets``. The angular properties (`interior_angle`,
    `exterior_angle`, `theta`) are returned as `~astropy.units.Quantity`
    in degrees and are independent of the length unit.
    """

    def __init__(self, offsets):
        self.offsets = offsets
        self.n_vertices = offsets.shape[0]

    @property
    def is_regular(self):
        return _is_regular_polygon(self.offsets)

    @property
    def side_length(self):
        return (2.0 * self.outer_radius
                * float(np.sin(np.pi / self.n_vertices)))

    @property
    def outer_radius(self):
        return float(np.hypot(self.offsets[0, 0], self.offsets[0, 1]))

    @property
    def inner_radius(self):
        return self.outer_radius * float(np.cos(np.pi / self.n_vertices))

    @property
    def interior_angle(self):
        return ((self.n_vertices - 2) / self.n_vertices) * 180.0 * u.deg

    @property
    def exterior_angle(self):
        return (360.0 / self.n_vertices) * u.deg

    @property
    def theta(self):
        dx, dy = self.offsets[0]
        theta_rad = np.arctan2(dy, dx) - np.pi / 2
        return (np.degrees(theta_rad) % 360.0) * u.deg


[docs] class PolygonAperture(PixelAperture): """ A polygon aperture defined in pixel coordinates. The aperture has a single fixed shape, defined by ``vertex_offsets`` relative to each ``positions`` entry, but it can have multiple positions. 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 vertex_offsets : array_like Shape ``(n_vertices, 2)``. The pixel-space offsets ``(dx, dy)`` of each polygon vertex relative to ``positions``. The polygon must be simple (non-self-intersecting) with at least 3 vertices. The polygon may be convex or non-convex. Either clockwise or counter-clockwise vertex orderings are accepted; the input is normalized to counter-clockwise internally. Raises ------ ValueError : `ValueError` If ``vertex_offsets`` is not a valid simple polygon with at least 3 vertices. Examples -------- >>> import numpy as np >>> from photutils.aperture import PolygonAperture >>> # A regular hexagon centered on (10, 20) with circumradius 5. >>> theta = np.linspace(0.0, 2 * np.pi, 6, endpoint=False) >>> offsets = np.column_stack([5.0 * np.cos(theta), ... 5.0 * np.sin(theta)]) >>> aper = PolygonAperture((10.0, 20.0), offsets) Construct a single polygon directly from absolute vertex coordinates:: >>> verts = [(0.0, 0.0), (4.0, 0.0), (4.0, 3.0)] >>> aper = PolygonAperture.from_vertices(verts) """ _params = ('positions', 'vertex_offsets') positions = PixelPositions('The center pixel position(s).') vertex_offsets = PixelVertexOffsets( 'The (n_vertices, 2) array of pixel offsets of the polygon ' 'vertices, measured relative to ``positions``.') def __init__(self, positions, vertex_offsets): self.positions = positions self.vertex_offsets = vertex_offsets @classmethod def _from_convex_offsets(cls, positions, offsets): """ Construct a `PolygonAperture` from vertex offsets that are known to define a simple polygon, skipping the ``O(n^2)`` self-intersection check. This is a fast-path internal constructor used by the ``to_polygon`` methods of the built-in apertures, where the generated polygon (a discretized circle, ellipse, or rectangle) is guaranteed to be simple by construction. The cheap shape, vertex-count, finiteness, zero-area, and counter-clockwise normalization checks are still applied. """ offsets = np.asarray(offsets, dtype=float) offsets = _validate_simple_polygon(offsets, name='vertex_offsets', check_simple=False) obj = cls.__new__(cls) obj.positions = positions obj.__dict__['vertex_offsets'] = offsets return obj
[docs] @classmethod def from_vertices(cls, vertices): """ Construct a `PolygonAperture` from a single set of absolute pixel vertices. The aperture's ``positions`` is set to the area-weighted centroid of the input vertices, and ``vertex_offsets`` is set to the vertices minus the centroid. Parameters ---------- vertices : array_like Shape ``(n_vertices, 2)`` array of absolute pixel ``(x, y)`` vertex coordinates. Returns ------- aperture : `PolygonAperture` A scalar polygon aperture whose ``vertices`` property reproduces the input. """ verts = np.asarray(vertices, dtype=float) if verts.ndim != 2 or verts.shape[1] != 2: msg = ('vertices must have shape (n_vertices, 2), ' f'got {verts.shape}') raise ValueError(msg) # Validate before computing the centroid: the area-weighted # centroid formula divides by the polygon area, which is zero # for a degenerate (collinear or coincident) polygon. verts = _validate_simple_polygon(verts, name='vertices') center = _vertices_centroid(verts) offsets = verts - center return cls(tuple(center), offsets)
[docs] @classmethod def from_regular_polygon(cls, positions, n_vertices, radius, *, theta=0.0): """ Construct a regular `PolygonAperture` from a center, vertex count, circumradius, and rotation angle. The first vertex is placed directly above the center (in the ``+y`` direction) for ``theta == 0`` and is rotated counter-clockwise by ``theta``. This convention matches `regions.RegularPolygonPixelRegion`. Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) (see `PolygonAperture` for the accepted formats). n_vertices : int The number of vertices (must be at least 3). radius : float The circumradius (outer radius) of the polygon in pixels (must be positive). theta : float or `~astropy.units.Quantity`, optional The rotation angle of the polygon, measured counter- clockwise from the ``+y`` axis. A scalar float is interpreted in radians. Returns ------- aperture : `PolygonAperture` The regular polygon aperture. """ n_vertices = int(n_vertices) if n_vertices < 3: msg = f'n_vertices must be at least 3, got {n_vertices}' raise ValueError(msg) radius = float(radius) if not np.isfinite(radius) or radius <= 0.0: msg = f'radius must be a finite positive number, got {radius}' raise ValueError(msg) if isinstance(theta, u.Quantity): theta_rad = float(theta.to_value(u.rad)) else: theta_rad = float(theta) offsets = _regular_polygon_offsets(n_vertices, radius, theta_rad) return cls(positions, offsets)
@classmethod def _from_star(cls, positions, n_spikes, outer_radius, inner_radius=None, *, theta=0.0, optimal_shape=False, collinear_edges=False): """ Construct a star-shaped `PolygonAperture`. The star has ``n_spikes`` spikes, with vertices alternating between ``outer_radius`` and ``inner_radius``. The first (outer) vertex is placed directly above the center (in the ``+y`` direction) for ``theta == 0`` and is rotated counter-clockwise by ``theta``. Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) (see `PolygonAperture` for the accepted formats). n_spikes : int The number of spikes (must be at least 2). outer_radius : float The distance from the center to the outer (spike) vertices in pixels (must be positive). inner_radius : float, optional The distance from the center to the inner vertices in pixels (must be positive and less than ``outer_radius``). If either ``optimal_shape`` or ``collinear_edges`` is `True`, this parameter is ignored and the inner radius is computed automatically. theta : float or `~astropy.units.Quantity`, optional The rotation angle of the star, measured counter-clockwise from the ``+y`` axis to the first (outer) vertex. A scalar float is interpreted in radians. optimal_shape : bool, optional If `True`, compute ``inner_radius`` automatically to produce a star with naturally-proportioned geometry. The spike angle (the angular width at each point) is set to 180°/``n_spikes``, which is the exterior angle of a regular ``n_spikes``-gon. This creates a balanced, visually appealing star where the indentations between spikes are symmetric and proportional to the spikes themselves. The formula is: inner_radius = outer_radius / (1 + 2·cos(π/``n_spikes``)) For a 5-pointed star, this gives a radius ratio of φ + 1 (where φ is the golden ratio ≈ 1.618), resulting in the inner_radius ≈ 3.82 for outer_radius = 10. This ratio is aesthetically pleasing and appears naturally in classical star designs. Default is `False`. collinear_edges : bool, optional If `True`, compute ``inner_radius`` automatically to make the two polygon edges adjacent to each spike lie on a single straight line. At ``theta=0``, these edges are horizontal at the sides of the top spike, creating a symmetric _/\\_ profile. When rotated (``theta > 0``), the edges remain collinear but at a different angle. This proportionality constraint makes the indentation between spikes flat-bottomed, resulting in a sharp, classical star appearance. The formula is: inner_radius = (outer_radius · cos(2π/``n_spikes``) / cos(π/``n_spikes``)) This keyword requires ``n_spikes >= 5``. Default is `False`. Returns ------- aperture : `PolygonAperture` The star-shaped polygon aperture. """ n_spikes = int(n_spikes) if n_spikes < 2: msg = f'n_spikes must be at least 2, got {n_spikes}' raise ValueError(msg) outer_radius = float(outer_radius) if not np.isfinite(outer_radius) or outer_radius <= 0.0: msg = ('outer_radius must be a finite positive number, ' f'got {outer_radius}') raise ValueError(msg) # Determine which automatic mode to use, if any num_auto_modes = sum([optimal_shape, collinear_edges]) if num_auto_modes > 1: msg = 'optimal_shape and collinear_edges cannot both be True' raise ValueError(msg) if optimal_shape: # Compute inner radius to make spike angles = 180°/n_spikes inner_radius = (outer_radius / (1.0 + 2.0 * np.cos(np.pi / n_spikes))) elif collinear_edges: # Compute inner radius to make the polygon edges adjacent # to each spike collinear (lie on the same straight line). # This creates a flat-bottomed indentation between spikes. # At theta=0, these edges are horizontal; when rotated, they # remain collinear but at a different angle. This requires # n_spikes >= 5 because for smaller values the formula # produces a negative or zero inner radius. if n_spikes < 5: msg = ('collinear_edges requires n_spikes >= 5, ' f'got {n_spikes}') raise ValueError(msg) inner_radius = (outer_radius * np.cos(2 * np.pi / n_spikes) / np.cos(np.pi / n_spikes)) else: if inner_radius is None: msg = ('inner_radius must be provided if both optimal_shape ' 'and horizontal_edges are False') raise ValueError(msg) inner_radius = float(inner_radius) if not np.isfinite(inner_radius) or inner_radius <= 0.0: msg = ('inner_radius must be a finite positive number, ' f'got {inner_radius}') raise ValueError(msg) if inner_radius >= outer_radius: msg = ('inner_radius must be less than outer_radius ' f'({inner_radius} >= {outer_radius})') raise ValueError(msg) if isinstance(theta, u.Quantity): theta_rad = float(theta.to_value(u.rad)) else: theta_rad = float(theta) # Create star vertices alternating between outer and inner radii. # The star has 2*n_spikes vertices total. # For theta=0, the first (outer) vertex points straight up (+y), # at angle pi/2 from the x-axis. n_vertices = 2 * n_spikes step = 2.0 * np.pi / n_vertices base = np.pi / 2.0 + theta_rad # Create alternating radii: outer, inner, outer, inner, ... radii = np.zeros(n_vertices) radii[::2] = outer_radius # even indices: outer vertices radii[1::2] = inner_radius # odd indices: inner vertices # Place vertices at the appropriate angles thetas = base + np.arange(n_vertices) * step offsets = np.column_stack([radii * np.cos(thetas), radii * np.sin(thetas)]) return cls(positions, offsets) @lazyproperty def vertices(self): """ The absolute pixel ``(x, y)`` vertices of the polygon at each aperture position. For a scalar aperture, the returned array has shape ``(n_vertices, 2)``. For an aperture with ``n_positions`` positions, the returned array has shape ``(n_positions, n_vertices, 2)``. """ # vertex_offsets shape: (n_v, 2); positions shape: (..., 2). # Broadcast position to vertices. offsets = self.vertex_offsets if self.isscalar: pos = np.asarray(self.positions, dtype=float) return pos + offsets # positions shape: (n_positions, 2); result: (n_positions, n_v, 2) return self.positions[:, np.newaxis, :] + offsets[np.newaxis, :, :] @lazyproperty def _xy_extents(self): """ Half the extents of the polygon's axis-aligned bounding box. """ x_extent = float(np.max(np.abs(self.vertex_offsets[:, 0]))) y_extent = float(np.max(np.abs(self.vertex_offsets[:, 1]))) return x_extent, y_extent def _batch_shape_params(self): # The vertex offsets are counter-clockwise (normalized when the # attribute is set) and flattened as (x0, y0, x1, y1, ...) for # the batch Cython photometry driver. return SHAPE_POLYGON, tuple(self.vertex_offsets.ravel()) @lazyproperty def area(self): """ The exact geometric area of the aperture shape. """ return abs(_signed_polygon_area(self.vertex_offsets)) @lazyproperty def perimeter(self): """ The perimeter of the polygon in pixels. The perimeter is computed as the sum of the Euclidean distances between consecutive vertices. """ offsets = self.vertex_offsets diff = np.roll(offsets, -1, axis=0) - offsets return float(np.sum(np.hypot(diff[:, 0], diff[:, 1]))) @lazyproperty def n_vertices(self): """ The number of vertices of the polygon. """ return int(self.vertex_offsets.shape[0]) @lazyproperty def _regular_geometry(self): """ Helper that computes the regular-polygon geometric properties from the pixel vertex offsets. """ return _RegularPolygonGeometry(self.vertex_offsets) @lazyproperty def is_regular(self): """ `True` if the polygon is regular (equal-length sides and equal interior angles, with all vertices at the same distance from ``positions``). """ return self._regular_geometry.is_regular def _check_regular(self, name): if not self.is_regular: msg = (f'{name!r} is only defined for a regular polygon ' 'aperture') raise ValueError(msg) @lazyproperty def outer_radius(self): """ The outer (circumscribed-circle) radius of the regular polygon in pixels, i.e., the distance from ``positions`` to each vertex. This is also called the `circumradius <https://mathworld.wolfram.com/Circumradius.html>`_. This attribute is only defined for regular polygons. Accessing it for a non-regular polygon raises `ValueError`. """ self._check_regular('outer_radius') return self._regular_geometry.outer_radius @lazyproperty def inner_radius(self): """ The inner (inscribed-circle) radius of the regular polygon in pixels, i.e., the distance from ``positions`` to the midpoint of each side. This is also called the `inradius <https://mathworld.wolfram.com/Inradius.html>`_ This attribute is only defined for regular polygons. Accessing it for a non-regular polygon raises `ValueError`. """ self._check_regular('inner_radius') return self._regular_geometry.inner_radius @lazyproperty def side_length(self): """ The side length of the regular polygon, in pixels. This attribute is only defined for regular polygons. Accessing it for a non-regular polygon raises `ValueError`. """ self._check_regular('side_length') return self._regular_geometry.side_length @lazyproperty def interior_angle(self): """ The interior angle of the regular polygon as an angular `~astropy.units.Quantity` in degrees. This attribute is only defined for regular polygons. Accessing it for a non-regular polygon raises `ValueError`. """ self._check_regular('interior_angle') return self._regular_geometry.interior_angle @lazyproperty def exterior_angle(self): """ The exterior angle of the regular polygon as an angular `~astropy.units.Quantity` in degrees. This attribute is only defined for regular polygons. Accessing it for a non-regular polygon raises `ValueError`. """ self._check_regular('exterior_angle') return self._regular_geometry.exterior_angle @lazyproperty def theta(self): """ The rotation angle of the regular polygon as an angular `~astropy.units.Quantity` in degrees. The angle is measured counter-clockwise from the ``+y`` axis to the first vertex, in the range ``[0, 360) deg``. This convention matches the ``theta`` parameter of `~PolygonAperture.from_regular_polygon`. This attribute is only defined for regular polygons. Accessing it for a non-regular polygon raises `ValueError`. """ self._check_regular('theta') return self._regular_geometry.theta 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. """ verts_x = np.ascontiguousarray(self.vertex_offsets[:, 0]) verts_y = np.ascontiguousarray(self.vertex_offsets[:, 1]) return polygon_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, verts_x, verts_y, use_exact, subpixels) def _to_patch(self, *, origin=(0, 0), **kwargs): """ Return a `~matplotlib.patches.Polygon` patch (or list of them) 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) offsets = self.vertex_offsets patches = [mpatches.Polygon(pos + offsets, closed=True, **patch_kwargs) for pos in xy_positions] if self.isscalar: return patches[0] return patches
[docs] def to_sky(self, wcs): """ Convert the aperture to a `SkyPolygonAperture` object defined in celestial coordinates. The conversion uses ``wcs.pixel_to_world`` directly on the absolute pixel vertex coordinates, evaluated at the first aperture position. 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 : `SkyPolygonAperture` The equivalent sky-coordinate polygon aperture. """ xpos, ypos = np.transpose(self.positions) positions = wcs.pixel_to_world(xpos, ypos) first_pos = np.atleast_2d(self.positions)[0] first_skypos = positions if self.isscalar else positions[0] abs_x = first_pos[0] + self.vertex_offsets[:, 0] abs_y = first_pos[1] + self.vertex_offsets[:, 1] sky_vertices = wcs.pixel_to_world(abs_x, abs_y) d_lon, d_lat = first_skypos.spherical_offsets_to(sky_vertices) offsets = u.Quantity([d_lon, d_lat]).T return SkyPolygonAperture(positions=positions, vertex_offsets=offsets)
[docs] class SkyPolygonAperture(SkyAperture): """ A polygon aperture defined in sky coordinates. The aperture has a single fixed shape, defined by angular ``vertex_offsets`` relative to each ``positions`` entry, but it can have multiple positions. The angular offsets are interpreted on the local tangent plane at each position. Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). This can be either scalar coordinates or an array of coordinates. vertex_offsets : `~astropy.units.Quantity` Shape ``(n_vertices, 2)`` angular Quantity of polygon vertex offsets ``(d_lon, d_lat)`` relative to ``positions``, applied as spherical offsets on the local tangent plane. The polygon must be simple (non-self-intersecting) with at least 3 vertices. The polygon may be convex or non-convex. Examples -------- >>> import astropy.units as u >>> import numpy as np >>> from astropy.coordinates import SkyCoord >>> from photutils.aperture import SkyPolygonAperture >>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') >>> theta = np.linspace(0, 2 * np.pi, 5, endpoint=False) >>> r = 1.0 >>> offsets = np.column_stack([r * np.cos(theta), ... r * np.sin(theta)]) * u.arcsec >>> aper = SkyPolygonAperture(positions, offsets) """ _params = ('positions', 'vertex_offsets') positions = SkyCoordPositions('The center position(s) in sky coordinates.') vertex_offsets = SkyVertexOffsets( 'The (n_vertices, 2) angular Quantity of polygon vertex offsets ' '(d_lon, d_lat) relative to ``positions``.') def __init__(self, positions, vertex_offsets): self.positions = positions self.vertex_offsets = vertex_offsets @classmethod def _from_convex_offsets(cls, positions, offsets): """ Construct a `SkyPolygonAperture` from angular vertex offsets that are known to define a simple polygon, skipping the ``O(n^2)`` self-intersection check. This is a fast-path internal constructor used by the ``to_polygon`` methods of the built-in sky apertures, where the generated polygon (a discretized circle, ellipse, or rectangle) is guaranteed to be simple by construction. The cheap shape, vertex-count, finiteness, zero-area, and counter-clockwise normalization checks are still applied. Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The center position(s) in sky coordinates. offsets : `~astropy.units.Quantity` The ``(n_vertices, 2)`` angular Quantity of vertex offsets ``(d_lon, d_lat)`` relative to ``positions``. Returns ------- aperture : `SkyPolygonAperture` The sky polygon aperture. """ verts = offsets.to_value(u.arcsec) verts = _validate_simple_polygon(verts, name='vertex_offsets', check_simple=False) offsets = (verts * u.arcsec).to(offsets.unit) obj = cls.__new__(cls) obj.positions = positions obj.__dict__['vertex_offsets'] = offsets return obj
[docs] @classmethod def from_vertices(cls, vertices): """ Construct a `SkyPolygonAperture` from a single set of absolute sky vertices. The aperture's ``positions`` is set to an approximate centroid of the input vertices, computed as a single tangent-plane mean of the spherical offsets from the first vertex (i.e., the vertices' spherical offsets from this centroid average to approximately zero), and ``vertex_offsets`` is set to the spherical offsets of the vertices from this centroid. Parameters ---------- vertices : `~astropy.coordinates.SkyCoord` A SkyCoord with shape ``(n_vertices,)`` of absolute polygon vertex coordinates. Returns ------- aperture : `SkyPolygonAperture` A scalar sky polygon aperture whose ``vertices`` property reproduces the input. """ if not isinstance(vertices, SkyCoord) or vertices.ndim != 1: msg = ('vertices must be a 1-D SkyCoord with at least 3 ' 'vertices') raise TypeError(msg) # Provisional centroid: spherical mean approximated via mean of # offsets from the first vertex. ref = vertices[0] d_lon, d_lat = ref.spherical_offsets_to(vertices) center_d_lon = d_lon.mean() center_d_lat = d_lat.mean() center = ref.spherical_offsets_by(center_d_lon, center_d_lat) d_lon, d_lat = center.spherical_offsets_to(vertices) offsets = u.Quantity([d_lon, d_lat]).T return cls(positions=center, vertex_offsets=offsets)
[docs] @classmethod def from_regular_polygon(cls, positions, n_vertices, radius, *, theta=0.0 * u.deg): """ Construct a regular `SkyPolygonAperture` from a center, vertex count, angular circumradius, and rotation angle. The first vertex is placed at the ``+lat`` edge of the local tangent plane (typically northward for standard right-handed celestial coordinates) for ``theta == 0`` and is rotated counter-clockwise by ``theta``. Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). n_vertices : int The number of vertices (must be at least 3). radius : `~astropy.units.Quantity` The angular circumradius (outer radius) of the polygon (must be a positive angular Quantity). theta : `~astropy.units.Quantity`, optional The rotation angle of the polygon, measured counter- clockwise from the ``+lat`` axis on the local tangent plane. This assumes a right-handed coordinate system (e.g., standard celestial coordinates). Returns ------- aperture : `SkyPolygonAperture` The regular sky polygon aperture. """ n_vertices = int(n_vertices) if n_vertices < 3: msg = f'n_vertices must be at least 3, got {n_vertices}' raise ValueError(msg) if (not isinstance(radius, u.Quantity) or not radius.unit.is_equivalent(u.deg)): msg = 'radius must be an angular Quantity' raise TypeError(msg) radius_arcsec = float(radius.to_value(u.arcsec)) if not np.isfinite(radius_arcsec) or radius_arcsec <= 0.0: msg = f'radius must be a finite positive Quantity, got {radius}' raise ValueError(msg) if (not isinstance(theta, u.Quantity) or not theta.unit.is_equivalent(u.deg)): msg = 'theta must be an angular Quantity' raise TypeError(msg) theta_rad = float(theta.to_value(u.radian)) offsets_arcsec = _regular_polygon_offsets(n_vertices, radius_arcsec, theta_rad) offsets = (offsets_arcsec * u.arcsec).to(radius.unit) return cls(positions=positions, vertex_offsets=offsets)
@lazyproperty def n_vertices(self): """ The number of vertices of the polygon. """ return int(self.vertex_offsets.shape[0]) @lazyproperty def perimeter(self): """ The angular perimeter of the polygon as a `~astropy.units.Quantity`. The perimeter is computed as the sum of the angular distances between consecutive vertices on the local tangent plane. """ offsets = self.vertex_offsets.to_value(u.arcsec) diff = np.roll(offsets, -1, axis=0) - offsets total = float(np.sum(np.hypot(diff[:, 0], diff[:, 1]))) return total * u.arcsec @lazyproperty def _regular_geometry(self): """ Helper that computes the regular-polygon geometric properties from the angular vertex offsets (in arcseconds). """ return _RegularPolygonGeometry( self.vertex_offsets.to_value(u.arcsec)) @lazyproperty def is_regular(self): """ `True` if the polygon is regular (equal-length sides and equal interior angles, with all vertices at the same angular distance from ``positions``). """ return self._regular_geometry.is_regular def _check_regular(self, name): if not self.is_regular: msg = (f'{name!r} is only defined for a regular polygon ' 'aperture') raise ValueError(msg) @lazyproperty def outer_radius(self): """ The angular outer (circumscribed-circle) radius of the regular polygon as a `~astropy.units.Quantity`, i.e., the distance from ``positions`` to each vertex. This is also called the `circumradius <https://mathworld.wolfram.com/Circumradius.html>`_. This attribute is only defined for regular polygons. Accessing it for a non-regular polygon raises `ValueError`. """ self._check_regular('outer_radius') return self._regular_geometry.outer_radius * u.arcsec @lazyproperty def inner_radius(self): """ The angular inner (inscribed-circle) radius of the regular polygon as a `~astropy.units.Quantity`, i.e., the distance from ``positions`` to the midpoint of each side. This is also called the `inradius <https://mathworld.wolfram.com/Inradius.html>`_ This attribute is only defined for regular polygons. Accessing it for a non-regular polygon raises `ValueError`. """ self._check_regular('inner_radius') return self._regular_geometry.inner_radius * u.arcsec @lazyproperty def side_length(self): """ The angular side length of the regular polygon as a `~astropy.units.Quantity`. This attribute is only defined for regular polygons. Accessing it for a non-regular polygon raises `ValueError`. """ self._check_regular('side_length') return self._regular_geometry.side_length * u.arcsec @lazyproperty def interior_angle(self): """ The interior angle of the regular polygon as an angular `~astropy.units.Quantity` in degrees. This attribute is only defined for regular polygons. Accessing it for a non-regular polygon raises `ValueError`. """ self._check_regular('interior_angle') return self._regular_geometry.interior_angle @lazyproperty def exterior_angle(self): """ The exterior angle of the regular polygon as an angular `~astropy.units.Quantity` in degrees. This attribute is only defined for regular polygons. Accessing it for a non-regular polygon raises `ValueError`. """ self._check_regular('exterior_angle') return self._regular_geometry.exterior_angle @lazyproperty def theta(self): """ The rotation angle of the regular polygon as an angular `~astropy.units.Quantity` in degrees. The angle is measured counter-clockwise from the ``+lat`` axis on the local tangent plane to the first vertex, in the range ``[0, 360) deg``. This assumes a right-handed coordinate system (e.g., standard celestial coordinates). This convention matches the ``theta`` parameter of `~SkyPolygonAperture.from_regular_polygon`. This attribute is only defined for regular polygons. Accessing it for a non-regular polygon raises `ValueError`. """ self._check_regular('theta') return self._regular_geometry.theta @lazyproperty def vertices(self): """ The absolute sky vertices of the polygon at each aperture position, as a `~astropy.coordinates.SkyCoord`. For a scalar aperture, the returned SkyCoord has shape ``(n_vertices,)``. For an aperture with ``n_positions`` positions, the returned SkyCoord has shape ``(n_positions, n_vertices)``. """ d_lon = self.vertex_offsets[:, 0] d_lat = self.vertex_offsets[:, 1] if self.isscalar: return self.positions.spherical_offsets_by(d_lon, d_lat) # Broadcast to shape (n_positions, n_vertices). positions = self.positions[:, np.newaxis] d_lon_b = d_lon[np.newaxis, :] d_lat_b = d_lat[np.newaxis, :] return positions.spherical_offsets_by(d_lon_b, d_lat_b)
[docs] def to_pixel(self, wcs): """ Convert the aperture to a `PolygonAperture` object defined in pixel coordinates. The conversion uses ``wcs.world_to_pixel`` directly on the absolute sky vertex coordinate, evaluated at the first aperture position. 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 : `PolygonAperture` The equivalent pixel-coordinate polygon aperture. """ xpos, ypos = wcs.world_to_pixel(self.positions) positions = np.transpose((xpos, ypos)) first_skypos = self.positions if self.isscalar else self.positions[0] first_x = float(np.atleast_1d(xpos)[0]) first_y = float(np.atleast_1d(ypos)[0]) d_lon = self.vertex_offsets[:, 0] d_lat = self.vertex_offsets[:, 1] abs_sky_vertices = first_skypos.spherical_offsets_by(d_lon, d_lat) vx, vy = wcs.world_to_pixel(abs_sky_vertices) offsets = np.column_stack([vx - first_x, vy - first_y]) return PolygonAperture(positions=positions, vertex_offsets=offsets)