What’s New in Photutils 3.1?#

Here we highlight some of the new functionality of the 3.1 release. In addition to these changes, Photutils 3.1 includes several smaller improvements and bug fixes, which are described in the full Changelog.

Dependency Version Updates#

Photutils 3.1 bumps the minimum required versions of several key dependencies to provide users with access to the latest features and performance improvements:

  • Astropy minimum version is now 6.1.7

  • SciPy minimum version is now 1.14

  • Regions minimum version is now 0.10

  • scikit-image minimum version is now 0.24

  • gwcs minimum version is now 0.22

  • Shapely minimum version is now 2.1

  • tqdm minimum version is now 4.67

ASDF Serialization Support#

Photutils 3.1 introduces initial support for serializing photutils objects to the Advanced Scientific Data Format (ASDF) file format. This requires installing the asdf package. For some classes, the new optional asdf-astropy package must also be installed.

>>> import asdf
>>> from photutils.aperture import CircularAperture
>>> aperture = CircularAperture((10, 20), r=5)
>>> with asdf.AsdfFile() as af:
...     af['aperture'] = aperture
...     af.write_to('aperture.asdf')
>>> with asdf.open('aperture.asdf') as af:
...     aperture = af['aperture']

So far, the following classes can be serialized to and deserialized from ASDF files:

Exact Rectangular Aperture Overlap#

Rectangular apertures now support the method='exact' option for computing pixel-aperture overlap fractions. Previously, requesting method='exact' for a RectangularAperture or RectangularAnnulus was silently translated to a 32x subpixel approximation. The new implementation computes the exact analytic overlap area using a Sutherland-Hodgman polygon-clipping algorithm:

>>> from photutils.aperture import RectangularAperture
>>> aper = RectangularAperture((10.0, 10.0), w=4.0, h=2.0, theta=0.5)
>>> mask = aper.to_mask(method='exact')

Faster Aperture Photometry#

Aperture photometry is now significantly faster for all built-in aperture shapes. The photometry for all sources is now computed in a single call into compiled code, avoiding the per-source Python overhead of creating individual aperture masks and cutouts. Typical speedups range from ~2x to more than 25x, depending on the aperture shape and overlap method, with the largest gains for fields with many sources. The results are identical to those from previous versions.

Thread-Safe and Free-Threading-Compatible Aperture Photometry#

The new aperture photometry implementation releases the Python global interpreter lock (GIL) during the computation and uses no global state, making it safe to call concurrently from multiple threads. The compiled extensions are also declared compatible with free-threaded CPython builds, so threads can run the computation truly in parallel.

For example, photometry of many apertures on a large image can be parallelized across threads by splitting the source positions into chunks:

>>> from concurrent.futures import ThreadPoolExecutor
>>> import numpy as np
>>> from astropy.table import vstack
>>> from photutils.aperture import (CircularAperture,
...                                 aperture_photometry)
>>> rng = np.random.default_rng(seed=0)
>>> data = rng.random((4096, 4096))
>>> positions = rng.uniform(0, 4096, (100_000, 2))
>>> n_workers = 10
>>> chunks = [CircularAperture(pos, r=5.0)
...           for pos in np.array_split(positions, n_workers)]
>>> with ThreadPoolExecutor(max_workers=n_workers) as executor:
...     tables = list(executor.map(
...         lambda aper: aperture_photometry(data, aper), chunks))
>>> phot_table = vstack(tables)
>>> phot_table['id'] = np.arange(1, len(phot_table) + 1)

Note that the id column of each chunk’s table is numbered starting from 1, so the last line renumbers the stacked id column to match the input source order. Because executor.map returns results in input order, the stacked table rows are in the same order as the input positions.

Polygon Apertures#

Two new aperture classes have been added: PolygonAperture and SkyPolygonAperture. Like the other aperture types, they have a single fixed shape (parameterized by vertex_offsets) that can be applied at one or more positions:

