Skip to content

Commit 5c412f2

Browse files
committed
Use principal WCS axes
1 parent 1ab9765 commit 5c412f2

File tree

4 files changed

+157
-25
lines changed

4 files changed

+157
-25
lines changed

regions/_utils/wcs_helpers.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,136 @@ def sky_ellipse_to_pixel_svd(skycoord, wcs, width_arcsec, height_arcsec,
797797
return center, pixel_width, pixel_height, pixel_angle
798798

799799

800+
def sky_to_pixel_svd_scales(skycoord, wcs):
801+
"""
802+
Compute the pixel center, principal-axis scale factors, and pixel
803+
angle for a sky-to-pixel conversion using SVD of the local Jacobian.
804+
805+
Uses the singular value decomposition (SVD) of the local Jacobian
806+
``J = d(pixel)/d(sky_arcsec)`` to find the natural principal axes
807+
of the WCS transformation at the given sky position. The singular
808+
values give the scale factors along the major and minor axes of the
809+
ellipse that a unit circle in sky space maps to in pixel space. The
810+
left singular vectors give the directions of those axes in pixel
811+
coordinates.
812+
813+
This is the appropriate method for converting a circular sky region
814+
to a pixel ellipse, as the resulting ellipse accurately represents
815+
the true shape of the WCS mapping (i.e., the tightest-fitting pixel
816+
ellipse that contains the sky circle).
817+
818+
Parameters
819+
----------
820+
skycoord : `~astropy.coordinates.SkyCoord`
821+
The sky coordinate of the region center.
822+
823+
wcs : WCS object
824+
A world coordinate system (WCS) transformation that
825+
supports the `astropy shared interface for WCS
826+
<https://docs.astropy.org/en/stable/wcs/wcsapi.html>`_ (e.g.,
827+
`astropy.wcs.WCS`, `gwcs.wcs.WCS`).
828+
829+
Returns
830+
-------
831+
center : `~regions.PixCoord`
832+
The pixel center position.
833+
834+
scale_major : float
835+
The scale factor along the major (maximum-stretch) axis
836+
(pixels per arcsec).
837+
838+
scale_minor : float
839+
The scale factor along the minor (minimum-stretch) axis
840+
(pixels per arcsec).
841+
842+
pixel_angle : `~astropy.coordinates.Angle`
843+
The pixel rotation angle of the major axis, measured
844+
counterclockwise from the positive x-axis, wrapped to
845+
[0, 360) degrees.
846+
"""
847+
x0, y0 = wcs.world_to_pixel(skycoord)
848+
center = PixCoord(x=x0, y=y0)
849+
850+
jacobian = compute_local_wcs_jacobian(skycoord, wcs)
851+
u_mat, s_vals, _vt = np.linalg.svd(jacobian)
852+
853+
# Pixel angle of the major axis: direction of u_mat[:,0] in pixel
854+
# space. No parity correction needed — pixel space has no axis
855+
# reflection.
856+
pixel_angle = Angle(
857+
np.rad2deg(np.arctan2(u_mat[1, 0], u_mat[0, 0])) * u.deg).wrap_at(
858+
360 * u.deg)
859+
860+
return center, s_vals[0], s_vals[1], pixel_angle
861+
862+
863+
def pixel_to_sky_svd_scales(pixcoord, wcs):
864+
"""
865+
Compute the sky center, principal-axis scale factors, and sky angle
866+
for a pixel-to-sky conversion using SVD of the inverse Jacobian.
867+
868+
Uses the singular value decomposition (SVD) of the local inverse
869+
Jacobian ``J^{-1} = d(sky)/d(pixel)`` to find the natural principal
870+
axes of the WCS transformation at the given pixel position. The
871+
singular values give the scale factors along the major and minor
872+
axes of the ellipse that a unit circle in pixel space maps to in sky
873+
space. The left singular vectors give the directions of those axes
874+
in tangent-plane coordinates.
875+
876+
This is the appropriate method for converting a circular pixel
877+
region to a sky ellipse, as the resulting ellipse accurately
878+
represents the true shape of the WCS mapping (i.e., the
879+
tightest-fitting sky ellipse that contains the pixel circle).
880+
881+
Parameters
882+
----------
883+
pixcoord : `~regions.PixCoord`
884+
The pixel coordinate of the region center.
885+
886+
wcs : WCS object
887+
A world coordinate system (WCS) transformation that
888+
supports the `astropy shared interface for WCS
889+
<https://docs.astropy.org/en/stable/wcs/wcsapi.html>`_ (e.g.,
890+
`astropy.wcs.WCS`, `gwcs.wcs.WCS`).
891+
892+
Returns
893+
-------
894+
center : `~astropy.coordinates.SkyCoord`
895+
The sky center position.
896+
897+
scale_major : float
898+
The scale factor along the major (maximum-stretch) axis
899+
(arcsec per pixel).
900+
901+
scale_minor : float
902+
The scale factor along the minor (minimum-stretch) axis
903+
(arcsec per pixel).
904+
905+
sky_angle : `~astropy.coordinates.Angle`
906+
The sky rotation angle of the major axis, measured
907+
counterclockwise from the longitude (RA) axis, wrapped to
908+
[0, 360) degrees.
909+
"""
910+
center = wcs.pixel_to_world(pixcoord.x, pixcoord.y)
911+
912+
jacobian = compute_local_wcs_jacobian(center, wcs)
913+
jacobian_inv = np.linalg.inv(jacobian)
914+
915+
# WCS parity: standard WCS has RA increasing to the left, giving
916+
# det(J) < 0. Apply parity to the RA component of U[:,0] to recover
917+
# the correct sky angle from the tangent-plane direction.
918+
parity = np.sign(np.linalg.det(jacobian))
919+
920+
u_mat, s_vals, _vt = np.linalg.svd(jacobian_inv)
921+
922+
# Sky angle of the major axis in tangent-plane coordinates
923+
sky_angle = Angle(
924+
np.rad2deg(np.arctan2(u_mat[1, 0], parity * u_mat[0, 0])) * u.deg
925+
).wrap_at(360 * u.deg)
926+
927+
return center, s_vals[0], s_vals[1], sky_angle
928+
929+
800930
def wcs_pixel_scale_angle(skycoord, wcs):
801931
"""
802932
Calculate the pixel coordinate, scale, and WCS rotation angle at the

regions/shapes/annulus.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
from regions._utils.wcs_helpers import (pixel_ellipse_to_sky_svd,
1212
pixel_to_sky_mean_scale,
1313
pixel_to_sky_scales,
14+
pixel_to_sky_svd_scales,
1415
sky_ellipse_to_pixel_svd,
1516
sky_to_pixel_mean_scale,
16-
sky_to_pixel_scales)
17+
sky_to_pixel_scales,
18+
sky_to_pixel_svd_scales)
1719
from regions.core.attributes import (PositiveScalar, PositiveScalarAngle,
1820
RegionMetaDescr, RegionVisualDescr,
1921
ScalarAngle, ScalarPixCoord,
@@ -179,12 +181,12 @@ def to_sky(self, wcs, *, use_ellipse=False):
179181
``use_ellipse`` is `True`.
180182
"""
181183
if use_ellipse:
182-
center, scale_w, scale_h, angle = pixel_to_sky_scales(
183-
self.center, wcs, 0.0)
184-
inner_width = 2 * self.inner_radius * scale_w * u.arcsec
185-
outer_width = 2 * self.outer_radius * scale_w * u.arcsec
186-
inner_height = 2 * self.inner_radius * scale_h * u.arcsec
187-
outer_height = 2 * self.outer_radius * scale_h * u.arcsec
184+
center, scale_major, scale_minor, angle = pixel_to_sky_svd_scales(
185+
self.center, wcs)
186+
inner_width = 2 * self.inner_radius * scale_major * u.arcsec
187+
outer_width = 2 * self.outer_radius * scale_major * u.arcsec
188+
inner_height = 2 * self.inner_radius * scale_minor * u.arcsec
189+
outer_height = 2 * self.outer_radius * scale_minor * u.arcsec
188190
return EllipseAnnulusSkyRegion(
189191
center, inner_width, outer_width,
190192
inner_height, outer_height, angle=angle,
@@ -286,14 +288,14 @@ def to_pixel(self, wcs, *, use_ellipse=False):
286288
``use_ellipse`` is `True`.
287289
"""
288290
if use_ellipse:
289-
center, scale_w, scale_h, angle = sky_to_pixel_scales(
290-
self.center, wcs, 0.0)
291+
center, scale_major, scale_minor, angle = sky_to_pixel_svd_scales(
292+
self.center, wcs)
291293
inner_radius_arcsec = self.inner_radius.to(u.arcsec).value
292294
outer_radius_arcsec = self.outer_radius.to(u.arcsec).value
293-
inner_width = 2 * inner_radius_arcsec * scale_w
294-
outer_width = 2 * outer_radius_arcsec * scale_w
295-
inner_height = 2 * inner_radius_arcsec * scale_h
296-
outer_height = 2 * outer_radius_arcsec * scale_h
295+
inner_width = 2 * inner_radius_arcsec * scale_major
296+
outer_width = 2 * outer_radius_arcsec * scale_major
297+
inner_height = 2 * inner_radius_arcsec * scale_minor
298+
outer_height = 2 * outer_radius_arcsec * scale_minor
297299
return EllipseAnnulusPixelRegion(
298300
center, inner_width, outer_width,
299301
inner_height, outer_height, angle=angle,

regions/shapes/circle.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111

1212
from regions._geometry import circular_overlap_grid
1313
from regions._utils.wcs_helpers import (pixel_to_sky_mean_scale,
14-
pixel_to_sky_scales,
14+
pixel_to_sky_svd_scales,
1515
sky_to_pixel_mean_scale,
16-
sky_to_pixel_scales)
16+
sky_to_pixel_svd_scales)
1717
from regions.core.attributes import (PositiveScalar, PositiveScalarAngle,
1818
RegionMetaDescr, RegionVisualDescr,
1919
ScalarPixCoord, ScalarSkyCoord)
@@ -115,10 +115,10 @@ def to_sky(self, wcs, *, use_ellipse=False):
115115
is `True`.
116116
"""
117117
if use_ellipse:
118-
center, scale_w, scale_h, angle = pixel_to_sky_scales(
119-
self.center, wcs, 0.0)
120-
width = Angle(2 * self.radius * scale_w, 'arcsec')
121-
height = Angle(2 * self.radius * scale_h, 'arcsec')
118+
center, scale_major, scale_minor, angle = pixel_to_sky_svd_scales(
119+
self.center, wcs)
120+
width = Angle(2 * self.radius * scale_major, 'arcsec')
121+
height = Angle(2 * self.radius * scale_minor, 'arcsec')
122122
return EllipseSkyRegion(center, width, height, angle=angle,
123123
meta=self.meta.copy(),
124124
visual=self.visual.copy())
@@ -295,11 +295,11 @@ def to_pixel(self, wcs, *, use_ellipse=False):
295295
is `True`.
296296
"""
297297
if use_ellipse:
298-
center, scale_w, scale_h, angle = sky_to_pixel_scales(
299-
self.center, wcs, 0.0)
298+
center, scale_major, scale_minor, angle = sky_to_pixel_svd_scales(
299+
self.center, wcs)
300300
radius_arcsec = self.radius.to(u.arcsec).value
301-
width = 2 * radius_arcsec * scale_w
302-
height = 2 * radius_arcsec * scale_h
301+
width = 2 * radius_arcsec * scale_major
302+
height = 2 * radius_arcsec * scale_minor
303303
return EllipsePixelRegion(center, width, height,
304304
angle=angle,
305305
meta=self.meta.copy(),

regions/shapes/tests/test_circle.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,8 @@ def test_to_sky_ellipse_roundtrip(self):
169169
pix_ellipse = sky_ellipse.to_pixel(self.wcs)
170170
# For a simple WCS without distortion, the roundtrip should
171171
# recover the original diameter as width and height
172-
assert_allclose(pix_ellipse.width, 2 * self.radius, rtol=1e-5)
173-
assert_allclose(pix_ellipse.height, 2 * self.radius, rtol=1e-5)
172+
assert_allclose(pix_ellipse.width, 2 * self.radius, rtol=1e-4)
173+
assert_allclose(pix_ellipse.height, 2 * self.radius, rtol=1e-4)
174174
assert_allclose(pix_ellipse.center.x, self.center.x, rtol=1e-5)
175175
assert_allclose(pix_ellipse.center.y, self.center.y, rtol=1e-5)
176176

0 commit comments

Comments
 (0)