Skip to content

Commit a929ea1

Browse files
authored
Merge pull request #2240 from larrybradley/aperture-wcs
Improve aperture pixel/sky conversions
2 parents 32154c2 + 8bd00c6 commit a929ea1

File tree

10 files changed

+2417
-221
lines changed

10 files changed

+2417
-221
lines changed

CHANGES.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ General
1717
New Features
1818
^^^^^^^^^^^^
1919

20+
- ``photutils.aperture``
21+
22+
- The ``to_sky`` and ``to_pixel`` methods for all pixel and sky
23+
aperture types now use the local WCS Jacobian to accurately compute
24+
pixel scale factors and rotation angles. This correctly handles WCS
25+
with distortions (e.g., SIP polynomial corrections) and non-square
26+
pixels. [#2240]
27+
2028
- ``photutils.background``
2129

2230
- Added a ``to_aperture`` method to ``LocalBackground``. [#2118]

docs/user_guide/aperture.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,17 @@ sky apertures, e.g.,::
132132
>>> pix_aperture # doctest: +FLOAT_CMP
133133
<CircularAperture([26.14628817, 56.58410628], r=4.000000000439743)>
134134

135+
.. note::
136+
137+
Aperture objects require scalar shape parameters (e.g., radius,
138+
semi-axes, angle), so only a single reference position can be
139+
used for computing local WCS properties (pixel scale, rotation
140+
angle). For apertures with multiple positions, the first position
141+
is used. Apertures with multiple positions used with a WCS that
142+
has spatially-varying distortions may produce inaccurate shape
143+
conversions for positions far from the first position.
144+
145+
135146
Performing Aperture Photometry
136147
------------------------------
137148

docs/whats_new/3.0.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,34 @@ parameters::
414414
... centroid_func=centroid_func)
415415

416416

417+
Improved Aperture Pixel-to-Sky Conversions
418+
==========================================
419+
420+
The :meth:`~photutils.aperture.PixelAperture.to_sky` and
421+
:meth:`~photutils.aperture.SkyAperture.to_pixel` methods for all
422+
aperture types have been significantly improved. They now use the local
423+
WCS Jacobian to accurately compute pixel scale factors and rotation
424+
angles, correctly handling WCS with distortions (e.g., SIP polynomial
425+
corrections) and non-square pixels.
426+
427+
For elliptical and rectangular apertures specifically, the conversion
428+
now uses singular value decomposition (SVD) of the local Jacobian to
429+
compute independent scale factors along the width and height axes. This
430+
is more accurate than applying a single mean pixel scale to all linear
431+
dimensions, particularly near the edge of a distorted WCS or when the
432+
pixel scale differs significantly along the two axes.
433+
434+
For all aperture types, the WCS properties (pixel scale, rotation angle)
435+
are evaluated at the first aperture position. Because aperture objects
436+
require scalar shape parameters, only a single reference position can
437+
be used. For apertures with multiple positions used with a WCS that
438+
has spatially-varying distortions, this may produce inaccurate shape
439+
conversions for positions far from the first position. This behavior
440+
is now clearly documented in the :ref:`photutils-aperture` user guide
441+
and in the ``Notes`` section of each ``to_sky`` and ``to_pixel`` method
442+
docstring.
443+
444+
417445
API Changes
418446
===========
419447

photutils/aperture/circle.py

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66

77
import math
88

9+
import astropy.units as u
10+
import numpy as np
11+
from astropy.coordinates import Angle
912
from astropy.utils import lazyproperty
1013

1114
from photutils.aperture.attributes import (PixelPositions, PositiveScalar,
@@ -15,6 +18,8 @@
1518
from photutils.aperture.mask import ApertureMask
1619
from photutils.geometry import circular_overlap_grid
1720
from photutils.utils._deprecation import deprecated_positional_kwargs
21+
from photutils.utils._wcs_helpers import (pixel_to_sky_mean_scale,
22+
sky_to_pixel_mean_scale)
1823

1924
__all__ = [
2025
'CircularAnnulus',
@@ -222,8 +227,26 @@ def to_sky(self, wcs):
222227
-------
223228
aperture : `SkyCircularAperture` object
224229
A `SkyCircularAperture` object.
230+
231+
Notes
232+
-----
233+
The aperture shape parameters are converted using the local
234+
WCS pixel scale evaluated at the first aperture position.
235+
Because aperture objects require scalar shape parameters, only
236+
a single reference position is used for the conversion. For
237+
apertures with multiple positions used with a WCS that has
238+
spatially-varying distortions, this may produce inaccurate
239+
results for positions far from the first position.
225240
"""
226-
return SkyCircularAperture(**self._to_sky_params(wcs))
241+
xpos, ypos = np.transpose(self.positions)
242+
positions = wcs.pixel_to_world(xpos, ypos)
243+
244+
first_pos = np.atleast_2d(self.positions)[0]
245+
_, mean_scale = pixel_to_sky_mean_scale(
246+
(float(first_pos[0]), float(first_pos[1])), wcs)
247+
248+
r = Angle(self.r * mean_scale, 'arcsec')
249+
return SkyCircularAperture(positions=positions, r=r)
227250

228251

229252
class CircularAnnulus(CircularMaskMixin, PixelAperture):
@@ -356,8 +379,27 @@ def to_sky(self, wcs):
356379
-------
357380
aperture : `SkyCircularAnnulus` object
358381
A `SkyCircularAnnulus` object.
382+
383+
Notes
384+
-----
385+
The aperture shape parameters are converted using the local
386+
WCS pixel scale evaluated at the first aperture position.
387+
Because aperture objects require scalar shape parameters, only
388+
a single reference position is used for the conversion. For
389+
apertures with multiple positions used with a WCS that has
390+
spatially-varying distortions, this may produce inaccurate
391+
results for positions far from the first position.
359392
"""
360-
return SkyCircularAnnulus(**self._to_sky_params(wcs))
393+
xpos, ypos = np.transpose(self.positions)
394+
positions = wcs.pixel_to_world(xpos, ypos)
395+
396+
first_pos = np.atleast_2d(self.positions)[0]
397+
_, mean_scale = pixel_to_sky_mean_scale(
398+
(float(first_pos[0]), float(first_pos[1])), wcs)
399+
400+
r_in = Angle(self.r_in * mean_scale, 'arcsec')
401+
r_out = Angle(self.r_out * mean_scale, 'arcsec')
402+
return SkyCircularAnnulus(positions=positions, r_in=r_in, r_out=r_out)
361403

362404

363405
class SkyCircularAperture(SkyAperture):
@@ -410,8 +452,25 @@ def to_pixel(self, wcs):
410452
-------
411453
aperture : `CircularAperture` object
412454
A `CircularAperture` object.
455+
456+
Notes
457+
-----
458+
The aperture shape parameters are converted using the local
459+
WCS pixel scale evaluated at the first aperture position.
460+
Because aperture objects require scalar shape parameters, only
461+
a single reference position is used for the conversion. For
462+
apertures with multiple positions used with a WCS that has
463+
spatially-varying distortions, this may produce inaccurate
464+
results for positions far from the first position.
413465
"""
414-
return CircularAperture(**self._to_pixel_params(wcs))
466+
xpos, ypos = wcs.world_to_pixel(self.positions)
467+
positions = np.transpose((xpos, ypos))
468+
469+
skypos = self.positions if self.isscalar else self.positions[0]
470+
_, mean_scale = sky_to_pixel_mean_scale(skypos, wcs)
471+
472+
r = self.r.to(u.arcsec).value * mean_scale
473+
return CircularAperture(positions=positions, r=r)
415474

416475

417476
class SkyCircularAnnulus(SkyAperture):
@@ -473,5 +532,23 @@ def to_pixel(self, wcs):
473532
-------
474533
aperture : `CircularAnnulus` object
475534
A `CircularAnnulus` object.
535+
536+
Notes
537+
-----
538+
The aperture shape parameters are converted using the local
539+
WCS pixel scale evaluated at the first aperture position.
540+
Because aperture objects require scalar shape parameters, only
541+
a single reference position is used for the conversion. For
542+
apertures with multiple positions used with a WCS that has
543+
spatially-varying distortions, this may produce inaccurate
544+
results for positions far from the first position.
476545
"""
477-
return CircularAnnulus(**self._to_pixel_params(wcs))
546+
xpos, ypos = wcs.world_to_pixel(self.positions)
547+
positions = np.transpose((xpos, ypos))
548+
549+
skypos = self.positions if self.isscalar else self.positions[0]
550+
_, mean_scale = sky_to_pixel_mean_scale(skypos, wcs)
551+
552+
r_in = self.r_in.to(u.arcsec).value * mean_scale
553+
r_out = self.r_out.to(u.arcsec).value * mean_scale
554+
return CircularAnnulus(positions=positions, r_in=r_in, r_out=r_out)

photutils/aperture/core.py

Lines changed: 0 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,12 @@
88
import warnings
99
from copy import deepcopy
1010

11-
import astropy.units as u
1211
import numpy as np
1312
from astropy.coordinates import SkyCoord
1413
from astropy.utils import lazyproperty
1514

1615
from photutils.aperture.bounding_box import BoundingBox
1716
from photutils.utils._deprecation import deprecated_positional_kwargs
18-
from photutils.utils._wcs_helpers import _pixel_scale_angle_at_skycoord
1917

2018
__all__ = ['Aperture', 'PixelAperture', 'SkyAperture']
2119

@@ -714,59 +712,6 @@ def plot(self, ax=None, origin=(0, 0), **kwargs):
714712

715713
return patches
716714

717-
def _to_sky_params(self, wcs):
718-
"""
719-
Convert the pixel aperture parameters to those for a sky
720-
aperture.
721-
722-
Parameters
723-
----------
724-
wcs : WCS object
725-
A world coordinate system (WCS) transformation that
726-
supports the `astropy shared interface for WCS
727-
<https://docs.astropy.org/en/stable/wcs/wcsapi.html>`_
728-
(e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`).
729-
730-
Returns
731-
-------
732-
sky_params : dict
733-
A dictionary of parameters for an equivalent sky aperture.
734-
"""
735-
sky_params = {}
736-
xpos, ypos = np.transpose(self.positions)
737-
sky_params['positions'] = skypos = wcs.pixel_to_world(xpos, ypos)
738-
739-
# Aperture objects require scalar shape parameters (e.g.,
740-
# radius, a, b, theta, etc.), therefore we must calculate the
741-
# pixel scale and angle at only a single sky position, which
742-
# we take as the first aperture position. For apertures with
743-
# multiple positions used with a WCS that contains distortions
744-
# (e.g., a spatially-dependent pixel scale), this may lead to
745-
# unexpected results (e.g., results that are dependent of the
746-
# order of the positions). There is no good way to fix this with
747-
# the current Aperture API allowing multiple positions.
748-
if not self.isscalar:
749-
skypos = skypos[0]
750-
_, pixscale, angle = _pixel_scale_angle_at_skycoord(skypos, wcs)
751-
752-
for param in self._params:
753-
value = getattr(self, param)
754-
if param == 'positions':
755-
continue
756-
757-
if param == 'theta':
758-
# photutils aperture sky angles are defined as the PA of
759-
# the semimajor axis (i.e., relative to the WCS latitude
760-
# axis). region sky angles are defined relative to the WCS
761-
# longitude axis.
762-
value = value - angle.to(u.rad)
763-
else:
764-
value = (value * u.pix * pixscale).to(u.arcsec)
765-
766-
sky_params[param] = value
767-
768-
return sky_params
769-
770715
@abc.abstractmethod
771716
def to_sky(self, wcs):
772717
"""
@@ -794,61 +739,6 @@ class SkyAperture(Aperture):
794739
coordinates.
795740
"""
796741

797-
def _to_pixel_params(self, wcs):
798-
"""
799-
Convert the sky aperture parameters to those for a pixel
800-
aperture.
801-
802-
Parameters
803-
----------
804-
wcs : WCS object
805-
A world coordinate system (WCS) transformation that
806-
supports the `astropy shared interface for WCS
807-
<https://docs.astropy.org/en/stable/wcs/wcsapi.html>`_
808-
(e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`).
809-
810-
Returns
811-
-------
812-
pixel_params : dict
813-
A dictionary of parameters for an equivalent pixel aperture.
814-
"""
815-
pixel_params = {}
816-
817-
xpos, ypos = wcs.world_to_pixel(self.positions)
818-
pixel_params['positions'] = np.transpose((xpos, ypos))
819-
820-
# Aperture objects require scalar shape parameters (e.g.,
821-
# radius, a, b, theta, etc.), therefore we must calculate the
822-
# pixel scale and angle at only a single sky position, which
823-
# we take as the first aperture position. For apertures with
824-
# multiple positions used with a WCS that contains distortions
825-
# (e.g., a spatially-dependent pixel scale), this may lead to
826-
# unexpected results (e.g., results that are dependent of the
827-
# order of the positions). There is no good way to fix this with
828-
# the current Aperture API allowing multiple positions.
829-
skypos = self.positions if self.isscalar else self.positions[0]
830-
_, pixscale, angle = _pixel_scale_angle_at_skycoord(skypos, wcs)
831-
832-
for param in self._params:
833-
value = getattr(self, param)
834-
if param == 'positions':
835-
continue
836-
837-
if param == 'theta':
838-
# photutils aperture sky angles are defined as the PA of
839-
# the semimajor axis (i.e., relative to the WCS latitude
840-
# axis). region sky angles are defined relative to the WCS
841-
# longitude axis.
842-
value = (value + angle).to(u.radian)
843-
elif value.unit.physical_type == 'angle':
844-
value = (value / pixscale).to(u.pixel).value
845-
else:
846-
value = value.value
847-
848-
pixel_params[param] = value
849-
850-
return pixel_params
851-
852742
@abc.abstractmethod
853743
def to_pixel(self, wcs):
854744
"""

0 commit comments

Comments
 (0)