>>> import numpy as np
>>> from photutils.aperture import PolygonAperture
>>> positions = [(10.0, 20.0), (30.0, 40.0)]
>>> # A regular hexagon centered on each position
>>> outer_radius = 5.0
>>> theta = np.linspace(0.0, 2 * np.pi, 6, endpoint=False)
>>> offsets = np.column_stack([outer_radius * np.cos(theta),
...                            outer_radius * np.sin(theta)])
>>> aper = PolygonAperture(positions, offsets)

The polygon must be simple (non-self-intersecting), but it does not need to be convex; non-convex shapes such as L-shapes or stars are fully supported.

Regular polygons can be constructed by specifying a center, the number of vertices, the circumradius (outer radius), and an optional rotation angle via the from_regular_polygon classmethod. The is_regular property reports whether a polygon is regular, and the outer_radius, inner_radius, side_length, interior_angle, exterior_angle, and theta properties expose the geometric parameters of regular polygons (they raise ValueError for non-regular polygons):

>>> hexagon = PolygonAperture.from_regular_polygon((10.0, 20.0),
...                                                n_vertices=6,
...                                                radius=5.0)
>>> hexagon.is_regular
True
>>> hexagon.outer_radius
5.0
>>> hexagon.interior_angle
<Quantity 120. deg>

A from_vertices classmethod is also provided as a convenience for building a single polygon directly from absolute pixel or sky vertex coordinates:

>>> verts = [(0.0, 0.0), (4.0, 0.0), (4.0, 3.0)]
>>> aper = PolygonAperture.from_vertices(verts)

Both polygon aperture classes also expose a vertices attribute returning the absolute vertex coordinates at every aperture position.

Conversions between PolygonAperture and SkyPolygonAperture use wcs.pixel_to_world and wcs.world_to_pixel directly on the absolute vertex coordinates, so the conversion captures the actual finite-displacement WCS transformation of the polygon corners.

aperture_to_region and region_to_aperture also now support PolygonPixelRegion and PolygonSkyRegion.

Converting Apertures to Polygons#

The circular, elliptical, and rectangular apertures (both their pixel and sky variants) now have a to_polygon method that converts them to an equivalent PolygonAperture or SkyPolygonAperture. The circular and elliptical apertures are approximated by a polygon with a configurable number of vertices (n_vertices, default 100), while the rectangular aperture is converted exactly to a four-vertex polygon:

>>> from photutils.aperture import CircularAperture
>>> aper = CircularAperture((10.0, 20.0), r=5.0)
>>> poly = aper.to_polygon(n_vertices=100)

Segmentation-Based Masking in Aperture Photometry#

aperture_photometry and ApertureStats now accept segmentation_image, labels, and mask_method keywords to mask or correct the flux from neighboring sources that overlap an aperture. The segmentation_image (see Image Segmentation) labels each source with a positive integer, and the mask_method keyword selects how neighboring sources are handled:

  • 'none' (default): the segmentation image is ignored.

  • 'mask': pixels belonging to neighboring sources are excluded.

  • 'source_only': only pixels belonging to the target source are included.

  • 'correct': neighboring-source pixels are replaced by the values mirrored across the aperture center (excluded if the mirror pixel is unavailable).

By default, the source label for each aperture is determined automatically by sampling the segmentation image at the aperture center; labels may also be provided explicitly via the labels keyword:

>>> import numpy as np
>>> from photutils.aperture import CircularAperture, aperture_photometry
>>> data = np.ones((11, 11))
>>> data[4:7, 4:7] = 10.0  # target source
>>> data[4:7, 7:10] = 50.0  # bright neighbor
>>> segm = np.zeros((11, 11), dtype=int)
>>> segm[4:7, 4:7] = 1
>>> segm[4:7, 7:10] = 2
>>> aperture = CircularAperture((5, 5), r=4)
>>> phot = aperture_photometry(data, aperture, segmentation_image=segm,
...                            mask_method='mask')

GriddedPSFModel Performance Improvements#

GriddedPSFModel evaluations are now significantly faster, with typical performance improvements of approximately 20–25%. The optimizations reduce the overhead of PSF interpolation, improving the runtime of PSF photometry workflows.

API Changes#

New Deprecations#

Removed Deprecations#

Breaking Changes#