stdpipe 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stdpipe/__init__.py +9 -0
- stdpipe/artefacts.py +370 -0
- stdpipe/astrometry.py +2001 -0
- stdpipe/astrometry_quad.py +1643 -0
- stdpipe/astrometry_wcs.py +1141 -0
- stdpipe/catalogs.py +942 -0
- stdpipe/cutouts.py +570 -0
- stdpipe/data/legacysurvey_bricks.fits.gz +0 -0
- stdpipe/data/ps1skycells.txt +199708 -0
- stdpipe/db.py +130 -0
- stdpipe/fringe_removal.py +1086 -0
- stdpipe/lcs.py +440 -0
- stdpipe/photometry.py +2340 -0
- stdpipe/photometry_background.py +998 -0
- stdpipe/photometry_iraf.py +913 -0
- stdpipe/photometry_measure.py +2268 -0
- stdpipe/photometry_model.py +931 -0
- stdpipe/photometry_psf.py +1124 -0
- stdpipe/photometry_quality.py +185 -0
- stdpipe/pipeline.py +1303 -0
- stdpipe/plots.py +1582 -0
- stdpipe/psf.py +1542 -0
- stdpipe/realbogus.py +971 -0
- stdpipe/realbogus_features.py +1657 -0
- stdpipe/reproject.py +943 -0
- stdpipe/resolve.py +267 -0
- stdpipe/sfft.py +1077 -0
- stdpipe/simulation.py +2131 -0
- stdpipe/smoothing.py +488 -0
- stdpipe/subtraction.py +1324 -0
- stdpipe/templates.py +929 -0
- stdpipe/utils.py +365 -0
- stdpipe-0.4.0.dist-info/METADATA +171 -0
- stdpipe-0.4.0.dist-info/RECORD +37 -0
- stdpipe-0.4.0.dist-info/WHEEL +5 -0
- stdpipe-0.4.0.dist-info/licenses/LICENSE.md +21 -0
- stdpipe-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from astropy.wcs import WCS
|
|
5
|
+
from astropy.coordinates import SkyCoord, SkyOffsetFrame
|
|
6
|
+
import astropy.units as u
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _sky_residuals_arcsec(
|
|
10
|
+
wcs: WCS, xy: np.ndarray, frame, off: SkyOffsetFrame, sky_off: SkyCoord, coslat: np.ndarray
|
|
11
|
+
) -> np.ndarray:
|
|
12
|
+
"""
|
|
13
|
+
Residuals in offset-frame arcsec around the field center.
|
|
14
|
+
|
|
15
|
+
The reference positions are precomputed in the offset frame (`sky_off`,
|
|
16
|
+
with `coslat` = cos of their offset latitudes) since they do not change
|
|
17
|
+
during the fit. Longitude offsets are scaled by cos(lat) so that both
|
|
18
|
+
components are proper angular distances even far from the center.
|
|
19
|
+
|
|
20
|
+
Returns concatenated [d_lon_arcsec, d_lat_arcsec] per point.
|
|
21
|
+
"""
|
|
22
|
+
ra, dec = wcs.all_pix2world(xy[:, 0], xy[:, 1], 0)
|
|
23
|
+
model = SkyCoord(ra * u.deg, dec * u.deg, frame=frame)
|
|
24
|
+
model_off = model.transform_to(off)
|
|
25
|
+
|
|
26
|
+
dlon = (model_off.lon - sky_off.lon).to_value(u.arcsec) * coslat
|
|
27
|
+
dlat = (model_off.lat - sky_off.lat).to_value(u.arcsec)
|
|
28
|
+
return np.concatenate([dlon, dlat])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _spherical_mean(sky: SkyCoord) -> SkyCoord:
|
|
32
|
+
"""Direction of the mean unit vector of *sky* (robust to the lon=0/360 wrap)."""
|
|
33
|
+
xyz = sky.cartesian.xyz.value
|
|
34
|
+
mean = np.mean(np.atleast_2d(xyz), axis=1)
|
|
35
|
+
norm = np.linalg.norm(mean)
|
|
36
|
+
if not np.isfinite(norm) or norm <= 0:
|
|
37
|
+
raise ValueError("Cannot determine field center from reference positions")
|
|
38
|
+
mean = mean / norm
|
|
39
|
+
return SkyCoord(
|
|
40
|
+
np.degrees(np.arctan2(mean[1], mean[0])) * u.deg,
|
|
41
|
+
np.degrees(np.arcsin(np.clip(mean[2], -1.0, 1.0))) * u.deg,
|
|
42
|
+
frame=sky.frame,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _pack_params(w: WCS, pv_deg: int) -> np.ndarray:
|
|
47
|
+
# CRPIX, CRVAL, CD(2x2), PV2_0..PV2_pv_deg
|
|
48
|
+
p = []
|
|
49
|
+
p += [float(w.wcs.crpix[0]), float(w.wcs.crpix[1])]
|
|
50
|
+
p += [float(w.wcs.crval[0]), float(w.wcs.crval[1])]
|
|
51
|
+
cd = np.array(w.wcs.cd, dtype=float)
|
|
52
|
+
p += [cd[0, 0], cd[0, 1], cd[1, 0], cd[1, 1]]
|
|
53
|
+
|
|
54
|
+
pv = np.zeros(pv_deg + 1, dtype=float)
|
|
55
|
+
# astropy stores PV in w.wcs.get_pv() / w.wcs.set_pv(); get_pv returns list of (i, m, value)
|
|
56
|
+
for i, m, val in w.wcs.get_pv():
|
|
57
|
+
if i == 2 and 0 <= m <= pv_deg:
|
|
58
|
+
pv[m] = float(val)
|
|
59
|
+
p += pv.tolist()
|
|
60
|
+
return np.array(p, dtype=float)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _unpack_params_to_wcs(base: WCS, p: np.ndarray, pv_deg: int) -> WCS:
|
|
64
|
+
w = base.deepcopy()
|
|
65
|
+
w.wcs.crpix = [p[0], p[1]]
|
|
66
|
+
w.wcs.crval = [p[2], p[3]]
|
|
67
|
+
w.wcs.cd = np.array([[p[4], p[5]], [p[6], p[7]]], dtype=float)
|
|
68
|
+
|
|
69
|
+
pv_vals = p[8 : 8 + (pv_deg + 1)]
|
|
70
|
+
# Preserve any PV keywords for other axes (e.g. PV1_*)
|
|
71
|
+
pv_list = [(i, m, val) for (i, m, val) in base.wcs.get_pv() if i != 2]
|
|
72
|
+
pv_list += [(2, m, float(pv_vals[m])) for m in range(pv_deg + 1)]
|
|
73
|
+
w.wcs.set_pv(pv_list)
|
|
74
|
+
return w
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _normalize_zpn_pv1(w: WCS) -> WCS:
|
|
78
|
+
"""Normalize a ZPN WCS so that PV2_1 = 1, absorbing the scale into CD.
|
|
79
|
+
|
|
80
|
+
The ZPN projection R = Σ PV2_m·θ^m has a degeneracy: scaling all PV
|
|
81
|
+
by ``c`` and CD by ``c`` gives an identical pixel→sky mapping. This
|
|
82
|
+
function normalizes PV2_1 to 1 (the FITS standard convention), moving
|
|
83
|
+
any absorbed plate-scale factor into the CD matrix.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
w : WCS
|
|
88
|
+
ZPN WCS (modified in place).
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
WCS
|
|
93
|
+
The same object, normalized.
|
|
94
|
+
"""
|
|
95
|
+
pv_dict = {}
|
|
96
|
+
for i, m, val in w.wcs.get_pv():
|
|
97
|
+
if i == 2:
|
|
98
|
+
pv_dict[m] = val
|
|
99
|
+
|
|
100
|
+
pv1 = pv_dict.get(1, 1.0)
|
|
101
|
+
if not np.isfinite(pv1) or abs(pv1) < 1e-15 or abs(pv1 - 1.0) < 1e-12:
|
|
102
|
+
return w # already normalized or can't normalize
|
|
103
|
+
|
|
104
|
+
scale = 1.0 / pv1
|
|
105
|
+
|
|
106
|
+
# CD' = CD/pv1 so that intermediate coords scale together with PV.
|
|
107
|
+
cd = np.array(w.wcs.cd, dtype=float)
|
|
108
|
+
w.wcs.cd = cd / pv1
|
|
109
|
+
|
|
110
|
+
# Scale all PV2 coefficients
|
|
111
|
+
pv_list = [(i, m, val) for (i, m, val) in w.wcs.get_pv() if i != 2]
|
|
112
|
+
for m, val in pv_dict.items():
|
|
113
|
+
pv_list.append((2, m, float(val * scale)))
|
|
114
|
+
w.wcs.set_pv(pv_list)
|
|
115
|
+
|
|
116
|
+
return w
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def fit_zpn_wcs_from_points(
|
|
120
|
+
xy: np.ndarray,
|
|
121
|
+
sky: SkyCoord,
|
|
122
|
+
wcs_init: WCS,
|
|
123
|
+
pv_deg: int = 5,
|
|
124
|
+
fit_crpix: bool = True,
|
|
125
|
+
fit_crval: bool = True,
|
|
126
|
+
fit_cd: bool = True,
|
|
127
|
+
fit_pv: bool = True,
|
|
128
|
+
robust_loss: str = "soft_l1",
|
|
129
|
+
f_scale_arcsec: float = 2.0,
|
|
130
|
+
max_nfev: int = 200,
|
|
131
|
+
verbose: bool = False,
|
|
132
|
+
):
|
|
133
|
+
"""
|
|
134
|
+
Fit a ZPN WCS by optimizing WCS parameters against matched (x,y) <-> (ra,dec).
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
xy : (N,2) array
|
|
139
|
+
Pixel coordinates (0-based as in astropy WCS, i.e. origin=0).
|
|
140
|
+
sky : SkyCoord (N)
|
|
141
|
+
Reference sky positions.
|
|
142
|
+
wcs_init : astropy.wcs.WCS
|
|
143
|
+
Initial WCS; MUST already be ZPN (RA---ZPN/DEC--ZPN) or at least usable as base.
|
|
144
|
+
pv_deg : int
|
|
145
|
+
Degree for PV2_m coefficients to fit (PV2_0..PV2_pv_deg).
|
|
146
|
+
fit_* : bool
|
|
147
|
+
Toggle which parameter blocks to optimize.
|
|
148
|
+
robust_loss : str
|
|
149
|
+
Passed to scipy.optimize.least_squares(loss=...).
|
|
150
|
+
Good options: 'linear', 'soft_l1', 'huber', 'cauchy'.
|
|
151
|
+
f_scale_arcsec : float
|
|
152
|
+
Robust loss scale in arcsec.
|
|
153
|
+
max_nfev : int
|
|
154
|
+
Optimization iterations (SciPy).
|
|
155
|
+
verbose : bool or callable
|
|
156
|
+
Whether to show verbose messages during the run of the function or not.
|
|
157
|
+
May also be a print-like function.
|
|
158
|
+
|
|
159
|
+
Returns
|
|
160
|
+
-------
|
|
161
|
+
wcs_best : astropy.wcs.WCS
|
|
162
|
+
result : scipy OptimizeResult (or None if nothing was fitted)
|
|
163
|
+
|
|
164
|
+
Notes
|
|
165
|
+
-----
|
|
166
|
+
For stability, the solver runs in two stages when *fit_pv* is True:
|
|
167
|
+
it first fits CRPIX/CRVAL/CD with PV fixed, then fits all free
|
|
168
|
+
parameters (including PV) with conservative bounds to prevent
|
|
169
|
+
invalid projections.
|
|
170
|
+
"""
|
|
171
|
+
try:
|
|
172
|
+
from scipy.optimize import least_squares
|
|
173
|
+
except ImportError as e:
|
|
174
|
+
raise RuntimeError(
|
|
175
|
+
"fit_zpn_wcs_from_points requires SciPy (scipy.optimize.least_squares)"
|
|
176
|
+
) from e
|
|
177
|
+
|
|
178
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
179
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
180
|
+
|
|
181
|
+
xy = np.asarray(xy, dtype=float)
|
|
182
|
+
if xy.ndim != 2 or xy.shape[1] != 2:
|
|
183
|
+
raise ValueError("xy must be (N,2)")
|
|
184
|
+
if len(sky) != xy.shape[0]:
|
|
185
|
+
raise ValueError("sky and xy must have the same length")
|
|
186
|
+
|
|
187
|
+
# Parameter packing assumes the CD convention; fold PC/CDELT into CD if needed
|
|
188
|
+
if not wcs_init.wcs.has_cd():
|
|
189
|
+
cd = np.array(wcs_init.pixel_scale_matrix, dtype=float)
|
|
190
|
+
wcs_init = wcs_init.deepcopy()
|
|
191
|
+
wcs_init.wcs.cdelt = np.ones(2)
|
|
192
|
+
if wcs_init.wcs.has_pc():
|
|
193
|
+
wcs_init.wcs.__delattr__("pc")
|
|
194
|
+
wcs_init.wcs.cd = cd
|
|
195
|
+
|
|
196
|
+
if fit_pv:
|
|
197
|
+
pv1_init = 0.0
|
|
198
|
+
for i, m, val in wcs_init.wcs.get_pv():
|
|
199
|
+
if i == 2 and m == 1:
|
|
200
|
+
pv1_init = float(val)
|
|
201
|
+
if not np.isfinite(pv1_init) or pv1_init == 0:
|
|
202
|
+
raise ValueError(
|
|
203
|
+
"Initial ZPN WCS has PV2_1 = 0 or undefined; the linear radial term "
|
|
204
|
+
"must be non-zero to fit PV coefficients "
|
|
205
|
+
"(e.g. initialize the WCS with tan_wcs_to_zpn())"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Stable center for residual evaluation: spherical mean of the reference
|
|
209
|
+
# positions (robust to the lon=0/360 wrap, unlike a coordinate-wise median)
|
|
210
|
+
center = _spherical_mean(sky)
|
|
211
|
+
|
|
212
|
+
# The offset frame and reference positions in it are constant during the
|
|
213
|
+
# fit, so compute them once here rather than per residual evaluation
|
|
214
|
+
off = SkyOffsetFrame(origin=center)
|
|
215
|
+
sky_off = sky.transform_to(off)
|
|
216
|
+
coslat = np.cos(sky_off.lat.to_value(u.rad))
|
|
217
|
+
|
|
218
|
+
def _estimate_theta_max_deg(w: WCS) -> float | None:
|
|
219
|
+
if w.pixel_shape is None:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
nx, ny = w.pixel_shape
|
|
223
|
+
if nx is None or ny is None:
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
# Prefer a pixel-scale estimate (robust to projection issues)
|
|
227
|
+
pixel_scale_deg = None
|
|
228
|
+
try:
|
|
229
|
+
pscales = w.proj_plane_pixel_scales()
|
|
230
|
+
if hasattr(pscales[0], "to_value"):
|
|
231
|
+
pixel_scale_deg = float(np.mean([s.to_value(u.deg) for s in pscales]))
|
|
232
|
+
else:
|
|
233
|
+
pixel_scale_deg = float(np.mean(pscales))
|
|
234
|
+
except Exception:
|
|
235
|
+
pixel_scale_deg = None
|
|
236
|
+
|
|
237
|
+
if pixel_scale_deg is not None and np.isfinite(pixel_scale_deg) and pixel_scale_deg > 0:
|
|
238
|
+
r_pix = 0.5 * np.hypot(nx, ny)
|
|
239
|
+
return max(float(pixel_scale_deg * r_pix), 0.01)
|
|
240
|
+
|
|
241
|
+
# Fallback: estimate from footprint if possible
|
|
242
|
+
try:
|
|
243
|
+
crpix = np.array(w.wcs.crpix, dtype=float)
|
|
244
|
+
corners = np.array(
|
|
245
|
+
[[0.0, 0.0], [nx - 1.0, 0.0], [0.0, ny - 1.0], [nx - 1.0, ny - 1.0]],
|
|
246
|
+
dtype=float,
|
|
247
|
+
)
|
|
248
|
+
ra_c, dec_c = w.all_pix2world(crpix[0], crpix[1], 0)
|
|
249
|
+
ra_k, dec_k = w.all_pix2world(corners[:, 0], corners[:, 1], 0)
|
|
250
|
+
if (
|
|
251
|
+
np.isfinite(ra_c)
|
|
252
|
+
and np.isfinite(dec_c)
|
|
253
|
+
and np.all(np.isfinite(ra_k))
|
|
254
|
+
and np.all(np.isfinite(dec_k))
|
|
255
|
+
):
|
|
256
|
+
center_c = SkyCoord(ra_c * u.deg, dec_c * u.deg, frame="icrs")
|
|
257
|
+
sky_k = SkyCoord(ra_k * u.deg, dec_k * u.deg, frame="icrs")
|
|
258
|
+
theta = float(np.max(center_c.separation(sky_k).to_value(u.deg)))
|
|
259
|
+
return max(theta, 0.01)
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
def _make_bounds(p0: np.ndarray, mask: np.ndarray, base: WCS, allow_pv: bool) -> tuple:
|
|
266
|
+
# Build bounds over the full parameter vector, then slice by mask.
|
|
267
|
+
# CRPIX/CRVAL/CD unbounded by default
|
|
268
|
+
lb = np.full_like(p0, -np.inf, dtype=float)
|
|
269
|
+
ub = np.full_like(p0, +np.inf, dtype=float)
|
|
270
|
+
|
|
271
|
+
if allow_pv and pv_deg >= 1:
|
|
272
|
+
# PV bounds: keep solution in a physically plausible neighborhood
|
|
273
|
+
pv0 = float(p0[8 + 0])
|
|
274
|
+
pv1 = float(p0[8 + 1]) # non-zero, guaranteed by the check above
|
|
275
|
+
|
|
276
|
+
theta_max_deg = _estimate_theta_max_deg(base)
|
|
277
|
+
pv1_abs = abs(pv1)
|
|
278
|
+
|
|
279
|
+
# PV2_0 ~ 0 (allow small drift; keep init inside bounds)
|
|
280
|
+
pv0_abs = max(1e-3, abs(pv0) * 2.0)
|
|
281
|
+
lb[8 + 0] = pv0 - pv0_abs
|
|
282
|
+
ub[8 + 0] = pv0 + pv0_abs
|
|
283
|
+
|
|
284
|
+
# PV2_1 (linear): allow moderate range, keep the initial sign
|
|
285
|
+
lo = min(max(0.1, pv1_abs * 0.2), pv1_abs)
|
|
286
|
+
hi = pv1_abs * 5.0
|
|
287
|
+
if pv1 > 0:
|
|
288
|
+
lb[8 + 1], ub[8 + 1] = lo, hi
|
|
289
|
+
else:
|
|
290
|
+
lb[8 + 1], ub[8 + 1] = -hi, -lo
|
|
291
|
+
|
|
292
|
+
# Higher-order PV terms: limit contribution at field edge
|
|
293
|
+
pv_frac = 0.3 # allow ~30% of linear term at the edge
|
|
294
|
+
for m in range(2, pv_deg + 1):
|
|
295
|
+
pv_m = float(p0[8 + m])
|
|
296
|
+
if theta_max_deg is not None and np.isfinite(theta_max_deg) and theta_max_deg > 0:
|
|
297
|
+
abs_bound = pv_frac * pv1_abs / (theta_max_deg ** (m - 1))
|
|
298
|
+
else:
|
|
299
|
+
abs_bound = max(abs(pv_m) * 5.0, 1e-6)
|
|
300
|
+
# Keep initial value inside bounds
|
|
301
|
+
abs_bound = max(abs_bound, abs(pv_m) * 2.0)
|
|
302
|
+
lb[8 + m] = pv_m - abs_bound
|
|
303
|
+
ub[8 + m] = pv_m + abs_bound
|
|
304
|
+
|
|
305
|
+
return lb[mask], ub[mask]
|
|
306
|
+
|
|
307
|
+
def _fit_with_mask(base: WCS, allow_pv: bool):
|
|
308
|
+
p0 = _pack_params(base, pv_deg=pv_deg)
|
|
309
|
+
|
|
310
|
+
# Build a mask over parameters to optionally freeze blocks
|
|
311
|
+
mask = np.ones_like(p0, dtype=bool)
|
|
312
|
+
# indices: 0..1 CRPIX, 2..3 CRVAL, 4..7 CD, 8.. PV
|
|
313
|
+
if not fit_crpix:
|
|
314
|
+
mask[0:2] = False
|
|
315
|
+
if not fit_crval:
|
|
316
|
+
mask[2:4] = False
|
|
317
|
+
if not fit_cd:
|
|
318
|
+
mask[4:8] = False
|
|
319
|
+
if not allow_pv:
|
|
320
|
+
mask[8:] = False
|
|
321
|
+
elif pv_deg >= 1:
|
|
322
|
+
# PV2_1 (linear scale) is degenerate with CD — let it float.
|
|
323
|
+
# Caller normalizes after fit via _normalize_zpn_pv1().
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
if not np.any(mask):
|
|
327
|
+
return base, None
|
|
328
|
+
|
|
329
|
+
free0 = p0[mask]
|
|
330
|
+
lb, ub = _make_bounds(p0, mask, base, allow_pv=allow_pv)
|
|
331
|
+
|
|
332
|
+
def make_wcs_from_free(free: np.ndarray) -> WCS:
|
|
333
|
+
p = p0.copy()
|
|
334
|
+
p[mask] = free
|
|
335
|
+
return _unpack_params_to_wcs(base, p, pv_deg=pv_deg)
|
|
336
|
+
|
|
337
|
+
def fun(free: np.ndarray) -> np.ndarray:
|
|
338
|
+
w = make_wcs_from_free(free)
|
|
339
|
+
try:
|
|
340
|
+
res = _sky_residuals_arcsec(w, xy, sky.frame, off, sky_off, coslat)
|
|
341
|
+
except ValueError:
|
|
342
|
+
# wcslib may reject some in-bounds PV combinations as an
|
|
343
|
+
# invalid projection; report a very poor fit instead of
|
|
344
|
+
# crashing so the optimizer backs off
|
|
345
|
+
return np.full(2 * xy.shape[0], 1e6)
|
|
346
|
+
if not np.all(np.isfinite(res)):
|
|
347
|
+
res = np.nan_to_num(res, nan=1e6, posinf=1e6, neginf=-1e6)
|
|
348
|
+
return res
|
|
349
|
+
|
|
350
|
+
res = least_squares(
|
|
351
|
+
fun,
|
|
352
|
+
free0,
|
|
353
|
+
bounds=(lb, ub),
|
|
354
|
+
loss=robust_loss,
|
|
355
|
+
f_scale=float(f_scale_arcsec),
|
|
356
|
+
max_nfev=int(max_nfev),
|
|
357
|
+
x_scale="jac",
|
|
358
|
+
verbose=0,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
w_best = make_wcs_from_free(res.x)
|
|
362
|
+
return w_best, res
|
|
363
|
+
|
|
364
|
+
def _log_residuals(label: str, res) -> None:
|
|
365
|
+
if res is None:
|
|
366
|
+
return
|
|
367
|
+
n = xy.shape[0]
|
|
368
|
+
r = np.hypot(res.fun[:n], res.fun[n:])
|
|
369
|
+
log(
|
|
370
|
+
f"ZPN fit {label}: median residual {np.median(r):.3g} arcsec "
|
|
371
|
+
f"over {n} points, {res.nfev} function evaluations"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Two-stage fit: first solve CRPIX/CRVAL/CD with PV fixed,
|
|
375
|
+
# then allow PV with conservative bounds.
|
|
376
|
+
w_curr = wcs_init
|
|
377
|
+
res_last = None
|
|
378
|
+
|
|
379
|
+
if fit_pv and (fit_crpix or fit_crval or fit_cd):
|
|
380
|
+
w_curr, res_last = _fit_with_mask(w_curr, allow_pv=False)
|
|
381
|
+
_log_residuals("linear stage (PV fixed)", res_last)
|
|
382
|
+
|
|
383
|
+
w_curr, res_last = _fit_with_mask(w_curr, allow_pv=fit_pv)
|
|
384
|
+
_log_residuals("final stage", res_last)
|
|
385
|
+
|
|
386
|
+
# Normalize so PV2_1 = 1 (breaks the CD/PV degeneracy)
|
|
387
|
+
if fit_pv:
|
|
388
|
+
w_curr = _normalize_zpn_pv1(w_curr)
|
|
389
|
+
|
|
390
|
+
return w_curr, res_last
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _fit_zpn_sip(
|
|
394
|
+
wcs_zpn,
|
|
395
|
+
xy,
|
|
396
|
+
sky,
|
|
397
|
+
sip_degree=2,
|
|
398
|
+
pv_deg=5,
|
|
399
|
+
n_iter=15,
|
|
400
|
+
robust_loss="soft_l1",
|
|
401
|
+
f_scale_arcsec=2.0,
|
|
402
|
+
max_nfev=200,
|
|
403
|
+
verbose=False,
|
|
404
|
+
):
|
|
405
|
+
"""Add SIP distortion corrections on top of a ZPN WCS.
|
|
406
|
+
|
|
407
|
+
The SIP polynomials A(u,v), B(u,v) correct for non-radial distortions
|
|
408
|
+
(coma, astigmatism, etc.) that the ZPN radial model cannot capture.
|
|
409
|
+
|
|
410
|
+
Iterates between fitting SIP coefficients (pixel-space residuals) and
|
|
411
|
+
re-optimizing PV parameters with SIP applied.
|
|
412
|
+
|
|
413
|
+
Parameters
|
|
414
|
+
----------
|
|
415
|
+
wcs_zpn : WCS
|
|
416
|
+
ZPN WCS with PV parameters already fitted (no SIP).
|
|
417
|
+
xy : (N, 2) array
|
|
418
|
+
Pixel coordinates of matched sources (0-based).
|
|
419
|
+
sky : SkyCoord
|
|
420
|
+
Reference sky positions.
|
|
421
|
+
sip_degree : int
|
|
422
|
+
SIP polynomial order (default 2).
|
|
423
|
+
pv_deg : int
|
|
424
|
+
ZPN PV polynomial degree for re-fitting.
|
|
425
|
+
n_iter : int
|
|
426
|
+
Maximal number of PV+SIP alternation iterations.
|
|
427
|
+
robust_loss, f_scale_arcsec, max_nfev :
|
|
428
|
+
Passed to ``fit_zpn_wcs_from_points`` for PV re-fitting.
|
|
429
|
+
verbose : bool or callable
|
|
430
|
+
Whether to show verbose messages during the run of the function or not.
|
|
431
|
+
May also be a print-like function.
|
|
432
|
+
|
|
433
|
+
Returns
|
|
434
|
+
-------
|
|
435
|
+
wcs_sip : WCS
|
|
436
|
+
ZPN-SIP WCS with both PV and SIP coefficients.
|
|
437
|
+
"""
|
|
438
|
+
from astropy.wcs.wcs import Sip
|
|
439
|
+
|
|
440
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
441
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
442
|
+
|
|
443
|
+
xy = np.asarray(xy, dtype=float)
|
|
444
|
+
w = wcs_zpn.deepcopy()
|
|
445
|
+
|
|
446
|
+
# SIP term indices: (p, q) with 2 <= p+q <= sip_degree
|
|
447
|
+
pq = []
|
|
448
|
+
for total in range(2, sip_degree + 1):
|
|
449
|
+
for q in range(total + 1):
|
|
450
|
+
pq.append((total - q, q))
|
|
451
|
+
|
|
452
|
+
crpix = np.array(w.wcs.crpix, dtype=float) # 1-based
|
|
453
|
+
prev_q90 = np.inf
|
|
454
|
+
|
|
455
|
+
for iteration in range(n_iter):
|
|
456
|
+
# Strip SIP to get the base ZPN-only WCS
|
|
457
|
+
w_nosip = w.deepcopy()
|
|
458
|
+
w_nosip.sip = None
|
|
459
|
+
if '-SIP' in w_nosip.wcs.ctype[0]:
|
|
460
|
+
w_nosip.wcs.ctype = [c.replace('-SIP', '') for c in w_nosip.wcs.ctype]
|
|
461
|
+
|
|
462
|
+
# Undistorted pixel coords: where ZPN projection places catalog stars.
|
|
463
|
+
# quiet=True prevents NoConvergence for points near the projection edge;
|
|
464
|
+
# non-finite results are excluded below.
|
|
465
|
+
x_cat, y_cat = w_nosip.all_world2pix(sky.ra.deg, sky.dec.deg, 0, quiet=True)
|
|
466
|
+
u_cat = x_cat - (crpix[0] - 1)
|
|
467
|
+
v_cat = y_cat - (crpix[1] - 1)
|
|
468
|
+
|
|
469
|
+
# Observed (distorted) pixel coords
|
|
470
|
+
u_obs = xy[:, 0] - (crpix[0] - 1)
|
|
471
|
+
v_obs = xy[:, 1] - (crpix[1] - 1)
|
|
472
|
+
|
|
473
|
+
# SIP convention: u_distorted = u_undistorted + A(u, v)
|
|
474
|
+
# u_obs = undistorted (raw pixel); u_cat = distorted (from ZPN inverse)
|
|
475
|
+
# So: A(u_obs, v_obs) = u_cat - u_obs
|
|
476
|
+
du = u_cat - u_obs
|
|
477
|
+
dv = v_cat - v_obs
|
|
478
|
+
|
|
479
|
+
# Filter outliers (use median absolute deviation)
|
|
480
|
+
dist = np.sqrt(du**2 + dv**2)
|
|
481
|
+
finite = np.isfinite(dist)
|
|
482
|
+
if np.sum(finite) < len(pq) + 5:
|
|
483
|
+
log(f"SIP iteration {iteration}: too few finite points ({np.sum(finite)}), stopping")
|
|
484
|
+
break
|
|
485
|
+
med_dist = np.median(dist[finite])
|
|
486
|
+
mad = np.median(np.abs(dist[finite] - med_dist))
|
|
487
|
+
good = finite & (dist < med_dist + 5 * max(mad, 0.5)) # generous 5-sigma clip
|
|
488
|
+
|
|
489
|
+
if np.sum(good) < len(pq) + 5:
|
|
490
|
+
log(f"SIP iteration {iteration}: too few points after clipping ({np.sum(good)}), stopping")
|
|
491
|
+
break
|
|
492
|
+
|
|
493
|
+
# SIP basis uses undistorted (raw pixel) coordinates
|
|
494
|
+
u_fit = u_obs[good]
|
|
495
|
+
v_fit = v_obs[good]
|
|
496
|
+
du_fit = du[good]
|
|
497
|
+
dv_fit = dv[good]
|
|
498
|
+
|
|
499
|
+
# Normalize coordinates to prevent ill-conditioning for high
|
|
500
|
+
# SIP degrees. Without this, u^5 ~ 2000^5 ~ 3e16 creates a
|
|
501
|
+
# design matrix with condition number > 1e14.
|
|
502
|
+
u_scale = max(np.abs(u_fit).max(), 1.0)
|
|
503
|
+
v_scale = max(np.abs(v_fit).max(), 1.0)
|
|
504
|
+
u_norm = u_fit / u_scale
|
|
505
|
+
v_norm = v_fit / v_scale
|
|
506
|
+
|
|
507
|
+
# Build SIP basis matrix in normalized coordinates
|
|
508
|
+
basis = np.column_stack([u_norm**p * v_norm**q for p, q in pq])
|
|
509
|
+
|
|
510
|
+
# Fit A and B coefficients via least squares (normalized)
|
|
511
|
+
ca_norm, _, _, _ = np.linalg.lstsq(basis, du_fit, rcond=None)
|
|
512
|
+
cb_norm, _, _, _ = np.linalg.lstsq(basis, dv_fit, rcond=None)
|
|
513
|
+
|
|
514
|
+
# Convert back to original (unnormalized) SIP coefficients
|
|
515
|
+
a_vals = np.zeros((sip_degree + 1, sip_degree + 1))
|
|
516
|
+
b_vals = np.zeros((sip_degree + 1, sip_degree + 1))
|
|
517
|
+
for k, (p, q) in enumerate(pq):
|
|
518
|
+
scale_pq = u_scale**p * v_scale**q
|
|
519
|
+
a_vals[p, q] = ca_norm[k] / scale_pq
|
|
520
|
+
b_vals[p, q] = cb_norm[k] / scale_pq
|
|
521
|
+
|
|
522
|
+
# Apply SIP to the WCS
|
|
523
|
+
if '-SIP' not in w.wcs.ctype[0]:
|
|
524
|
+
w.wcs.ctype = [c + '-SIP' for c in w.wcs.ctype]
|
|
525
|
+
|
|
526
|
+
w.sip = Sip(
|
|
527
|
+
a_vals,
|
|
528
|
+
b_vals,
|
|
529
|
+
np.zeros((sip_degree + 1, sip_degree + 1)),
|
|
530
|
+
np.zeros((sip_degree + 1, sip_degree + 1)),
|
|
531
|
+
crpix,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
# Check convergence: stop when SIP corrections stabilize.
|
|
535
|
+
# Use 90th percentile (sensitive to outer-field + center) rather
|
|
536
|
+
# than median which converges before the center is corrected.
|
|
537
|
+
curr_q90 = np.percentile(dist[good], 90)
|
|
538
|
+
log(f"SIP iteration {iteration}: q90 distortion {curr_q90:.4g} pix over {np.sum(good)} points")
|
|
539
|
+
if iteration >= 2 and prev_q90 < np.inf:
|
|
540
|
+
rel_change = abs(curr_q90 - prev_q90) / max(prev_q90, 1e-6)
|
|
541
|
+
if rel_change < 0.005:
|
|
542
|
+
log(f"SIP converged after {iteration + 1} iterations")
|
|
543
|
+
break
|
|
544
|
+
prev_q90 = curr_q90
|
|
545
|
+
|
|
546
|
+
# Re-fit PV with SIP now applied (last iteration skip PV refit)
|
|
547
|
+
if iteration < n_iter - 1:
|
|
548
|
+
w, _ = fit_zpn_wcs_from_points(
|
|
549
|
+
xy,
|
|
550
|
+
sky,
|
|
551
|
+
w,
|
|
552
|
+
pv_deg=pv_deg,
|
|
553
|
+
robust_loss=robust_loss,
|
|
554
|
+
f_scale_arcsec=f_scale_arcsec,
|
|
555
|
+
max_nfev=max_nfev,
|
|
556
|
+
verbose=verbose,
|
|
557
|
+
)
|
|
558
|
+
crpix = np.array(w.wcs.crpix, dtype=float)
|
|
559
|
+
|
|
560
|
+
return w
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def tan_wcs_to_zpn(
|
|
564
|
+
w_tan: WCS,
|
|
565
|
+
pv_deg: int = 5,
|
|
566
|
+
n_samples: int = 256,
|
|
567
|
+
theta_max_deg: float | None = None,
|
|
568
|
+
) -> WCS:
|
|
569
|
+
"""
|
|
570
|
+
Convert a celestial TAN WCS into a ZPN WCS with PV2_m initialized to approximate TAN.
|
|
571
|
+
|
|
572
|
+
Notes
|
|
573
|
+
-----
|
|
574
|
+
TAN (gnomonic) radial law: r = tan(theta) [in radians]
|
|
575
|
+
In "degrees" units (common in FITS WCS plane coordinates), that's:
|
|
576
|
+
r_deg = tan(theta_rad) * (180/pi)
|
|
577
|
+
|
|
578
|
+
ZPN radial law: r_deg ≈ sum_{m=0..M} PV2_m * theta_deg^m
|
|
579
|
+
We set PV2_0 = 0 and fit PV2_1..PV2_M to approximate the TAN law
|
|
580
|
+
over theta in [0, theta_max_deg].
|
|
581
|
+
|
|
582
|
+
SIP and other pixel-space distortions of the input WCS are dropped
|
|
583
|
+
(they are not representable in the radial ZPN model); refit them on
|
|
584
|
+
top of the result if needed, e.g. with :func:`_fit_zpn_sip`.
|
|
585
|
+
|
|
586
|
+
Parameters
|
|
587
|
+
----------
|
|
588
|
+
w_tan : astropy.wcs.WCS
|
|
589
|
+
Input TAN WCS (2D celestial).
|
|
590
|
+
pv_deg : int
|
|
591
|
+
Highest PV degree to initialize (PV2_0..PV2_pv_deg).
|
|
592
|
+
5–9 is usually plenty; higher can get wiggly.
|
|
593
|
+
n_samples : int
|
|
594
|
+
Samples used for the polynomial fit.
|
|
595
|
+
theta_max_deg : float or None
|
|
596
|
+
Max angular radius (deg) over which to match TAN. If None,
|
|
597
|
+
estimated from image footprint corners using pixel_shape.
|
|
598
|
+
|
|
599
|
+
Returns
|
|
600
|
+
-------
|
|
601
|
+
w_zpn : astropy.wcs.WCS
|
|
602
|
+
A ZPN WCS with same CRVAL/CRPIX/CD and PV2_m initialized.
|
|
603
|
+
"""
|
|
604
|
+
ctype1, ctype2 = w_tan.wcs.ctype
|
|
605
|
+
if len(ctype1) < 8 or len(ctype2) < 8:
|
|
606
|
+
raise ValueError("Expected CTYPE like 'RA---TAN'/'DEC--TAN'.")
|
|
607
|
+
|
|
608
|
+
# Clean linear WCS (CRPIX/CRVAL/CD + metadata) with ZPN projection;
|
|
609
|
+
# strips SIP and PV parameters
|
|
610
|
+
w = _wcs_to_linear(w_tan, "ZPN")
|
|
611
|
+
|
|
612
|
+
# Estimate theta_max from footprint if not provided
|
|
613
|
+
if theta_max_deg is None:
|
|
614
|
+
if w.pixel_shape is None:
|
|
615
|
+
raise ValueError(
|
|
616
|
+
"w_tan.pixel_shape is None; provide theta_max_deg explicitly "
|
|
617
|
+
"or set w_tan.pixel_shape = (nx, ny)."
|
|
618
|
+
)
|
|
619
|
+
nx, ny = w.pixel_shape # (NAXIS1, NAXIS2)
|
|
620
|
+
crpix = np.array(w.wcs.crpix, dtype=float)
|
|
621
|
+
|
|
622
|
+
# Corners in pixel coordinates (origin=0 convention for astropy WCS)
|
|
623
|
+
corners = np.array(
|
|
624
|
+
[
|
|
625
|
+
[0.0, 0.0],
|
|
626
|
+
[nx - 1.0, 0.0],
|
|
627
|
+
[0.0, ny - 1.0],
|
|
628
|
+
[nx - 1.0, ny - 1.0],
|
|
629
|
+
],
|
|
630
|
+
dtype=float,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
# Sky positions of corners and center under TAN WCS
|
|
634
|
+
ra_c, dec_c = w_tan.all_pix2world(crpix[0], crpix[1], 0)
|
|
635
|
+
center = SkyCoord(ra_c * u.deg, dec_c * u.deg, frame="icrs")
|
|
636
|
+
|
|
637
|
+
ra_k, dec_k = w_tan.all_pix2world(corners[:, 0], corners[:, 1], 0)
|
|
638
|
+
sky_k = SkyCoord(ra_k * u.deg, dec_k * u.deg, frame="icrs")
|
|
639
|
+
|
|
640
|
+
theta_max_deg = float(np.max(center.separation(sky_k).to_value(u.deg)))
|
|
641
|
+
|
|
642
|
+
# Safety floor
|
|
643
|
+
theta_max_deg = max(theta_max_deg, 0.01)
|
|
644
|
+
|
|
645
|
+
# Fit PV2_m so that ZPN radial r(theta) ~ TAN radial r(theta)
|
|
646
|
+
# Use degrees for theta and degrees for r on plane.
|
|
647
|
+
theta = np.linspace(0.0, theta_max_deg, n_samples, dtype=float)
|
|
648
|
+
theta_rad = np.deg2rad(theta)
|
|
649
|
+
r_tan_deg = np.tan(theta_rad) * (180.0 / np.pi)
|
|
650
|
+
|
|
651
|
+
# Build design matrix for m=1..pv_deg (PV2_0 fixed to 0)
|
|
652
|
+
# r ≈ sum c[m-1] * theta^m
|
|
653
|
+
A = np.vstack([theta**m for m in range(1, pv_deg + 1)]).T
|
|
654
|
+
|
|
655
|
+
# Mild weighting emphasizing the central region: stabilizes the fit
|
|
656
|
+
# against the steep TAN growth near theta_max; only used for initialization
|
|
657
|
+
wgt = 1.0 / (1.0 + (theta / (0.6 * theta_max_deg)) ** 2)
|
|
658
|
+
Aw = A * wgt[:, None]
|
|
659
|
+
bw = r_tan_deg * wgt
|
|
660
|
+
|
|
661
|
+
coeffs, *_ = np.linalg.lstsq(Aw, bw, rcond=None)
|
|
662
|
+
|
|
663
|
+
pv_list = [(2, 0, 0.0)] + [(2, m, float(coeffs[m - 1])) for m in range(1, pv_deg + 1)]
|
|
664
|
+
w.wcs.set_pv(pv_list)
|
|
665
|
+
|
|
666
|
+
return w
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def convert_wcs_projection(
|
|
670
|
+
wcs_input: WCS,
|
|
671
|
+
target_projection: str,
|
|
672
|
+
pv_deg: int = 5,
|
|
673
|
+
) -> WCS:
|
|
674
|
+
"""Convert a celestial WCS to a different projection type.
|
|
675
|
+
|
|
676
|
+
Parameters
|
|
677
|
+
----------
|
|
678
|
+
wcs_input : WCS
|
|
679
|
+
Input WCS (any celestial projection).
|
|
680
|
+
target_projection : str
|
|
681
|
+
Target projection code, e.g. ``'TAN'``, ``'ZPN'``, ``'STG'``,
|
|
682
|
+
``'ARC'``, ``'ZEA'``, ``'SIN'``, etc.
|
|
683
|
+
pv_deg : int, optional
|
|
684
|
+
ZPN PV polynomial degree (only used when *target_projection* is
|
|
685
|
+
``'ZPN'``). Default 5.
|
|
686
|
+
|
|
687
|
+
Returns
|
|
688
|
+
-------
|
|
689
|
+
WCS
|
|
690
|
+
New WCS with the target projection. For ZPN the PV coefficients
|
|
691
|
+
are initialised from the TAN radial law — an exact match for TAN
|
|
692
|
+
input, and a generic starting point (meant to be refined by a
|
|
693
|
+
subsequent fit) for other input projections. For other targets
|
|
694
|
+
CRPIX/CRVAL/CD are copied and CTYPE is replaced, so the mapping
|
|
695
|
+
away from the reference point changes; refit afterwards.
|
|
696
|
+
"""
|
|
697
|
+
target = target_projection.upper().strip()
|
|
698
|
+
|
|
699
|
+
# Detect current projection code (last 3 chars of CTYPE after the dash)
|
|
700
|
+
try:
|
|
701
|
+
cur_proj = wcs_input.wcs.ctype[0].split('-')[-1]
|
|
702
|
+
# Strip trailing SIP suffix if present
|
|
703
|
+
if cur_proj == 'SIP':
|
|
704
|
+
parts = wcs_input.wcs.ctype[0].replace('-SIP', '').split('-')
|
|
705
|
+
cur_proj = parts[-1]
|
|
706
|
+
except Exception:
|
|
707
|
+
cur_proj = ''
|
|
708
|
+
|
|
709
|
+
if cur_proj == target:
|
|
710
|
+
return wcs_input.deepcopy()
|
|
711
|
+
|
|
712
|
+
# ZPN needs special initialisation (PV coefficients)
|
|
713
|
+
if target == 'ZPN':
|
|
714
|
+
# tan_wcs_to_zpn works from TAN (with or without SIP)
|
|
715
|
+
# For non-TAN input, we first need a TAN-like WCS base
|
|
716
|
+
if 'TAN' in cur_proj:
|
|
717
|
+
return tan_wcs_to_zpn(wcs_input, pv_deg=pv_deg)
|
|
718
|
+
else:
|
|
719
|
+
# Build a minimal TAN WCS from the input's linear terms,
|
|
720
|
+
# then convert to ZPN
|
|
721
|
+
w_tan = _wcs_to_linear(wcs_input, 'TAN')
|
|
722
|
+
return tan_wcs_to_zpn(w_tan, pv_deg=pv_deg)
|
|
723
|
+
|
|
724
|
+
# For all other projections: copy linear WCS with new CTYPE
|
|
725
|
+
return _wcs_to_linear(wcs_input, target)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _wcs_to_linear(wcs_input: WCS, proj_code: str) -> WCS:
|
|
729
|
+
"""Create a clean linear WCS with the same pointing but a new projection.
|
|
730
|
+
|
|
731
|
+
Copies CRPIX, CRVAL, CD (or derives CD from PC+CDELT), metadata, and
|
|
732
|
+
pixel_shape. Strips SIP distortion and PV parameters.
|
|
733
|
+
"""
|
|
734
|
+
w = WCS(naxis=2)
|
|
735
|
+
|
|
736
|
+
# CD matrix; pixel_scale_matrix correctly folds CDELT_i * PC_ij
|
|
737
|
+
# (row scaling) when the input uses the PC/CDELT convention
|
|
738
|
+
cd = np.array(wcs_input.pixel_scale_matrix, dtype=float)
|
|
739
|
+
|
|
740
|
+
w.wcs.crpix = np.array(wcs_input.wcs.crpix, dtype=float)
|
|
741
|
+
w.wcs.crval = np.array(wcs_input.wcs.crval, dtype=float)
|
|
742
|
+
w.wcs.cd = cd
|
|
743
|
+
|
|
744
|
+
# Build CTYPE: preserve axis names (e.g. 'RA---' / 'DEC--')
|
|
745
|
+
ctype1, ctype2 = wcs_input.wcs.ctype
|
|
746
|
+
# Strip any existing projection + SIP suffix
|
|
747
|
+
base1 = ctype1.replace('-SIP', '')[:5]
|
|
748
|
+
base2 = ctype2.replace('-SIP', '')[:5]
|
|
749
|
+
w.wcs.ctype = (base1 + proj_code, base2 + proj_code)
|
|
750
|
+
|
|
751
|
+
# Copy metadata
|
|
752
|
+
for attr in ('cunit', 'radesys', 'equinox'):
|
|
753
|
+
try:
|
|
754
|
+
setattr(w.wcs, attr, getattr(wcs_input.wcs, attr))
|
|
755
|
+
except Exception:
|
|
756
|
+
pass
|
|
757
|
+
for attr in ('lonpole', 'latpole'):
|
|
758
|
+
try:
|
|
759
|
+
val = getattr(wcs_input.wcs, attr)
|
|
760
|
+
if np.isfinite(val):
|
|
761
|
+
setattr(w.wcs, attr, float(val))
|
|
762
|
+
except Exception:
|
|
763
|
+
pass
|
|
764
|
+
|
|
765
|
+
if wcs_input.pixel_shape is not None:
|
|
766
|
+
w.pixel_shape = wcs_input.pixel_shape
|
|
767
|
+
|
|
768
|
+
return w
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _fit_tan_sip_robust(
|
|
772
|
+
xy,
|
|
773
|
+
world_coords,
|
|
774
|
+
proj_point="center",
|
|
775
|
+
projection=None,
|
|
776
|
+
sip_degree=2,
|
|
777
|
+
robust_loss="soft_l1",
|
|
778
|
+
f_scale=None,
|
|
779
|
+
verbose=False,
|
|
780
|
+
):
|
|
781
|
+
"""Fit TAN+SIP WCS with robust loss function.
|
|
782
|
+
|
|
783
|
+
Replicates astropy's ``fit_wcs_from_points`` two-stage fitting
|
|
784
|
+
(linear WCS, then joint CD + CRPIX + SIP) but uses a robust loss
|
|
785
|
+
in the linear stage and a two-pass approach for the SIP stage:
|
|
786
|
+
first L2 to capture the distortion pattern, then robust re-fit
|
|
787
|
+
with data-driven ``f_scale`` to suppress outliers.
|
|
788
|
+
|
|
789
|
+
Parameters
|
|
790
|
+
----------
|
|
791
|
+
xy : tuple of arrays ``(x, y)``
|
|
792
|
+
Pixel coordinates.
|
|
793
|
+
world_coords : `~astropy.coordinates.SkyCoord`
|
|
794
|
+
Reference sky positions.
|
|
795
|
+
proj_point : str or SkyCoord
|
|
796
|
+
Projection center ('center' or explicit).
|
|
797
|
+
projection : WCS or None
|
|
798
|
+
Template WCS; used as initial guess for CD matrix.
|
|
799
|
+
sip_degree : int
|
|
800
|
+
SIP polynomial degree.
|
|
801
|
+
robust_loss : str
|
|
802
|
+
Loss function for `scipy.optimize.least_squares`.
|
|
803
|
+
f_scale : float or None
|
|
804
|
+
Soft margin for robust loss in the SIP stage. If None
|
|
805
|
+
(default), estimated adaptively from the L2 SIP residuals.
|
|
806
|
+
The linear stage always uses a data-driven scale.
|
|
807
|
+
verbose : bool or callable
|
|
808
|
+
Whether to show verbose messages during the run of the function or not.
|
|
809
|
+
May also be a print-like function.
|
|
810
|
+
|
|
811
|
+
Returns
|
|
812
|
+
-------
|
|
813
|
+
wcs : `~astropy.wcs.WCS`
|
|
814
|
+
"""
|
|
815
|
+
from scipy.optimize import least_squares
|
|
816
|
+
|
|
817
|
+
from astropy.wcs.utils import celestial_frame_to_wcs
|
|
818
|
+
from astropy.wcs.wcs import Sip
|
|
819
|
+
|
|
820
|
+
try:
|
|
821
|
+
# Private astropy helpers (stable since astropy 3.x, but not public API)
|
|
822
|
+
from astropy.wcs.utils import _linear_wcs_fit, _sip_fit
|
|
823
|
+
except ImportError as e:
|
|
824
|
+
raise RuntimeError(
|
|
825
|
+
"astropy internals (_linear_wcs_fit/_sip_fit) are not available in "
|
|
826
|
+
"this astropy version; _fit_tan_sip_robust needs updating"
|
|
827
|
+
) from e
|
|
828
|
+
|
|
829
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
830
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
831
|
+
|
|
832
|
+
xp, yp = xy
|
|
833
|
+
try:
|
|
834
|
+
lon, lat = world_coords.data.lon.deg, world_coords.data.lat.deg
|
|
835
|
+
except AttributeError:
|
|
836
|
+
unit_sph = world_coords.unit_spherical
|
|
837
|
+
lon, lat = unit_sph.lon.deg, unit_sph.lat.deg
|
|
838
|
+
|
|
839
|
+
use_center_as_proj_point = str(proj_point) == "center"
|
|
840
|
+
|
|
841
|
+
# Build WCS template
|
|
842
|
+
if isinstance(projection, str) or projection is None:
|
|
843
|
+
proj_code = projection if isinstance(projection, str) else "TAN"
|
|
844
|
+
wcs = celestial_frame_to_wcs(frame=world_coords.frame, projection=proj_code)
|
|
845
|
+
else:
|
|
846
|
+
wcs = projection.deepcopy()
|
|
847
|
+
wcs.sip = None
|
|
848
|
+
|
|
849
|
+
if wcs.wcs.has_pc():
|
|
850
|
+
# pixel_scale_matrix folds CDELT_i * PC_ij with correct row scaling
|
|
851
|
+
wcs.wcs.cd = wcs.pixel_scale_matrix
|
|
852
|
+
wcs.wcs.cdelt = (1.0, 1.0)
|
|
853
|
+
wcs.wcs.__delattr__("pc")
|
|
854
|
+
|
|
855
|
+
xpmin, xpmax = xp.min(), xp.max()
|
|
856
|
+
ypmin, ypmax = yp.min(), yp.max()
|
|
857
|
+
|
|
858
|
+
wcs.pixel_shape = (
|
|
859
|
+
1 if xpmax <= 0.0 else int(np.ceil(xpmax)),
|
|
860
|
+
1 if ypmax <= 0.0 else int(np.ceil(ypmax)),
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
if use_center_as_proj_point:
|
|
864
|
+
sc1 = SkyCoord(lon.min() * u.deg, lat.max() * u.deg)
|
|
865
|
+
sc2 = SkyCoord(lon.max() * u.deg, lat.min() * u.deg)
|
|
866
|
+
pa = sc1.position_angle(sc2)
|
|
867
|
+
sep = sc1.separation(sc2)
|
|
868
|
+
midpoint_sc = sc1.directional_offset_by(pa, sep / 2)
|
|
869
|
+
wcs.wcs.crval = (midpoint_sc.data.lon.deg, midpoint_sc.data.lat.deg)
|
|
870
|
+
wcs.wcs.crpix = ((xpmax + xpmin) / 2.0, (ypmax + ypmin) / 2.0)
|
|
871
|
+
else:
|
|
872
|
+
proj_point = proj_point.transform_to(world_coords.frame)
|
|
873
|
+
wcs.wcs.crval = (proj_point.data.lon.deg, proj_point.data.lat.deg)
|
|
874
|
+
close = lambda l, p: p[np.argmin(np.abs(l))]
|
|
875
|
+
wcs.wcs.crpix = (
|
|
876
|
+
close(lon - wcs.wcs.crval[0], xp + 1),
|
|
877
|
+
close(lat - wcs.wcs.crval[1], yp + 1),
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
if xpmin == xpmax:
|
|
881
|
+
xpmin, xpmax = xpmin - 0.5, xpmax + 0.5
|
|
882
|
+
if ypmin == ypmax:
|
|
883
|
+
ypmin, ypmax = ypmin - 0.5, ypmax + 0.5
|
|
884
|
+
|
|
885
|
+
# --- Stage 1: linear WCS (CD + CRPIX) ---
|
|
886
|
+
# Use robust loss here: linear residuals include distortion as systematic
|
|
887
|
+
# pattern that looks like outliers; robust loss prevents them from biasing
|
|
888
|
+
# the linear fit.
|
|
889
|
+
p0_lin = np.concatenate([wcs.wcs.cd.flatten(), wcs.wcs.crpix.flatten()])
|
|
890
|
+
lin_resids = _linear_wcs_fit(p0_lin, lon, lat, xp, yp, wcs)
|
|
891
|
+
lin_f_scale = max(float(np.median(np.abs(lin_resids)) * 3), 1.0 / 3600)
|
|
892
|
+
|
|
893
|
+
fit = least_squares(
|
|
894
|
+
_linear_wcs_fit,
|
|
895
|
+
p0_lin,
|
|
896
|
+
args=(lon, lat, xp, yp, wcs),
|
|
897
|
+
bounds=[
|
|
898
|
+
[-np.inf, -np.inf, -np.inf, -np.inf, xpmin + 1, ypmin + 1],
|
|
899
|
+
[np.inf, np.inf, np.inf, np.inf, xpmax + 1, ypmax + 1],
|
|
900
|
+
],
|
|
901
|
+
loss=robust_loss,
|
|
902
|
+
f_scale=lin_f_scale,
|
|
903
|
+
method="trf",
|
|
904
|
+
)
|
|
905
|
+
wcs.wcs.crpix = np.array(fit.x[4:6])
|
|
906
|
+
wcs.wcs.cd = np.array(fit.x[0:4].reshape((2, 2)))
|
|
907
|
+
log(
|
|
908
|
+
f"TAN-SIP linear stage: median residual "
|
|
909
|
+
f"{np.median(np.abs(fit.fun)) * 3600:.3g} arcsec"
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
# --- Stage 2: joint CD + CRPIX + SIP ---
|
|
913
|
+
if "-SIP" not in wcs.wcs.ctype[0]:
|
|
914
|
+
wcs.wcs.ctype = [x + "-SIP" for x in wcs.wcs.ctype]
|
|
915
|
+
|
|
916
|
+
coef_names = [
|
|
917
|
+
f"{i}_{j}"
|
|
918
|
+
for i in range(sip_degree + 1)
|
|
919
|
+
for j in range(sip_degree + 1)
|
|
920
|
+
if (i + j) < (sip_degree + 1) and (i + j) > 1
|
|
921
|
+
]
|
|
922
|
+
|
|
923
|
+
sip_bounds = (
|
|
924
|
+
[xpmin + 1, ypmin + 1] + [-np.inf] * (4 + 2 * len(coef_names)),
|
|
925
|
+
[xpmax + 1, ypmax + 1] + [np.inf] * (4 + 2 * len(coef_names)),
|
|
926
|
+
)
|
|
927
|
+
# Parameter layout of _sip_fit: [CRPIX(2), CD(4), A_coeffs, B_coeffs]
|
|
928
|
+
sip_args = (lon, lat, xp, yp, wcs, sip_degree, coef_names)
|
|
929
|
+
|
|
930
|
+
# Pass 1: L2 fit to capture the full distortion pattern (SIP starts at 0)
|
|
931
|
+
p0_sip = np.concatenate(
|
|
932
|
+
(
|
|
933
|
+
np.array(wcs.wcs.crpix),
|
|
934
|
+
wcs.wcs.cd.flatten(),
|
|
935
|
+
np.zeros(2 * len(coef_names)),
|
|
936
|
+
)
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
# Compute parameter scales for the optimizer.
|
|
940
|
+
# SIP coefficients A_{p,q} multiply (u-crpix)^p * (v-crpix)^q where
|
|
941
|
+
# pixel offsets can be ~U pixels. Expected coefficient magnitude is
|
|
942
|
+
# ~1/U^(p+q), which spans many orders of magnitude for high SIP orders.
|
|
943
|
+
# Without proper scaling the optimizer cannot find meaningful SIP≥4
|
|
944
|
+
# coefficients on wide-field images.
|
|
945
|
+
U = max(
|
|
946
|
+
abs(xpmax - wcs.wcs.crpix[0]),
|
|
947
|
+
abs(xpmin - wcs.wcs.crpix[0]),
|
|
948
|
+
abs(ypmax - wcs.wcs.crpix[1]),
|
|
949
|
+
abs(ypmin - wcs.wcs.crpix[1]),
|
|
950
|
+
1.0,
|
|
951
|
+
)
|
|
952
|
+
sip_x_scale = np.ones(len(p0_sip))
|
|
953
|
+
# CRPIX: O(pixels) → scale 1
|
|
954
|
+
# CD: O(deg/pixel) → use current magnitude
|
|
955
|
+
cd_scale = max(np.abs(wcs.wcs.cd).max(), 1e-10)
|
|
956
|
+
sip_x_scale[2:6] = cd_scale
|
|
957
|
+
# SIP coefficients: O(1/U^(p+q))
|
|
958
|
+
for k, coef_name in enumerate(coef_names):
|
|
959
|
+
p_deg, q_deg = int(coef_name[0]), int(coef_name[2])
|
|
960
|
+
order = p_deg + q_deg
|
|
961
|
+
scale = 1.0 / U**order
|
|
962
|
+
sip_x_scale[6 + k] = scale
|
|
963
|
+
sip_x_scale[6 + len(coef_names) + k] = scale
|
|
964
|
+
|
|
965
|
+
fit = least_squares(
|
|
966
|
+
_sip_fit,
|
|
967
|
+
p0_sip,
|
|
968
|
+
args=sip_args,
|
|
969
|
+
bounds=sip_bounds,
|
|
970
|
+
x_scale=sip_x_scale,
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
# Pass 2: robust re-fit starting from L2 solution
|
|
974
|
+
# f_scale estimated from L2 residuals so distortion signal is inlier
|
|
975
|
+
sip_resids = fit.fun
|
|
976
|
+
if f_scale is None:
|
|
977
|
+
sip_f_scale = max(float(np.median(np.abs(sip_resids)) * 3), 1e-7)
|
|
978
|
+
else:
|
|
979
|
+
sip_f_scale = float(f_scale)
|
|
980
|
+
|
|
981
|
+
fit = least_squares(
|
|
982
|
+
_sip_fit,
|
|
983
|
+
fit.x, # warm-start from L2 solution
|
|
984
|
+
args=sip_args,
|
|
985
|
+
bounds=sip_bounds,
|
|
986
|
+
loss=robust_loss,
|
|
987
|
+
f_scale=sip_f_scale,
|
|
988
|
+
method="trf",
|
|
989
|
+
x_scale=sip_x_scale,
|
|
990
|
+
)
|
|
991
|
+
log(
|
|
992
|
+
f"TAN-SIP robust stage: median residual "
|
|
993
|
+
f"{np.median(np.abs(fit.fun)) * 3600:.3g} arcsec, f_scale {sip_f_scale * 3600:.3g} arcsec"
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
coef_fit = (
|
|
997
|
+
list(fit.x[6 : 6 + len(coef_names)]),
|
|
998
|
+
list(fit.x[6 + len(coef_names) :]),
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
wcs.wcs.cd = fit.x[2:6].reshape((2, 2))
|
|
1002
|
+
wcs.wcs.crpix = fit.x[0:2]
|
|
1003
|
+
|
|
1004
|
+
a_vals = np.zeros((sip_degree + 1, sip_degree + 1))
|
|
1005
|
+
b_vals = np.zeros((sip_degree + 1, sip_degree + 1))
|
|
1006
|
+
|
|
1007
|
+
for coef_name in coef_names:
|
|
1008
|
+
a_vals[int(coef_name[0])][int(coef_name[2])] = coef_fit[0].pop(0)
|
|
1009
|
+
b_vals[int(coef_name[0])][int(coef_name[2])] = coef_fit[1].pop(0)
|
|
1010
|
+
|
|
1011
|
+
wcs.sip = Sip(
|
|
1012
|
+
a_vals,
|
|
1013
|
+
b_vals,
|
|
1014
|
+
np.zeros((sip_degree + 1, sip_degree + 1)),
|
|
1015
|
+
np.zeros((sip_degree + 1, sip_degree + 1)),
|
|
1016
|
+
wcs.wcs.crpix,
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
return wcs
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def fit_wcs_from_points(
|
|
1023
|
+
xy,
|
|
1024
|
+
world_coords,
|
|
1025
|
+
proj_point="center",
|
|
1026
|
+
projection=None,
|
|
1027
|
+
sip_degree=None,
|
|
1028
|
+
pv_deg=5,
|
|
1029
|
+
verbose=False,
|
|
1030
|
+
):
|
|
1031
|
+
"""Drop-in wrapper around :func:`astropy.wcs.utils.fit_wcs_from_points`
|
|
1032
|
+
that also handles **ZPN** projection (which astropy does not natively fit)
|
|
1033
|
+
and **ZPN-SIP** (ZPN radial distortion plus SIP polynomial corrections).
|
|
1034
|
+
|
|
1035
|
+
Parameters
|
|
1036
|
+
----------
|
|
1037
|
+
xy : tuple of arrays ``(x, y)`` or ``(2, N)`` array
|
|
1038
|
+
Pixel coordinates (same convention as the astropy function).
|
|
1039
|
+
world_coords : `~astropy.coordinates.SkyCoord`
|
|
1040
|
+
Reference sky positions.
|
|
1041
|
+
proj_point : str, optional
|
|
1042
|
+
Passed through to astropy for non-ZPN projections. Ignored for ZPN,
|
|
1043
|
+
where the projection center comes from the template WCS.
|
|
1044
|
+
projection : `~astropy.wcs.WCS` or other, optional
|
|
1045
|
+
Projection template. If this is a WCS with ``RA---ZPN / DEC--ZPN``
|
|
1046
|
+
CTYPEs, the ZPN fitter is used instead of astropy's.
|
|
1047
|
+
sip_degree : int or None, optional
|
|
1048
|
+
SIP polynomial degree. For TAN projections, controls SIP distortion
|
|
1049
|
+
order. For ZPN projections, if > 0, SIP corrections are fitted on
|
|
1050
|
+
top of ZPN PV parameters to capture non-radial distortions. For
|
|
1051
|
+
other projections it is ignored (SIP is not standard there).
|
|
1052
|
+
pv_deg : int, optional
|
|
1053
|
+
ZPN PV polynomial degree (``PV2_0 … PV2_pv_deg``). Default 5.
|
|
1054
|
+
verbose : bool or callable, optional
|
|
1055
|
+
Whether to show verbose messages during the run of the function or not.
|
|
1056
|
+
May also be a print-like function.
|
|
1057
|
+
|
|
1058
|
+
Returns
|
|
1059
|
+
-------
|
|
1060
|
+
wcs : `~astropy.wcs.WCS`
|
|
1061
|
+
Fitted WCS (same return type as the astropy function).
|
|
1062
|
+
"""
|
|
1063
|
+
from astropy.wcs.utils import fit_wcs_from_points as _astropy_fit
|
|
1064
|
+
|
|
1065
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
1066
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
1067
|
+
|
|
1068
|
+
# Detect ZPN projection
|
|
1069
|
+
is_zpn = False
|
|
1070
|
+
if isinstance(projection, WCS):
|
|
1071
|
+
try:
|
|
1072
|
+
is_zpn = "ZPN" in projection.wcs.ctype[0]
|
|
1073
|
+
except Exception:
|
|
1074
|
+
pass
|
|
1075
|
+
|
|
1076
|
+
if is_zpn:
|
|
1077
|
+
# Convert xy to (N, 2) array expected by fit_zpn_wcs_from_points
|
|
1078
|
+
if isinstance(xy, (list, tuple)) and len(xy) == 2:
|
|
1079
|
+
# (x_array, y_array) form
|
|
1080
|
+
xy_arr = np.column_stack(
|
|
1081
|
+
[np.asarray(xy[0], dtype=float), np.asarray(xy[1], dtype=float)]
|
|
1082
|
+
)
|
|
1083
|
+
else:
|
|
1084
|
+
xy_arr = np.asarray(xy, dtype=float)
|
|
1085
|
+
if xy_arr.ndim == 2 and xy_arr.shape[0] == 2 and xy_arr.shape[1] != 2:
|
|
1086
|
+
# (2, N) -> (N, 2)
|
|
1087
|
+
xy_arr = xy_arr.T
|
|
1088
|
+
|
|
1089
|
+
zpn_deg = int(pv_deg)
|
|
1090
|
+
|
|
1091
|
+
# First fit ZPN PV parameters (radial distortion)
|
|
1092
|
+
log(f"Fitting ZPN WCS with pv_deg={zpn_deg} using {len(xy_arr)} points")
|
|
1093
|
+
wcs_best, _result = fit_zpn_wcs_from_points(
|
|
1094
|
+
xy_arr, world_coords, wcs_init=projection, pv_deg=zpn_deg, verbose=verbose
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
# Then fit SIP corrections for non-radial distortions
|
|
1098
|
+
if sip_degree is not None and int(sip_degree) > 0:
|
|
1099
|
+
log(f"Fitting SIP degree {int(sip_degree)} corrections on top of ZPN")
|
|
1100
|
+
wcs_best = _fit_zpn_sip(
|
|
1101
|
+
wcs_best,
|
|
1102
|
+
xy_arr,
|
|
1103
|
+
world_coords,
|
|
1104
|
+
sip_degree=int(sip_degree),
|
|
1105
|
+
pv_deg=zpn_deg,
|
|
1106
|
+
verbose=verbose,
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
return wcs_best
|
|
1110
|
+
|
|
1111
|
+
# ---------- Non-ZPN ----------
|
|
1112
|
+
# SIP only makes sense for TAN-based projections
|
|
1113
|
+
effective_sip = sip_degree
|
|
1114
|
+
if isinstance(projection, WCS):
|
|
1115
|
+
try:
|
|
1116
|
+
if "TAN" not in projection.wcs.ctype[0]:
|
|
1117
|
+
effective_sip = None
|
|
1118
|
+
except Exception:
|
|
1119
|
+
pass
|
|
1120
|
+
|
|
1121
|
+
if effective_sip is not None and effective_sip > 0:
|
|
1122
|
+
log(f"Fitting TAN-SIP WCS with sip_degree={int(effective_sip)}")
|
|
1123
|
+
return _fit_tan_sip_robust(
|
|
1124
|
+
xy,
|
|
1125
|
+
world_coords,
|
|
1126
|
+
proj_point=proj_point,
|
|
1127
|
+
projection=projection,
|
|
1128
|
+
sip_degree=int(effective_sip),
|
|
1129
|
+
verbose=verbose,
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
if effective_sip != sip_degree:
|
|
1133
|
+
log("SIP is not supported for this projection, ignoring sip_degree")
|
|
1134
|
+
|
|
1135
|
+
log("Fitting WCS using astropy fit_wcs_from_points")
|
|
1136
|
+
return _astropy_fit(
|
|
1137
|
+
xy,
|
|
1138
|
+
world_coords,
|
|
1139
|
+
proj_point=proj_point,
|
|
1140
|
+
projection=projection,
|
|
1141
|
+
)
|