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,1643 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quad-hash astrometry solver (Python-only) with practical upgrades:
|
|
3
|
+
|
|
4
|
+
1) Multi-scale quad pools with canonical descriptors:
|
|
5
|
+
- fully canonical quad descriptors (astrometry.net-style A/B convention),
|
|
6
|
+
so a hash hit directly provides the point correspondence for the fit
|
|
7
|
+
- quads are partitioned into baseline-length *rank bins* (e.g. 6 bins)
|
|
8
|
+
- consistent binning between detection and reference sets via shared edges
|
|
9
|
+
- matching is done only within the same bin, reducing ambiguity
|
|
10
|
+
|
|
11
|
+
2) Two-stage hypothesis scoring with accuracy enhancements:
|
|
12
|
+
- Stage 1: weighted scoring on a small subset with multi-probe hashing
|
|
13
|
+
- Stage 2: mutual nearest-neighbor matching with weighted scoring
|
|
14
|
+
- Iterative affine re-matching to grow the match set
|
|
15
|
+
- Progressive sigma-clipping in final WCS refinement
|
|
16
|
+
|
|
17
|
+
Inputs
|
|
18
|
+
------
|
|
19
|
+
obj : astropy.table.Table with 'x','y' and either 'flux' or 'mag'
|
|
20
|
+
cat : astropy.table.Table with 'ra','dec','mag'
|
|
21
|
+
wcs_init : astropy.wcs.WCS (rough)
|
|
22
|
+
|
|
23
|
+
Outputs
|
|
24
|
+
-------
|
|
25
|
+
refined_wcs : astropy.wcs.WCS
|
|
26
|
+
match : astropy.table.Table
|
|
27
|
+
diagnostics : dict
|
|
28
|
+
|
|
29
|
+
Deps: numpy, scipy, astropy
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import dataclasses
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from typing import Dict, List, Optional, Tuple
|
|
37
|
+
|
|
38
|
+
import numpy as np
|
|
39
|
+
from scipy.spatial import cKDTree
|
|
40
|
+
|
|
41
|
+
from astropy.coordinates import SkyCoord
|
|
42
|
+
import astropy.units as u
|
|
43
|
+
from astropy.table import Table
|
|
44
|
+
from astropy.wcs import WCS
|
|
45
|
+
from stdpipe.astrometry_wcs import fit_wcs_from_points
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# -----------------------------
|
|
49
|
+
# Utilities
|
|
50
|
+
# -----------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _as_float_array(x) -> np.ndarray:
|
|
54
|
+
a = np.asarray(x)
|
|
55
|
+
return a.astype(np.float64, copy=False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _finite_mask(*cols) -> np.ndarray:
|
|
59
|
+
m = np.ones(len(cols[0]), dtype=bool)
|
|
60
|
+
for c in cols:
|
|
61
|
+
c = np.asarray(c)
|
|
62
|
+
m &= np.isfinite(c)
|
|
63
|
+
return m
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _robust_sigma(x: np.ndarray) -> float:
|
|
67
|
+
x = np.asarray(x, dtype=np.float64)
|
|
68
|
+
med = np.nanmedian(x)
|
|
69
|
+
mad = np.nanmedian(np.abs(x - med))
|
|
70
|
+
return 1.4826 * mad if mad > 0 else np.nanstd(x)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def mag_orderings_consistent(mags_a, mags_b, tol: float = 0.5) -> bool:
|
|
74
|
+
"""Check that pairwise brightness orderings of two corresponding point
|
|
75
|
+
sets agree within a tolerance.
|
|
76
|
+
|
|
77
|
+
Only pairs whose magnitude difference exceeds ``tol`` in *both* sets are
|
|
78
|
+
required to agree in sign, so noise-flipped orderings of nearly
|
|
79
|
+
equal-brightness stars are not rejected. Insensitive to a constant
|
|
80
|
+
magnitude offset (instrumental vs catalogue zero point).
|
|
81
|
+
"""
|
|
82
|
+
a = np.asarray(mags_a, dtype=np.float64)
|
|
83
|
+
b = np.asarray(mags_b, dtype=np.float64)
|
|
84
|
+
da = a[:, None] - a[None, :]
|
|
85
|
+
db = b[:, None] - b[None, :]
|
|
86
|
+
bad = (np.abs(da) > tol) & (np.abs(db) > tol) & (da * db < 0)
|
|
87
|
+
return not bool(bad.any())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# -----------------------------
|
|
91
|
+
# Similarity transform (Umeyama)
|
|
92
|
+
# -----------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def estimate_similarity_2d(
|
|
96
|
+
A: np.ndarray, B: np.ndarray, allow_reflection: bool = True
|
|
97
|
+
) -> Tuple[np.ndarray, np.ndarray, float]:
|
|
98
|
+
A = np.asarray(A, dtype=np.float64)
|
|
99
|
+
B = np.asarray(B, dtype=np.float64)
|
|
100
|
+
if A.shape != B.shape or A.shape[1] != 2:
|
|
101
|
+
raise ValueError("A and B must be (N,2) and same shape")
|
|
102
|
+
|
|
103
|
+
mu_A = A.mean(axis=0)
|
|
104
|
+
mu_B = B.mean(axis=0)
|
|
105
|
+
X = A - mu_A
|
|
106
|
+
Y = B - mu_B
|
|
107
|
+
|
|
108
|
+
var_A = np.sum(X**2) / A.shape[0]
|
|
109
|
+
if var_A <= 0:
|
|
110
|
+
raise ValueError("Degenerate A variance")
|
|
111
|
+
|
|
112
|
+
Sigma = (Y.T @ X) / A.shape[0]
|
|
113
|
+
U, D, Vt = np.linalg.svd(Sigma)
|
|
114
|
+
|
|
115
|
+
R = U @ Vt
|
|
116
|
+
if not allow_reflection and np.linalg.det(R) < 0:
|
|
117
|
+
U[:, -1] *= -1
|
|
118
|
+
R = U @ Vt
|
|
119
|
+
# If allow_reflection=True, accept the reflection as-is
|
|
120
|
+
|
|
121
|
+
s = np.sum(D) / var_A
|
|
122
|
+
t = mu_B - s * (R @ mu_A)
|
|
123
|
+
return R, t, float(s)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def apply_similarity(xy: np.ndarray, R: np.ndarray, t: np.ndarray, s: float) -> np.ndarray:
|
|
127
|
+
xy = np.asarray(xy, dtype=np.float64)
|
|
128
|
+
return (s * (xy @ R.T)) + t
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# -----------------------------
|
|
132
|
+
# Affine transform
|
|
133
|
+
# -----------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def estimate_affine_2d(A: np.ndarray, B: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
137
|
+
"""Fit affine transform B = A @ M.T + t via least squares (6 DOF).
|
|
138
|
+
|
|
139
|
+
More flexible than similarity (4 DOF) - handles shear and non-square pixels.
|
|
140
|
+
Returns (M, t) where M is (2,2) linear part and t is (2,) translation.
|
|
141
|
+
"""
|
|
142
|
+
A = np.asarray(A, dtype=np.float64)
|
|
143
|
+
B = np.asarray(B, dtype=np.float64)
|
|
144
|
+
n = A.shape[0]
|
|
145
|
+
if n < 3:
|
|
146
|
+
raise ValueError("Need at least 3 points for affine fit")
|
|
147
|
+
A_aug = np.hstack([A, np.ones((n, 1))]) # (n, 3)
|
|
148
|
+
cond = np.linalg.cond(A_aug)
|
|
149
|
+
if cond > 1e12:
|
|
150
|
+
raise ValueError(f"Degenerate affine fit (condition number {cond:.1e})")
|
|
151
|
+
X, _, _, _ = np.linalg.lstsq(A_aug, B, rcond=None)
|
|
152
|
+
M = X[:2].T # (2, 2)
|
|
153
|
+
t = X[2] # (2,)
|
|
154
|
+
if not (np.all(np.isfinite(M)) and np.all(np.isfinite(t))):
|
|
155
|
+
raise ValueError("Degenerate affine fit (non-finite values)")
|
|
156
|
+
return M, t
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def apply_affine(xy: np.ndarray, M: np.ndarray, t: np.ndarray) -> np.ndarray:
|
|
160
|
+
"""Apply affine transform: result = xy @ M.T + t
|
|
161
|
+
|
|
162
|
+
Raises ValueError if the result contains non-finite values.
|
|
163
|
+
"""
|
|
164
|
+
xy = np.asarray(xy, dtype=np.float64)
|
|
165
|
+
with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
|
|
166
|
+
result = xy @ M.T + t
|
|
167
|
+
if not np.all(np.isfinite(result)):
|
|
168
|
+
raise ValueError("Affine transform produced non-finite values")
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# -----------------------------
|
|
173
|
+
# Mutual nearest-neighbor matching
|
|
174
|
+
# -----------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _mutual_nearest_neighbor(
|
|
178
|
+
xy_a: np.ndarray,
|
|
179
|
+
xy_b: np.ndarray,
|
|
180
|
+
radius: float,
|
|
181
|
+
tree_b: Optional[cKDTree] = None,
|
|
182
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
183
|
+
"""Find mutual nearest-neighbor pairs between two point sets within radius.
|
|
184
|
+
|
|
185
|
+
Returns ``(idx_a, idx_b)`` arrays of matched indices where each pair is
|
|
186
|
+
the mutual closest match within the given radius.
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
xy_a : (N, 2) ndarray
|
|
191
|
+
First point set.
|
|
192
|
+
xy_b : (M, 2) ndarray
|
|
193
|
+
Second point set.
|
|
194
|
+
radius : float
|
|
195
|
+
Maximum matching distance.
|
|
196
|
+
tree_b : cKDTree, optional
|
|
197
|
+
Pre-built cKDTree for ``xy_b`` (avoids rebuilding).
|
|
198
|
+
|
|
199
|
+
Returns
|
|
200
|
+
-------
|
|
201
|
+
idx_a : ndarray of int
|
|
202
|
+
Indices into ``xy_a`` for matched pairs.
|
|
203
|
+
idx_b : ndarray of int
|
|
204
|
+
Indices into ``xy_b`` for matched pairs.
|
|
205
|
+
"""
|
|
206
|
+
if len(xy_a) == 0 or len(xy_b) == 0:
|
|
207
|
+
return np.array([], dtype=np.int32), np.array([], dtype=np.int32)
|
|
208
|
+
|
|
209
|
+
tree_a = cKDTree(xy_a)
|
|
210
|
+
if tree_b is None:
|
|
211
|
+
tree_b = cKDTree(xy_b)
|
|
212
|
+
|
|
213
|
+
# Forward: for each point in A, find nearest in B
|
|
214
|
+
dist_fwd, nn_fwd = tree_b.query(xy_a, k=1, distance_upper_bound=radius)
|
|
215
|
+
# Reverse: for each point in B, find nearest in A
|
|
216
|
+
dist_rev, nn_rev = tree_a.query(xy_b, k=1, distance_upper_bound=radius)
|
|
217
|
+
|
|
218
|
+
ok_fwd = np.isfinite(dist_fwd) & (dist_fwd < radius) & (nn_fwd < len(xy_b))
|
|
219
|
+
a_ids = np.nonzero(ok_fwd)[0]
|
|
220
|
+
b_ids = nn_fwd[a_ids].astype(int)
|
|
221
|
+
|
|
222
|
+
# Mutual check: B's nearest neighbor back to A must be the same point
|
|
223
|
+
mutual = nn_rev[b_ids] == a_ids
|
|
224
|
+
return a_ids[mutual], b_ids[mutual]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# -----------------------------
|
|
228
|
+
# Quad generation & descriptors
|
|
229
|
+
# -----------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def make_local_quads(points: np.ndarray, k: int = 8) -> List[Tuple[int, int, int, int]]:
|
|
233
|
+
points = np.asarray(points, dtype=np.float64)
|
|
234
|
+
n = points.shape[0]
|
|
235
|
+
if n < 4:
|
|
236
|
+
return []
|
|
237
|
+
k = int(min(k, max(3, n - 1)))
|
|
238
|
+
|
|
239
|
+
tree = cKDTree(points)
|
|
240
|
+
_, idxs = tree.query(points, k=k + 1)
|
|
241
|
+
quads: List[Tuple[int, int, int, int]] = []
|
|
242
|
+
for i in range(n):
|
|
243
|
+
neigh = idxs[i, 1:]
|
|
244
|
+
for a in range(len(neigh) - 2):
|
|
245
|
+
for b in range(a + 1, len(neigh) - 1):
|
|
246
|
+
for c in range(b + 1, len(neigh)):
|
|
247
|
+
quads.append((i, int(neigh[a]), int(neigh[b]), int(neigh[c])))
|
|
248
|
+
return quads
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def quad_descriptor(P: np.ndarray) -> Tuple[np.ndarray, float]:
|
|
252
|
+
"""
|
|
253
|
+
Descriptor invariant to translation/rotation/scale.
|
|
254
|
+
Returns (desc[4], baseline_len).
|
|
255
|
+
"""
|
|
256
|
+
P = np.asarray(P, dtype=np.float64)
|
|
257
|
+
descs, lens, _ = quad_descriptor_batch(P, np.arange(4, dtype=np.int32)[None, :])
|
|
258
|
+
return descs[0], float(lens[0])
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def quad_descriptor_batch(
|
|
262
|
+
points: np.ndarray, quad_indices: np.ndarray
|
|
263
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
264
|
+
"""
|
|
265
|
+
Vectorized canonical quad descriptor calculation for multiple quads.
|
|
266
|
+
|
|
267
|
+
The descriptor is a pure geometric invariant of the 4-point set: the
|
|
268
|
+
baseline endpoint A is chosen such that cx + dx <= 1 (astrometry.net
|
|
269
|
+
convention) and C, D are ordered lexicographically, so the same physical
|
|
270
|
+
quad yields the same descriptor and point order regardless of the order
|
|
271
|
+
in which the indices are listed.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
points: (N, 2) array of point coordinates
|
|
275
|
+
quad_indices: (M, 4) array of quad indices into points
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
descs: (M, 4) array of descriptors
|
|
279
|
+
lens: (M,) array of baseline lengths
|
|
280
|
+
order: (M, 4) array of point indices in canonical (A, B, C, D)
|
|
281
|
+
order (-1 for degenerate quads)
|
|
282
|
+
"""
|
|
283
|
+
quad_indices = np.asarray(quad_indices)
|
|
284
|
+
M = quad_indices.shape[0]
|
|
285
|
+
descs = np.full((M, 4), np.nan, dtype=np.float64)
|
|
286
|
+
lens = np.full(M, np.nan, dtype=np.float64)
|
|
287
|
+
order = np.full((M, 4), -1, dtype=np.int64)
|
|
288
|
+
|
|
289
|
+
if M == 0:
|
|
290
|
+
return descs, lens, order
|
|
291
|
+
|
|
292
|
+
# Gather all quad points: (M, 4, 2)
|
|
293
|
+
P = points[quad_indices]
|
|
294
|
+
|
|
295
|
+
# Compute pairwise distances for all quads: (M, 4, 4)
|
|
296
|
+
diff = P[:, None, :, :] - P[:, :, None, :] # (M, 4, 4, 2)
|
|
297
|
+
d2 = np.sum(diff**2, axis=3) # (M, 4, 4)
|
|
298
|
+
|
|
299
|
+
# Find longest baseline for each quad
|
|
300
|
+
flat_idx = np.argmax(d2.reshape(M, -1), axis=1)
|
|
301
|
+
i_idx = flat_idx // 4
|
|
302
|
+
j_idx = flat_idx % 4
|
|
303
|
+
|
|
304
|
+
# Check for degenerate quads (i == j)
|
|
305
|
+
valid = i_idx != j_idx
|
|
306
|
+
|
|
307
|
+
# Process only valid quads
|
|
308
|
+
if not np.any(valid):
|
|
309
|
+
return descs, lens, order
|
|
310
|
+
|
|
311
|
+
valid_mask = np.where(valid)[0]
|
|
312
|
+
M_valid = len(valid_mask)
|
|
313
|
+
|
|
314
|
+
# Extract baseline points for valid quads
|
|
315
|
+
A = P[valid_mask, i_idx[valid_mask]] # (M_valid, 2)
|
|
316
|
+
B = P[valid_mask, j_idx[valid_mask]] # (M_valid, 2)
|
|
317
|
+
|
|
318
|
+
# Compute baseline vectors and lengths
|
|
319
|
+
base = B - A # (M_valid, 2)
|
|
320
|
+
L = np.hypot(base[:, 0], base[:, 1]) # (M_valid,)
|
|
321
|
+
|
|
322
|
+
# Filter out zero-length baselines
|
|
323
|
+
valid_L = L > 0
|
|
324
|
+
if not np.any(valid_L):
|
|
325
|
+
return descs, lens, order
|
|
326
|
+
|
|
327
|
+
final_mask = valid_mask[valid_L]
|
|
328
|
+
M_final = len(final_mask)
|
|
329
|
+
|
|
330
|
+
# Update for final valid quads
|
|
331
|
+
A = A[valid_L]
|
|
332
|
+
B = B[valid_L]
|
|
333
|
+
base = base[valid_L]
|
|
334
|
+
L = L[valid_L]
|
|
335
|
+
i_idx_final = i_idx[final_mask]
|
|
336
|
+
j_idx_final = j_idx[final_mask]
|
|
337
|
+
P_final = P[final_mask]
|
|
338
|
+
|
|
339
|
+
# Compute orthonormal basis
|
|
340
|
+
e1 = base / L[:, None] # (M_final, 2)
|
|
341
|
+
e2 = np.stack([-e1[:, 1], e1[:, 0]], axis=1) # (M_final, 2)
|
|
342
|
+
|
|
343
|
+
# Find the other two points (C, D) for each quad (vectorized)
|
|
344
|
+
# Create a mask for each quad indicating which indices are NOT i or j
|
|
345
|
+
all_indices = np.arange(4)
|
|
346
|
+
mask = np.ones((M_final, 4), dtype=bool)
|
|
347
|
+
mask[np.arange(M_final), i_idx_final] = False
|
|
348
|
+
mask[np.arange(M_final), j_idx_final] = False
|
|
349
|
+
|
|
350
|
+
# Find the two other indices for each quad
|
|
351
|
+
other_indices = np.where(mask)
|
|
352
|
+
# Reshape to (M_final, 2) - two "other" indices per quad
|
|
353
|
+
other_indices_reshaped = other_indices[1].reshape(M_final, 2)
|
|
354
|
+
C_indices = other_indices_reshaped[:, 0]
|
|
355
|
+
D_indices = other_indices_reshaped[:, 1]
|
|
356
|
+
|
|
357
|
+
C = P_final[np.arange(M_final), C_indices] # (M_final, 2)
|
|
358
|
+
D = P_final[np.arange(M_final), D_indices] # (M_final, 2)
|
|
359
|
+
|
|
360
|
+
# Project C and D onto basis and normalize by L
|
|
361
|
+
C_vec = C - A
|
|
362
|
+
D_vec = D - A
|
|
363
|
+
|
|
364
|
+
c_coords = (
|
|
365
|
+
np.stack([np.sum(C_vec * e1, axis=1), np.sum(C_vec * e2, axis=1)], axis=1) / L[:, None]
|
|
366
|
+
) # (M_final, 2)
|
|
367
|
+
|
|
368
|
+
d_coords = (
|
|
369
|
+
np.stack([np.sum(D_vec * e1, axis=1), np.sum(D_vec * e2, axis=1)], axis=1) / L[:, None]
|
|
370
|
+
) # (M_final, 2)
|
|
371
|
+
|
|
372
|
+
# Canonical A/B choice (astrometry.net convention): require cx + dx <= 1.
|
|
373
|
+
# Swapping A and B maps internal coordinates (x, y) -> (1 - x, -y).
|
|
374
|
+
swap_ab = (c_coords[:, 0] + d_coords[:, 0]) > 1.0
|
|
375
|
+
c_coords[swap_ab] = np.stack(
|
|
376
|
+
[1.0 - c_coords[swap_ab, 0], -c_coords[swap_ab, 1]], axis=1
|
|
377
|
+
)
|
|
378
|
+
d_coords[swap_ab] = np.stack(
|
|
379
|
+
[1.0 - d_coords[swap_ab, 0], -d_coords[swap_ab, 1]], axis=1
|
|
380
|
+
)
|
|
381
|
+
A_pos = np.where(swap_ab, j_idx_final, i_idx_final)
|
|
382
|
+
B_pos = np.where(swap_ab, i_idx_final, j_idx_final)
|
|
383
|
+
|
|
384
|
+
# Canonical C/D order: lexicographic by (x, y)
|
|
385
|
+
swap_cd = (c_coords[:, 0] > d_coords[:, 0]) | (
|
|
386
|
+
(c_coords[:, 0] == d_coords[:, 0]) & (c_coords[:, 1] > d_coords[:, 1])
|
|
387
|
+
)
|
|
388
|
+
c_fin = np.where(swap_cd[:, None], d_coords, c_coords)
|
|
389
|
+
d_fin = np.where(swap_cd[:, None], c_coords, d_coords)
|
|
390
|
+
C_pos = np.where(swap_cd, D_indices, C_indices)
|
|
391
|
+
D_pos = np.where(swap_cd, C_indices, D_indices)
|
|
392
|
+
|
|
393
|
+
descs[final_mask] = np.concatenate([c_fin, d_fin], axis=1)
|
|
394
|
+
lens[final_mask] = L
|
|
395
|
+
pos = np.stack([A_pos, B_pos, C_pos, D_pos], axis=1)
|
|
396
|
+
order[final_mask] = np.take_along_axis(quad_indices[final_mask], pos, axis=1)
|
|
397
|
+
|
|
398
|
+
return descs, lens, order
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def quantize_desc(desc: np.ndarray, eps: float) -> Tuple[int, int, int, int]:
|
|
402
|
+
q = np.floor(desc / eps + 0.5).astype(np.int64)
|
|
403
|
+
return int(q[0]), int(q[1]), int(q[2]), int(q[3])
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def reflect_desc(desc: np.ndarray) -> np.ndarray:
|
|
407
|
+
"""Reflect quad descriptor across its baseline (flip y) with lexicographic re-ordering."""
|
|
408
|
+
return _reflect_desc_order(desc)[0]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _reflect_desc_order(desc: np.ndarray) -> Tuple[np.ndarray, bool]:
|
|
412
|
+
"""Reflect descriptor across baseline; also report whether C/D swapped."""
|
|
413
|
+
x1, y1, x2, y2 = (float(desc[0]), -float(desc[1]), float(desc[2]), -float(desc[3]))
|
|
414
|
+
if (x1 > x2) or (x1 == x2 and y1 > y2):
|
|
415
|
+
return np.array([x2, y2, x1, y1], dtype=np.float64), True
|
|
416
|
+
return np.array([x1, y1, x2, y2], dtype=np.float64), False
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _ab_swap_desc_order(desc: np.ndarray) -> Tuple[np.ndarray, bool]:
|
|
420
|
+
"""Descriptor after swapping baseline endpoints A/B, i.e. (x, y) -> (1-x, -y),
|
|
421
|
+
with lexicographic re-ordering; also report whether C/D swapped."""
|
|
422
|
+
x1, y1, x2, y2 = (
|
|
423
|
+
1.0 - float(desc[0]),
|
|
424
|
+
-float(desc[1]),
|
|
425
|
+
1.0 - float(desc[2]),
|
|
426
|
+
-float(desc[3]),
|
|
427
|
+
)
|
|
428
|
+
if (x1 > x2) or (x1 == x2 and y1 > y2):
|
|
429
|
+
return np.array([x2, y2, x1, y1], dtype=np.float64), True
|
|
430
|
+
return np.array([x1, y1, x2, y2], dtype=np.float64), False
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _swap_cd(idxs: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
|
|
434
|
+
return (idxs[0], idxs[1], idxs[3], idxs[2])
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def multiprobe_desc_keys(
|
|
438
|
+
desc: np.ndarray, eps: float, threshold: float = 0.3
|
|
439
|
+
) -> List[Tuple[int, int, int, int]]:
|
|
440
|
+
"""Multi-probe hashing: return the primary quantized key plus keys for nearby
|
|
441
|
+
bins where the descriptor is close to a bin boundary.
|
|
442
|
+
|
|
443
|
+
Instead of searching all (2r+1)^4 = 81 neighboring bins, this probes only
|
|
444
|
+
the bins that the descriptor might fall into due to quantization noise.
|
|
445
|
+
Typically returns 1-8 keys instead of 81.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
desc: 4D descriptor array
|
|
449
|
+
eps: quantization step size
|
|
450
|
+
threshold: fraction of eps within which a boundary is considered "near"
|
|
451
|
+
"""
|
|
452
|
+
q = desc / eps + 0.5
|
|
453
|
+
base = np.floor(q).astype(np.int64)
|
|
454
|
+
frac = q - base # fractional part in [0, 1)
|
|
455
|
+
|
|
456
|
+
primary = tuple(int(x) for x in base)
|
|
457
|
+
keys = [primary]
|
|
458
|
+
|
|
459
|
+
# Find dimensions near bin boundaries
|
|
460
|
+
near_dims = []
|
|
461
|
+
offsets_per_dim = {}
|
|
462
|
+
|
|
463
|
+
for d in range(4):
|
|
464
|
+
if frac[d] < threshold:
|
|
465
|
+
near_dims.append(d)
|
|
466
|
+
offsets_per_dim[d] = -1
|
|
467
|
+
elif frac[d] > (1.0 - threshold):
|
|
468
|
+
near_dims.append(d)
|
|
469
|
+
offsets_per_dim[d] = 1
|
|
470
|
+
|
|
471
|
+
# Single-dimension probes
|
|
472
|
+
for d in near_dims:
|
|
473
|
+
alt = list(base)
|
|
474
|
+
alt[d] += offsets_per_dim[d]
|
|
475
|
+
keys.append(tuple(int(x) for x in alt))
|
|
476
|
+
|
|
477
|
+
# Two-dimension combination probes
|
|
478
|
+
for i in range(len(near_dims)):
|
|
479
|
+
for j in range(i + 1, len(near_dims)):
|
|
480
|
+
d1, d2 = near_dims[i], near_dims[j]
|
|
481
|
+
alt = list(base)
|
|
482
|
+
alt[d1] += offsets_per_dim[d1]
|
|
483
|
+
alt[d2] += offsets_per_dim[d2]
|
|
484
|
+
keys.append(tuple(int(x) for x in alt))
|
|
485
|
+
|
|
486
|
+
return keys
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def compute_bin_edges(lengths: np.ndarray, n_bins: int) -> Optional[np.ndarray]:
|
|
490
|
+
"""Compute quantile-based bin edges from a set of baseline lengths.
|
|
491
|
+
|
|
492
|
+
Returns array of (n_bins+1) edge values, or None if binning not possible.
|
|
493
|
+
"""
|
|
494
|
+
lengths = np.asarray(lengths, dtype=np.float64)
|
|
495
|
+
if len(lengths) == 0 or n_bins <= 1:
|
|
496
|
+
return None
|
|
497
|
+
qs = np.quantile(lengths, q=np.linspace(0, 1, n_bins + 1))
|
|
498
|
+
for i in range(1, len(qs)):
|
|
499
|
+
if qs[i] <= qs[i - 1]:
|
|
500
|
+
qs[i] = np.nextafter(qs[i - 1], np.inf)
|
|
501
|
+
return qs
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def baseline_rank_bins(
|
|
505
|
+
lengths: np.ndarray, n_bins: int, edges: Optional[np.ndarray] = None
|
|
506
|
+
) -> np.ndarray:
|
|
507
|
+
"""
|
|
508
|
+
Assign each length to a *rank* bin based on quantiles.
|
|
509
|
+
If edges are provided, use those instead of computing from the data.
|
|
510
|
+
This allows consistent binning between detection and reference sets.
|
|
511
|
+
"""
|
|
512
|
+
lengths = np.asarray(lengths, dtype=np.float64)
|
|
513
|
+
n = len(lengths)
|
|
514
|
+
if n == 0:
|
|
515
|
+
return np.zeros(0, dtype=np.int16)
|
|
516
|
+
if n_bins <= 1:
|
|
517
|
+
return np.zeros(n, dtype=np.int16)
|
|
518
|
+
if edges is None:
|
|
519
|
+
edges = compute_bin_edges(lengths, n_bins)
|
|
520
|
+
if edges is None:
|
|
521
|
+
return np.zeros(n, dtype=np.int16)
|
|
522
|
+
b = np.searchsorted(edges[1:-1], lengths, side="right")
|
|
523
|
+
return np.clip(b, 0, n_bins - 1).astype(np.int16)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@dataclass
|
|
527
|
+
class QuadEntry:
|
|
528
|
+
idxs: Tuple[int, int, int, int]
|
|
529
|
+
mags: Optional[Tuple[float, float, float, float]] = None
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def build_quad_hash_multiscale(
|
|
533
|
+
points: np.ndarray,
|
|
534
|
+
mags: Optional[np.ndarray],
|
|
535
|
+
k: int,
|
|
536
|
+
eps: float,
|
|
537
|
+
n_scale_bins: int,
|
|
538
|
+
allow_reflection: bool = True,
|
|
539
|
+
bin_edges: Optional[np.ndarray] = None,
|
|
540
|
+
quad_array: Optional[np.ndarray] = None,
|
|
541
|
+
descs: Optional[np.ndarray] = None,
|
|
542
|
+
lens: Optional[np.ndarray] = None,
|
|
543
|
+
order: Optional[np.ndarray] = None,
|
|
544
|
+
) -> Dict[Tuple[int, Tuple[int, int, int, int]], List[QuadEntry]]:
|
|
545
|
+
"""
|
|
546
|
+
Hash key: (scale_bin_id, quantized_descriptor)
|
|
547
|
+
|
|
548
|
+
Entries store point indices in the canonical (A, B, C, D) descriptor
|
|
549
|
+
order, so a hash hit directly provides the point correspondence for the
|
|
550
|
+
transform fit.
|
|
551
|
+
|
|
552
|
+
If bin_edges is provided, use those for baseline binning instead of
|
|
553
|
+
computing quantiles from the data. This enables consistent binning
|
|
554
|
+
between detection and reference sets.
|
|
555
|
+
"""
|
|
556
|
+
if quad_array is None or descs is None or lens is None or order is None:
|
|
557
|
+
quads = make_local_quads(points, k=k)
|
|
558
|
+
if not quads:
|
|
559
|
+
return {}
|
|
560
|
+
|
|
561
|
+
quad_array = np.array(quads, dtype=np.int32)
|
|
562
|
+
descs, lens, order = quad_descriptor_batch(points, quad_array)
|
|
563
|
+
else:
|
|
564
|
+
quad_array = np.asarray(quad_array, dtype=np.int32)
|
|
565
|
+
descs = np.asarray(descs, dtype=np.float64)
|
|
566
|
+
lens = np.asarray(lens, dtype=np.float64)
|
|
567
|
+
order = np.asarray(order, dtype=np.int64)
|
|
568
|
+
|
|
569
|
+
if quad_array.ndim != 2 or quad_array.shape[1] != 4:
|
|
570
|
+
raise ValueError("quad_array must be (N, 4)")
|
|
571
|
+
if descs.shape != (len(quad_array), 4):
|
|
572
|
+
raise ValueError("descs must have shape (N, 4)")
|
|
573
|
+
if lens.shape != (len(quad_array),):
|
|
574
|
+
raise ValueError("lens must have shape (N,)")
|
|
575
|
+
if order.shape != (len(quad_array), 4):
|
|
576
|
+
raise ValueError("order must have shape (N, 4)")
|
|
577
|
+
|
|
578
|
+
ok = (
|
|
579
|
+
np.isfinite(descs).all(axis=1)
|
|
580
|
+
& np.isfinite(lens)
|
|
581
|
+
& (lens > 0)
|
|
582
|
+
& np.all(order >= 0, axis=1)
|
|
583
|
+
)
|
|
584
|
+
order_ok = order[ok]
|
|
585
|
+
descs_ok = descs[ok]
|
|
586
|
+
lens_ok = lens[ok]
|
|
587
|
+
|
|
588
|
+
if len(order_ok) == 0:
|
|
589
|
+
return {}
|
|
590
|
+
|
|
591
|
+
bins = baseline_rank_bins(lens_ok, n_bins=n_scale_bins, edges=bin_edges)
|
|
592
|
+
|
|
593
|
+
# Near the A/B canonicalization boundary (cx + dx == 1) noise can flip
|
|
594
|
+
# the canonical endpoint choice between detection and reference sets, so
|
|
595
|
+
# quads close to it are also inserted with the endpoints swapped.
|
|
596
|
+
ab_boundary = 2.0 * eps
|
|
597
|
+
|
|
598
|
+
H: Dict[Tuple[int, Tuple[int, int, int, int]], List[QuadEntry]] = {}
|
|
599
|
+
for i in range(len(order_ok)):
|
|
600
|
+
q = tuple(int(v) for v in order_ok[i])
|
|
601
|
+
desc = descs_ok[i]
|
|
602
|
+
b = int(bins[i])
|
|
603
|
+
|
|
604
|
+
variants = [(desc, q)]
|
|
605
|
+
if abs(desc[0] + desc[2] - 1.0) <= ab_boundary:
|
|
606
|
+
desc_ab, cd_swap = _ab_swap_desc_order(desc)
|
|
607
|
+
q_ab = (q[1], q[0], q[2], q[3])
|
|
608
|
+
variants.append((desc_ab, _swap_cd(q_ab) if cd_swap else q_ab))
|
|
609
|
+
if allow_reflection:
|
|
610
|
+
# Reflected counterparts of all variants to handle parity flips
|
|
611
|
+
for dv, qv in list(variants):
|
|
612
|
+
desc_ref, cd_swap = _reflect_desc_order(dv)
|
|
613
|
+
variants.append((desc_ref, _swap_cd(qv) if cd_swap else qv))
|
|
614
|
+
|
|
615
|
+
seen_keys = set()
|
|
616
|
+
for dv, qv in variants:
|
|
617
|
+
key = (b, quantize_desc(dv, eps))
|
|
618
|
+
if key in seen_keys:
|
|
619
|
+
continue
|
|
620
|
+
seen_keys.add(key)
|
|
621
|
+
me = None
|
|
622
|
+
if mags is not None:
|
|
623
|
+
me = tuple(float(mags[idx]) for idx in qv)
|
|
624
|
+
H.setdefault(key, []).append(QuadEntry(idxs=qv, mags=me))
|
|
625
|
+
|
|
626
|
+
return H
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class _PatternMatchFailed(RuntimeError):
|
|
630
|
+
"""Internal sentinel for pattern matching failure (used by adaptive retry)."""
|
|
631
|
+
|
|
632
|
+
pass
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _auto_match_resolution(det_xy: np.ndarray, ref_uv: np.ndarray) -> Optional[float]:
|
|
636
|
+
"""Compute matching resolution from source density (SCAMP-inspired).
|
|
637
|
+
|
|
638
|
+
Uses the mean inter-source spacing as confusion limit:
|
|
639
|
+
matchresol = sqrt(field_area / n_sources)
|
|
640
|
+
|
|
641
|
+
Returns the confusion radius in pixels, or None if it can't be computed.
|
|
642
|
+
"""
|
|
643
|
+
# Use the smaller of the two sets (like SCAMP's cross-section approach)
|
|
644
|
+
n = min(len(det_xy), len(ref_uv))
|
|
645
|
+
if n < 4:
|
|
646
|
+
return None
|
|
647
|
+
|
|
648
|
+
# Estimate field area from convex hull or bounding box of detections
|
|
649
|
+
x_range = det_xy[:, 0].max() - det_xy[:, 0].min()
|
|
650
|
+
y_range = det_xy[:, 1].max() - det_xy[:, 1].min()
|
|
651
|
+
field_area = x_range * y_range
|
|
652
|
+
|
|
653
|
+
if field_area <= 0:
|
|
654
|
+
return None
|
|
655
|
+
|
|
656
|
+
# Mean area per source → confusion radius
|
|
657
|
+
mean_area = field_area / n
|
|
658
|
+
confusion_radius = np.sqrt(mean_area)
|
|
659
|
+
|
|
660
|
+
return confusion_radius
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
# -----------------------------
|
|
664
|
+
# Solver
|
|
665
|
+
# -----------------------------
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@dataclass
|
|
669
|
+
class AstrometryConfig:
|
|
670
|
+
# Selection sizes
|
|
671
|
+
n_det: int = 150
|
|
672
|
+
n_ref: int = 600
|
|
673
|
+
|
|
674
|
+
# Quads
|
|
675
|
+
neighbor_k: int = 10
|
|
676
|
+
eps_desc: float = 0.02
|
|
677
|
+
n_scale_bins: int = 6
|
|
678
|
+
|
|
679
|
+
allow_reflection: bool = True
|
|
680
|
+
use_mag_signature: bool = True
|
|
681
|
+
mag_tolerance: float = 0.5 # ordering disagreements below this (mag) are ignored
|
|
682
|
+
|
|
683
|
+
# Hypothesis sanity checks: both point sets live in the same pixel space
|
|
684
|
+
# (catalogue projected through the initial WCS), so valid transforms have
|
|
685
|
+
# scale close to 1 and positive parity
|
|
686
|
+
use_wcs_prior: bool = True
|
|
687
|
+
scale_tolerance: float = 0.30 # fractional, e.g. 0.30 = +/-30%
|
|
688
|
+
enforce_parity: bool = True
|
|
689
|
+
|
|
690
|
+
# Two-stage scoring
|
|
691
|
+
stage1_n_det: int = 60 # cheap scoring uses only brightest subset of selected detections
|
|
692
|
+
stage1_radius_arcsec: float = 20.0 # loose
|
|
693
|
+
stage2_radius_arcsec: float = 8.0 # tighter
|
|
694
|
+
top_k_hypotheses: int = 80 # keep best hypotheses from stage 1
|
|
695
|
+
max_quads_tested: int = 8000 # max detection quads to try
|
|
696
|
+
max_candidates_per_bucket: int = 60 # cap ref candidates pulled per hash bucket
|
|
697
|
+
|
|
698
|
+
# Final fit
|
|
699
|
+
sip_degree: int = 3
|
|
700
|
+
refine_clip_sigma: float = 4.0
|
|
701
|
+
refine_clip_sigma_start: float = 5.0 # progressive clipping: start value
|
|
702
|
+
refine_min_match_fraction: float = 0.5 # keep at least this fraction of initial matches
|
|
703
|
+
refine_max_iter: int = 5
|
|
704
|
+
refine_rematch_iters: int = 2 # iterative affine re-matching rounds
|
|
705
|
+
# Optional expanded refinement pool (use many more objects for final fit)
|
|
706
|
+
refine_use_all: bool = False
|
|
707
|
+
refine_n_det: Optional[int] = None
|
|
708
|
+
refine_n_ref: Optional[int] = None
|
|
709
|
+
refine_match_radius_arcsec: Optional[float] = None
|
|
710
|
+
|
|
711
|
+
# Auto matching resolution: compute stage radii from source density.
|
|
712
|
+
# Disabled by default: the density-based inflation can produce very large
|
|
713
|
+
# radii for sparse wide-field images; prefer pixel-scale-aware radii.
|
|
714
|
+
auto_match_resolution: bool = False
|
|
715
|
+
|
|
716
|
+
# Adaptive source count: retry with more sources if matching fails
|
|
717
|
+
adaptive_n_retry: int = 2 # number of retry doublings (0 = disabled)
|
|
718
|
+
adaptive_min_inliers: int = 12 # minimum inliers to accept without retry
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
class QuadHashAstrometry:
|
|
722
|
+
def __init__(self, config: Optional[AstrometryConfig] = None):
|
|
723
|
+
self.cfg = config if config is not None else AstrometryConfig()
|
|
724
|
+
|
|
725
|
+
def _pattern_match(
|
|
726
|
+
self,
|
|
727
|
+
det_xy: np.ndarray,
|
|
728
|
+
det_mag: np.ndarray,
|
|
729
|
+
ref_uv: np.ndarray,
|
|
730
|
+
ref_mag: np.ndarray,
|
|
731
|
+
pixel_scale_arcsec: float,
|
|
732
|
+
cfg: Optional['AstrometryConfig'] = None,
|
|
733
|
+
) -> Tuple[list, list, dict]:
|
|
734
|
+
"""Run quad-hash pattern matching (stages 1 & 2) + affine re-matching.
|
|
735
|
+
|
|
736
|
+
Returns (pairs, top_hyp, best) where pairs is list of (det_idx, ref_idx).
|
|
737
|
+
Raises _PatternMatchFailed if matching fails.
|
|
738
|
+
"""
|
|
739
|
+
if cfg is None:
|
|
740
|
+
cfg = self.cfg
|
|
741
|
+
# Hypothesis sanity checks: the catalogue is projected into pixel space
|
|
742
|
+
# with wcs_init, so the det->ref transform must have scale close to 1
|
|
743
|
+
# and preserve parity, regardless of the sky orientation of the WCS.
|
|
744
|
+
expected_scale = None
|
|
745
|
+
expected_parity = None # sign of determinant
|
|
746
|
+
if cfg.use_wcs_prior:
|
|
747
|
+
expected_scale = 1.0
|
|
748
|
+
expected_parity = 1.0
|
|
749
|
+
|
|
750
|
+
# --- Consistent baseline binning ---
|
|
751
|
+
ref_qa = None
|
|
752
|
+
ref_descs_raw = None
|
|
753
|
+
ref_lens_raw = None
|
|
754
|
+
ref_order_raw = None
|
|
755
|
+
ref_bin_edges = None
|
|
756
|
+
ref_quads_raw = make_local_quads(ref_uv, k=cfg.neighbor_k)
|
|
757
|
+
if ref_quads_raw:
|
|
758
|
+
ref_qa = np.array(ref_quads_raw, dtype=np.int32)
|
|
759
|
+
ref_descs_raw, ref_lens_raw, ref_order_raw = quad_descriptor_batch(ref_uv, ref_qa)
|
|
760
|
+
ok_ref = np.isfinite(ref_lens_raw) & (ref_lens_raw > 0)
|
|
761
|
+
if np.any(ok_ref):
|
|
762
|
+
ref_bin_edges = compute_bin_edges(ref_lens_raw[ok_ref], cfg.n_scale_bins)
|
|
763
|
+
|
|
764
|
+
# Reference multiscale hash
|
|
765
|
+
ref_hash = build_quad_hash_multiscale(
|
|
766
|
+
ref_uv,
|
|
767
|
+
ref_mag,
|
|
768
|
+
k=cfg.neighbor_k,
|
|
769
|
+
eps=cfg.eps_desc,
|
|
770
|
+
n_scale_bins=cfg.n_scale_bins,
|
|
771
|
+
allow_reflection=cfg.allow_reflection,
|
|
772
|
+
bin_edges=ref_bin_edges,
|
|
773
|
+
quad_array=ref_qa,
|
|
774
|
+
descs=ref_descs_raw,
|
|
775
|
+
lens=ref_lens_raw,
|
|
776
|
+
order=ref_order_raw,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
# Detection quads
|
|
780
|
+
det_quads = make_local_quads(det_xy, k=cfg.neighbor_k)
|
|
781
|
+
if not det_quads:
|
|
782
|
+
raise _PatternMatchFailed("Not enough detections for quad matching.")
|
|
783
|
+
|
|
784
|
+
det_quad_array = np.array(det_quads, dtype=np.int32)
|
|
785
|
+
det_descs, det_lens, det_order = quad_descriptor_batch(det_xy, det_quad_array)
|
|
786
|
+
|
|
787
|
+
okq = (
|
|
788
|
+
np.isfinite(det_descs).all(axis=1)
|
|
789
|
+
& np.isfinite(det_lens)
|
|
790
|
+
& (det_lens > 0)
|
|
791
|
+
& np.all(det_order >= 0, axis=1)
|
|
792
|
+
)
|
|
793
|
+
det_order = det_order[okq]
|
|
794
|
+
det_descs = det_descs[okq]
|
|
795
|
+
det_lens = det_lens[okq]
|
|
796
|
+
|
|
797
|
+
if ref_bin_edges is not None:
|
|
798
|
+
det_bins = baseline_rank_bins(det_lens, n_bins=cfg.n_scale_bins, edges=ref_bin_edges)
|
|
799
|
+
else:
|
|
800
|
+
det_bins = baseline_rank_bins(det_lens, n_bins=cfg.n_scale_bins)
|
|
801
|
+
|
|
802
|
+
ref_tree = cKDTree(ref_uv)
|
|
803
|
+
|
|
804
|
+
# Stage radii in pixels
|
|
805
|
+
r1 = cfg.stage1_radius_arcsec / pixel_scale_arcsec
|
|
806
|
+
r2 = cfg.stage2_radius_arcsec / pixel_scale_arcsec
|
|
807
|
+
|
|
808
|
+
# Stage1 detection subset
|
|
809
|
+
order_det = np.argsort(det_mag)
|
|
810
|
+
det_stage1_ids = order_det[: min(cfg.stage1_n_det, len(order_det))]
|
|
811
|
+
det_xy_stage1 = det_xy[det_stage1_ids]
|
|
812
|
+
|
|
813
|
+
top_hyp: List[Tuple[float, np.ndarray, np.ndarray, float]] = []
|
|
814
|
+
|
|
815
|
+
rng = np.random.default_rng(12345)
|
|
816
|
+
|
|
817
|
+
q_order = np.arange(len(det_order))
|
|
818
|
+
rng.shuffle(q_order)
|
|
819
|
+
q_order = q_order[: min(cfg.max_quads_tested, len(q_order))]
|
|
820
|
+
|
|
821
|
+
def _try_insert(score: float, R: np.ndarray, t: np.ndarray, s: float):
|
|
822
|
+
nonlocal top_hyp
|
|
823
|
+
if len(top_hyp) < cfg.top_k_hypotheses:
|
|
824
|
+
top_hyp.append((score, R, t, s))
|
|
825
|
+
return
|
|
826
|
+
worst_i = int(np.argmin([h[0] for h in top_hyp]))
|
|
827
|
+
if score > top_hyp[worst_i][0]:
|
|
828
|
+
top_hyp[worst_i] = (score, R, t, s)
|
|
829
|
+
|
|
830
|
+
# --- Stage 1 ---
|
|
831
|
+
for qi in q_order:
|
|
832
|
+
# Canonical (A, B, C, D) point order: a hash hit directly gives
|
|
833
|
+
# the det<->ref point correspondence for the similarity fit
|
|
834
|
+
q = det_order[qi]
|
|
835
|
+
bin_id = int(det_bins[qi])
|
|
836
|
+
|
|
837
|
+
candidates: List[QuadEntry] = []
|
|
838
|
+
for kdesc2 in multiprobe_desc_keys(det_descs[qi], cfg.eps_desc):
|
|
839
|
+
kk = (bin_id, kdesc2)
|
|
840
|
+
if kk in ref_hash:
|
|
841
|
+
candidates.extend(ref_hash[kk])
|
|
842
|
+
|
|
843
|
+
if not candidates:
|
|
844
|
+
continue
|
|
845
|
+
|
|
846
|
+
det_quad_mags = det_mag[q] if cfg.use_mag_signature else None
|
|
847
|
+
P = det_xy[q]
|
|
848
|
+
|
|
849
|
+
rng.shuffle(candidates)
|
|
850
|
+
for ce in candidates[: cfg.max_candidates_per_bucket]:
|
|
851
|
+
if det_quad_mags is not None and ce.mags is not None:
|
|
852
|
+
if not mag_orderings_consistent(
|
|
853
|
+
det_quad_mags, ce.mags, cfg.mag_tolerance
|
|
854
|
+
):
|
|
855
|
+
continue
|
|
856
|
+
|
|
857
|
+
Q = ref_uv[list(ce.idxs)]
|
|
858
|
+
|
|
859
|
+
try:
|
|
860
|
+
R, t, s = estimate_similarity_2d(P, Q, allow_reflection=cfg.allow_reflection)
|
|
861
|
+
except Exception:
|
|
862
|
+
continue
|
|
863
|
+
|
|
864
|
+
if expected_scale is not None and cfg.scale_tolerance is not None:
|
|
865
|
+
tol = float(cfg.scale_tolerance)
|
|
866
|
+
if tol >= 0:
|
|
867
|
+
if not (expected_scale * (1 - tol) <= s <= expected_scale * (1 + tol)):
|
|
868
|
+
continue
|
|
869
|
+
if expected_parity is not None and cfg.enforce_parity and cfg.allow_reflection:
|
|
870
|
+
detR = float(np.linalg.det(R))
|
|
871
|
+
if not np.isfinite(detR) or np.sign(detR) != expected_parity:
|
|
872
|
+
continue
|
|
873
|
+
|
|
874
|
+
det_uv1 = apply_similarity(det_xy_stage1, R, t, s)
|
|
875
|
+
dist, nn = ref_tree.query(det_uv1, k=1, distance_upper_bound=r1)
|
|
876
|
+
ok = np.isfinite(dist) & (dist < r1) & (nn < len(ref_uv))
|
|
877
|
+
if not np.any(ok):
|
|
878
|
+
continue
|
|
879
|
+
|
|
880
|
+
weights = 1.0 - (dist[ok] / r1) ** 2
|
|
881
|
+
score = float(np.sum(weights))
|
|
882
|
+
|
|
883
|
+
_try_insert(score, R, t, s)
|
|
884
|
+
|
|
885
|
+
if not top_hyp:
|
|
886
|
+
raise _PatternMatchFailed(
|
|
887
|
+
"Pattern matching failed at stage 1. "
|
|
888
|
+
"Try increasing n_det/n_ref, eps_desc, or stage1_radius_arcsec."
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
# --- Stage 2 ---
|
|
892
|
+
top_hyp.sort(key=lambda x: x[0], reverse=True)
|
|
893
|
+
|
|
894
|
+
best = {"score": -1.0, "inliers": -1, "R": None, "t": None, "s": None, "pairs": None}
|
|
895
|
+
|
|
896
|
+
for score1, R, t, s in top_hyp:
|
|
897
|
+
# Scale/parity filters already applied in stage 1
|
|
898
|
+
det_uv = apply_similarity(det_xy, R, t, s)
|
|
899
|
+
|
|
900
|
+
det_ids, ref_ids = _mutual_nearest_neighbor(det_uv, ref_uv, r2, tree_b=ref_tree)
|
|
901
|
+
|
|
902
|
+
if len(det_ids) == 0:
|
|
903
|
+
continue
|
|
904
|
+
|
|
905
|
+
dists = np.hypot(
|
|
906
|
+
det_uv[det_ids, 0] - ref_uv[ref_ids, 0], det_uv[det_ids, 1] - ref_uv[ref_ids, 1]
|
|
907
|
+
)
|
|
908
|
+
weights = 1.0 - (dists / r2) ** 2
|
|
909
|
+
score = float(np.sum(weights))
|
|
910
|
+
|
|
911
|
+
pairs = list(zip(det_ids.tolist(), ref_ids.tolist()))
|
|
912
|
+
|
|
913
|
+
if score > best["score"]:
|
|
914
|
+
best = {
|
|
915
|
+
"score": score,
|
|
916
|
+
"inliers": len(pairs),
|
|
917
|
+
"R": R,
|
|
918
|
+
"t": t,
|
|
919
|
+
"s": s,
|
|
920
|
+
"pairs": pairs,
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if best["inliers"] < 8:
|
|
924
|
+
raise _PatternMatchFailed(
|
|
925
|
+
f"Pattern matching failed at stage 2 (best inliers={best['inliers']}). "
|
|
926
|
+
f"Try loosening stage2_radius_arcsec or increasing top_k_hypotheses."
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
# --- Iterative affine re-matching ---
|
|
930
|
+
pairs = best["pairs"]
|
|
931
|
+
r_rematch = r2
|
|
932
|
+
|
|
933
|
+
for rematch_it in range(cfg.refine_rematch_iters):
|
|
934
|
+
if len(pairs) < 6:
|
|
935
|
+
break
|
|
936
|
+
|
|
937
|
+
pairs_arr = np.array(pairs, dtype=np.int32)
|
|
938
|
+
A_pts = det_xy[pairs_arr[:, 0]]
|
|
939
|
+
B_pts = ref_uv[pairs_arr[:, 1]]
|
|
940
|
+
|
|
941
|
+
try:
|
|
942
|
+
M_affine, t_affine = estimate_affine_2d(A_pts, B_pts)
|
|
943
|
+
det_uv_affine = apply_affine(det_xy, M_affine, t_affine)
|
|
944
|
+
except Exception:
|
|
945
|
+
break
|
|
946
|
+
|
|
947
|
+
r_iter = r_rematch * (0.8 if rematch_it > 0 else 1.0)
|
|
948
|
+
|
|
949
|
+
new_det_ids, new_ref_ids = _mutual_nearest_neighbor(
|
|
950
|
+
det_uv_affine, ref_uv, r_iter, tree_b=ref_tree
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
if len(new_det_ids) < max(8, int(len(pairs) * 0.6)):
|
|
954
|
+
break
|
|
955
|
+
|
|
956
|
+
pairs = list(zip(new_det_ids.tolist(), new_ref_ids.tolist()))
|
|
957
|
+
|
|
958
|
+
return pairs, top_hyp, best
|
|
959
|
+
|
|
960
|
+
def refine(
|
|
961
|
+
self,
|
|
962
|
+
obj: Table,
|
|
963
|
+
cat: Table,
|
|
964
|
+
wcs_init: WCS,
|
|
965
|
+
fit_projection: Optional[WCS] = None,
|
|
966
|
+
pv_deg: int = 5,
|
|
967
|
+
) -> Tuple[WCS, Table, dict]:
|
|
968
|
+
if not all(k in obj.colnames for k in ("x", "y")):
|
|
969
|
+
raise ValueError("obj must contain 'x' and 'y'")
|
|
970
|
+
if not all(k in cat.colnames for k in ("ra", "dec", "mag")):
|
|
971
|
+
raise ValueError("cat must contain 'ra','dec','mag'")
|
|
972
|
+
|
|
973
|
+
x = _as_float_array(obj["x"])
|
|
974
|
+
y = _as_float_array(obj["y"])
|
|
975
|
+
if "mag" in obj.colnames:
|
|
976
|
+
m_obj = _as_float_array(obj["mag"])
|
|
977
|
+
elif "flux" in obj.colnames:
|
|
978
|
+
f = _as_float_array(obj["flux"])
|
|
979
|
+
f = np.where(f > 0, f, np.nan)
|
|
980
|
+
m_obj = -2.5 * np.log10(f)
|
|
981
|
+
else:
|
|
982
|
+
raise ValueError("obj must contain 'flux' or 'mag'")
|
|
983
|
+
|
|
984
|
+
ra = _as_float_array(cat["ra"])
|
|
985
|
+
dec = _as_float_array(cat["dec"])
|
|
986
|
+
m_cat = _as_float_array(cat["mag"])
|
|
987
|
+
|
|
988
|
+
m0 = _finite_mask(x, y, m_obj)
|
|
989
|
+
x, y, m_obj = x[m0], y[m0], m_obj[m0]
|
|
990
|
+
|
|
991
|
+
m1 = _finite_mask(ra, dec, m_cat)
|
|
992
|
+
ra, dec, m_cat = ra[m1], dec[m1], m_cat[m1]
|
|
993
|
+
|
|
994
|
+
# Rough sky center (used for residuals and SIP check)
|
|
995
|
+
try:
|
|
996
|
+
ra0, dec0 = float(wcs_init.wcs.crval[0]), float(wcs_init.wcs.crval[1])
|
|
997
|
+
if not (np.isfinite(ra0) and np.isfinite(dec0)):
|
|
998
|
+
raise ValueError
|
|
999
|
+
except Exception:
|
|
1000
|
+
ra0, dec0 = float(np.nanmedian(ra)), float(np.nanmedian(dec))
|
|
1001
|
+
|
|
1002
|
+
# Pixel scale for radius conversion (arcsec/pixel)
|
|
1003
|
+
try:
|
|
1004
|
+
pscales = wcs_init.proj_plane_pixel_scales()
|
|
1005
|
+
# proj_plane_pixel_scales() may return Quantity with units
|
|
1006
|
+
if hasattr(pscales[0], 'to_value'):
|
|
1007
|
+
pixel_scale_deg = float(np.mean([s.to_value(u.deg) for s in pscales]))
|
|
1008
|
+
else:
|
|
1009
|
+
pixel_scale_deg = float(np.mean(pscales))
|
|
1010
|
+
except Exception:
|
|
1011
|
+
pixel_scale_deg = None
|
|
1012
|
+
if pixel_scale_deg is None or pixel_scale_deg <= 0 or not np.isfinite(pixel_scale_deg):
|
|
1013
|
+
raise RuntimeError("Could not determine pixel scale from WCS.")
|
|
1014
|
+
pixel_scale_arcsec = pixel_scale_deg * 3600.0
|
|
1015
|
+
|
|
1016
|
+
# --- Adaptive source count retry loop (SCAMP-inspired) ---
|
|
1017
|
+
# Try pattern matching with increasing source counts. On each retry,
|
|
1018
|
+
# double n_det/n_ref to bring in more sources for matching.
|
|
1019
|
+
|
|
1020
|
+
# Pre-sort by brightness (avoids re-sorting each retry)
|
|
1021
|
+
obj_order = np.argsort(m_obj) # lower mag = brighter
|
|
1022
|
+
cat_order = np.argsort(m_cat)
|
|
1023
|
+
|
|
1024
|
+
# Project full catalog to pixel space once (slice per retry)
|
|
1025
|
+
all_ref_xy = np.column_stack(wcs_init.all_world2pix(ra, dec, 0))
|
|
1026
|
+
all_ref_finite = np.all(np.isfinite(all_ref_xy), axis=1)
|
|
1027
|
+
|
|
1028
|
+
# Local config copy so auto_match_resolution doesn't mutate self.cfg
|
|
1029
|
+
cfg = dataclasses.replace(self.cfg)
|
|
1030
|
+
|
|
1031
|
+
n_retries = max(0, cfg.adaptive_n_retry)
|
|
1032
|
+
last_error = None
|
|
1033
|
+
# Snapshot of the best attempt so far: pairs index into the det/ref
|
|
1034
|
+
# subsets of the *same* attempt, so they must be kept together
|
|
1035
|
+
matched = None
|
|
1036
|
+
|
|
1037
|
+
for _attempt in range(n_retries + 1):
|
|
1038
|
+
scale_factor = 2**_attempt
|
|
1039
|
+
cur_n_det = min(cfg.n_det * scale_factor, len(x))
|
|
1040
|
+
cur_n_ref = min(cfg.n_ref * scale_factor, len(ra))
|
|
1041
|
+
|
|
1042
|
+
# Select bright subsets (pre-sorted, just slice)
|
|
1043
|
+
obj_sub_idx = obj_order[:cur_n_det]
|
|
1044
|
+
cat_sub_idx = cat_order[:cur_n_ref]
|
|
1045
|
+
|
|
1046
|
+
det_xy = np.column_stack([x[obj_sub_idx], y[obj_sub_idx]])
|
|
1047
|
+
det_mag = m_obj[obj_sub_idx]
|
|
1048
|
+
|
|
1049
|
+
# Slice pre-projected catalog and filter non-finite
|
|
1050
|
+
ref_uv = all_ref_xy[cat_sub_idx]
|
|
1051
|
+
ref_mag = m_cat[cat_sub_idx]
|
|
1052
|
+
ref_ok = all_ref_finite[cat_sub_idx]
|
|
1053
|
+
if not np.all(ref_ok):
|
|
1054
|
+
ref_uv = ref_uv[ref_ok]
|
|
1055
|
+
ref_mag = ref_mag[ref_ok]
|
|
1056
|
+
cat_sub_idx = cat_sub_idx[ref_ok]
|
|
1057
|
+
|
|
1058
|
+
if len(det_xy) < 8 or len(ref_uv) < 8:
|
|
1059
|
+
last_error = _PatternMatchFailed("Not enough points after selection.")
|
|
1060
|
+
continue
|
|
1061
|
+
|
|
1062
|
+
# Auto matching resolution: widen stage radii from source density
|
|
1063
|
+
if cfg.auto_match_resolution:
|
|
1064
|
+
auto_r_pix = _auto_match_resolution(det_xy, ref_uv)
|
|
1065
|
+
if auto_r_pix is not None:
|
|
1066
|
+
auto_r_arcsec = auto_r_pix * pixel_scale_arcsec
|
|
1067
|
+
cfg = dataclasses.replace(
|
|
1068
|
+
cfg,
|
|
1069
|
+
stage1_radius_arcsec=max(cfg.stage1_radius_arcsec, auto_r_arcsec * 2.5),
|
|
1070
|
+
stage2_radius_arcsec=max(cfg.stage2_radius_arcsec, auto_r_arcsec),
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
try:
|
|
1074
|
+
pairs, top_hyp, best = self._pattern_match(
|
|
1075
|
+
det_xy,
|
|
1076
|
+
det_mag,
|
|
1077
|
+
ref_uv,
|
|
1078
|
+
ref_mag,
|
|
1079
|
+
pixel_scale_arcsec,
|
|
1080
|
+
cfg=cfg,
|
|
1081
|
+
)
|
|
1082
|
+
except _PatternMatchFailed as e:
|
|
1083
|
+
last_error = e
|
|
1084
|
+
if cur_n_det < len(x) or cur_n_ref < len(ra):
|
|
1085
|
+
continue
|
|
1086
|
+
else:
|
|
1087
|
+
break
|
|
1088
|
+
|
|
1089
|
+
matched = {
|
|
1090
|
+
"pairs": pairs,
|
|
1091
|
+
"top_hyp": top_hyp,
|
|
1092
|
+
"best": best,
|
|
1093
|
+
"det_xy": det_xy,
|
|
1094
|
+
"ref_uv": ref_uv,
|
|
1095
|
+
"obj_sub_idx": obj_sub_idx,
|
|
1096
|
+
"cat_sub_idx": cat_sub_idx,
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
# Check if we have enough inliers
|
|
1100
|
+
if len(pairs) >= cfg.adaptive_min_inliers:
|
|
1101
|
+
break
|
|
1102
|
+
# Not enough inliers — retry with more sources if possible
|
|
1103
|
+
last_error = _PatternMatchFailed(
|
|
1104
|
+
f"Only {len(pairs)} inliers (need {cfg.adaptive_min_inliers})"
|
|
1105
|
+
)
|
|
1106
|
+
if cur_n_det >= len(x) and cur_n_ref >= len(ra):
|
|
1107
|
+
break # can't add more sources
|
|
1108
|
+
|
|
1109
|
+
if matched is None or len(matched["pairs"]) < 8:
|
|
1110
|
+
raise RuntimeError(str(last_error) if last_error else "Pattern matching failed.")
|
|
1111
|
+
|
|
1112
|
+
# Restore the state of the attempt that produced the pairs (a later
|
|
1113
|
+
# failed attempt may have overwritten the loop variables)
|
|
1114
|
+
pairs = matched["pairs"]
|
|
1115
|
+
top_hyp = matched["top_hyp"]
|
|
1116
|
+
best = matched["best"]
|
|
1117
|
+
det_xy = matched["det_xy"]
|
|
1118
|
+
ref_uv = matched["ref_uv"]
|
|
1119
|
+
obj_sub_idx = matched["obj_sub_idx"]
|
|
1120
|
+
cat_sub_idx = matched["cat_sub_idx"]
|
|
1121
|
+
|
|
1122
|
+
# Build match list for final fit
|
|
1123
|
+
pairs_arr = np.array(pairs, dtype=np.int32)
|
|
1124
|
+
det_indices = pairs_arr[:, 0]
|
|
1125
|
+
ref_indices = pairs_arr[:, 1]
|
|
1126
|
+
|
|
1127
|
+
det_match_idx = obj_sub_idx[det_indices]
|
|
1128
|
+
ref_match_idx = cat_sub_idx[ref_indices]
|
|
1129
|
+
|
|
1130
|
+
seed_matches = int(len(det_match_idx))
|
|
1131
|
+
used_expanded = False
|
|
1132
|
+
refine_pool_det = int(len(det_match_idx))
|
|
1133
|
+
refine_pool_ref = int(len(ref_match_idx))
|
|
1134
|
+
|
|
1135
|
+
# Optional: expand refinement pool using many more detections/catalog entries
|
|
1136
|
+
if self.cfg.refine_use_all:
|
|
1137
|
+
det_pool_idx = np.arange(len(x))
|
|
1138
|
+
if self.cfg.refine_n_det is not None and self.cfg.refine_n_det > 0:
|
|
1139
|
+
det_pool_idx = np.argsort(m_obj)[: min(self.cfg.refine_n_det, len(m_obj))]
|
|
1140
|
+
ref_pool_idx = np.arange(len(ra))
|
|
1141
|
+
if self.cfg.refine_n_ref is not None and self.cfg.refine_n_ref > 0:
|
|
1142
|
+
ref_pool_idx = np.argsort(m_cat)[: min(self.cfg.refine_n_ref, len(m_cat))]
|
|
1143
|
+
|
|
1144
|
+
if len(det_pool_idx) >= 8 and len(ref_pool_idx) >= 8:
|
|
1145
|
+
det_xy_pool = np.column_stack([x[det_pool_idx], y[det_pool_idx]])
|
|
1146
|
+
# Project expanded catalog to pixel space using initial WCS
|
|
1147
|
+
ref_x_all, ref_y_all = wcs_init.all_world2pix(
|
|
1148
|
+
ra[ref_pool_idx], dec[ref_pool_idx], 0
|
|
1149
|
+
)
|
|
1150
|
+
ref_uv_all = np.column_stack([ref_x_all, ref_y_all])
|
|
1151
|
+
# Filter non-finite
|
|
1152
|
+
_ref_ok = np.all(np.isfinite(ref_uv_all), axis=1)
|
|
1153
|
+
if not np.all(_ref_ok):
|
|
1154
|
+
ref_uv_all = ref_uv_all[_ref_ok]
|
|
1155
|
+
ref_pool_idx = ref_pool_idx[_ref_ok]
|
|
1156
|
+
|
|
1157
|
+
# Use affine from last rematch iteration for better projection
|
|
1158
|
+
try:
|
|
1159
|
+
_pa = np.array(pairs, dtype=np.int32)
|
|
1160
|
+
_A = det_xy[_pa[:, 0]]
|
|
1161
|
+
_B = ref_uv[_pa[:, 1]]
|
|
1162
|
+
M_exp, t_exp = estimate_affine_2d(_A, _B)
|
|
1163
|
+
det_uv_pool = apply_affine(det_xy_pool, M_exp, t_exp)
|
|
1164
|
+
except Exception:
|
|
1165
|
+
det_uv_pool = apply_similarity(det_xy_pool, best["R"], best["t"], best["s"])
|
|
1166
|
+
|
|
1167
|
+
r_match_arcsec = self.cfg.refine_match_radius_arcsec
|
|
1168
|
+
if r_match_arcsec is None:
|
|
1169
|
+
r_match_arcsec = self.cfg.stage2_radius_arcsec
|
|
1170
|
+
r_match = float(r_match_arcsec) / pixel_scale_arcsec
|
|
1171
|
+
|
|
1172
|
+
# Mutual nearest-neighbor for expanded pool
|
|
1173
|
+
exp_det_ids, exp_ref_ids = _mutual_nearest_neighbor(
|
|
1174
|
+
det_uv_pool, ref_uv_all, r_match
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
if len(exp_det_ids) >= 8:
|
|
1178
|
+
det_match_idx = det_pool_idx[exp_det_ids]
|
|
1179
|
+
ref_match_idx = ref_pool_idx[exp_ref_ids]
|
|
1180
|
+
used_expanded = True
|
|
1181
|
+
refine_pool_det = int(len(det_pool_idx))
|
|
1182
|
+
refine_pool_ref = int(len(ref_pool_idx))
|
|
1183
|
+
|
|
1184
|
+
det_x = x[det_match_idx]
|
|
1185
|
+
det_y = y[det_match_idx]
|
|
1186
|
+
ref_ra = ra[ref_match_idx]
|
|
1187
|
+
ref_dec = dec[ref_match_idx]
|
|
1188
|
+
ref_m = m_cat[ref_match_idx]
|
|
1189
|
+
det_m = m_obj[det_match_idx]
|
|
1190
|
+
refine_matches_preclip = int(len(det_match_idx))
|
|
1191
|
+
|
|
1192
|
+
# Minimum match floor (#6): don't clip below this count
|
|
1193
|
+
min_matches = max(8, int(refine_matches_preclip * self.cfg.refine_min_match_fraction))
|
|
1194
|
+
|
|
1195
|
+
# --- Progressive sigma-clipping refinement (#6) ---
|
|
1196
|
+
sky = SkyCoord(ref_ra * u.deg, ref_dec * u.deg, frame="icrs")
|
|
1197
|
+
xy = np.vstack([det_x, det_y]) # (2,N)
|
|
1198
|
+
|
|
1199
|
+
refined_wcs = None
|
|
1200
|
+
keep = np.ones(xy.shape[1], dtype=bool)
|
|
1201
|
+
|
|
1202
|
+
# Projection template for WCS fitting (may differ from wcs_init
|
|
1203
|
+
# which is used only for the matching phase)
|
|
1204
|
+
wcs_fit_proj = fit_projection if fit_projection is not None else wcs_init
|
|
1205
|
+
|
|
1206
|
+
# Progressive clipping: interpolate from start to end sigma
|
|
1207
|
+
clip_start = self.cfg.refine_clip_sigma_start
|
|
1208
|
+
clip_end = self.cfg.refine_clip_sigma
|
|
1209
|
+
n_iter = self.cfg.refine_max_iter
|
|
1210
|
+
|
|
1211
|
+
for it in range(n_iter):
|
|
1212
|
+
# Linearly interpolate clip threshold from start (loose) to end (tight)
|
|
1213
|
+
if n_iter > 1:
|
|
1214
|
+
frac = it / (n_iter - 1)
|
|
1215
|
+
else:
|
|
1216
|
+
frac = 1.0
|
|
1217
|
+
clip_sigma = clip_start + (clip_end - clip_start) * frac
|
|
1218
|
+
|
|
1219
|
+
xy_use = xy[:, keep]
|
|
1220
|
+
sky_use = sky[keep]
|
|
1221
|
+
|
|
1222
|
+
# Wrapper handles SIP for TAN, PV for ZPN, linear for others
|
|
1223
|
+
refined_wcs = fit_wcs_from_points(
|
|
1224
|
+
xy_use,
|
|
1225
|
+
sky_use,
|
|
1226
|
+
projection=wcs_fit_proj,
|
|
1227
|
+
sip_degree=int(self.cfg.sip_degree),
|
|
1228
|
+
pv_deg=pv_deg,
|
|
1229
|
+
)
|
|
1230
|
+
wcs_fit_proj = refined_wcs
|
|
1231
|
+
|
|
1232
|
+
# Residuals via spherical distance (projection-independent)
|
|
1233
|
+
ra_fit, dec_fit = refined_wcs.all_pix2world(xy_use[0], xy_use[1], 0)
|
|
1234
|
+
ref_ra_use = sky_use.ra.deg
|
|
1235
|
+
ref_dec_use = sky_use.dec.deg
|
|
1236
|
+
cos_dec = np.cos(np.deg2rad(0.5 * (dec_fit + ref_dec_use)))
|
|
1237
|
+
# Wrap RA difference to [-180, 180) to handle fields crossing RA=0
|
|
1238
|
+
dra_deg = ((ra_fit - ref_ra_use + 180.0) % 360.0 - 180.0) * cos_dec
|
|
1239
|
+
ddec_deg = dec_fit - ref_dec_use
|
|
1240
|
+
dr = np.deg2rad(np.hypot(dra_deg, ddec_deg))
|
|
1241
|
+
|
|
1242
|
+
sig = _robust_sigma(dr)
|
|
1243
|
+
if not np.isfinite(sig) or sig <= 0:
|
|
1244
|
+
break
|
|
1245
|
+
|
|
1246
|
+
thresh = clip_sigma * sig
|
|
1247
|
+
dr_full = np.full(keep.shape[0], np.nan, dtype=np.float64)
|
|
1248
|
+
dr_full[keep] = dr
|
|
1249
|
+
keep_new = keep & np.isfinite(dr_full) & (dr_full < thresh)
|
|
1250
|
+
|
|
1251
|
+
# Enforce minimum match floor (#6)
|
|
1252
|
+
if keep_new.sum() < min_matches:
|
|
1253
|
+
# Don't clip further if we'd go below the minimum.
|
|
1254
|
+
# Keep the current inlier set instead of partially applying
|
|
1255
|
+
# a clip that violates the configured floor.
|
|
1256
|
+
break
|
|
1257
|
+
|
|
1258
|
+
if keep_new.sum() == keep.sum():
|
|
1259
|
+
keep = keep_new
|
|
1260
|
+
break
|
|
1261
|
+
keep = keep_new
|
|
1262
|
+
|
|
1263
|
+
if refined_wcs is None:
|
|
1264
|
+
raise RuntimeError("WCS refinement failed in fit_wcs_from_points stage.")
|
|
1265
|
+
|
|
1266
|
+
# Output match table
|
|
1267
|
+
final_idx = np.nonzero(keep)[0]
|
|
1268
|
+
match = Table()
|
|
1269
|
+
match["x"] = det_x[final_idx]
|
|
1270
|
+
match["y"] = det_y[final_idx]
|
|
1271
|
+
match["obj_mag"] = det_m[final_idx]
|
|
1272
|
+
match["ra"] = ref_ra[final_idx]
|
|
1273
|
+
match["dec"] = ref_dec[final_idx]
|
|
1274
|
+
match["cat_mag"] = ref_m[final_idx]
|
|
1275
|
+
|
|
1276
|
+
# Residuals in arcsec using final WCS (spherical, projection-independent)
|
|
1277
|
+
ra_fit, dec_fit = refined_wcs.all_pix2world(match["x"], match["y"], 0)
|
|
1278
|
+
cos_dec_out = np.cos(np.deg2rad(0.5 * (dec_fit + _as_float_array(match["dec"]))))
|
|
1279
|
+
dra_out = (ra_fit - _as_float_array(match["ra"]) + 180.0) % 360.0 - 180.0
|
|
1280
|
+
match["du_arcsec"] = dra_out * cos_dec_out * 3600.0
|
|
1281
|
+
match["dv_arcsec"] = (dec_fit - _as_float_array(match["dec"])) * 3600.0
|
|
1282
|
+
match["dr_arcsec"] = np.hypot(match["du_arcsec"], match["dv_arcsec"])
|
|
1283
|
+
|
|
1284
|
+
diagnostics = {
|
|
1285
|
+
"rough_center_deg": (float(ra0), float(dec0)),
|
|
1286
|
+
"stage1_hypotheses_kept": int(len(top_hyp)),
|
|
1287
|
+
"pattern_inliers_stage2": int(best["inliers"]),
|
|
1288
|
+
"seed_matches": int(seed_matches),
|
|
1289
|
+
"refine_used_expanded": bool(used_expanded),
|
|
1290
|
+
"refine_pool_det": int(refine_pool_det),
|
|
1291
|
+
"refine_pool_ref": int(refine_pool_ref),
|
|
1292
|
+
"refine_matches_preclip": int(refine_matches_preclip),
|
|
1293
|
+
"final_matches": int(len(match)),
|
|
1294
|
+
"rms_dr_arcsec": float(np.sqrt(np.mean(match["dr_arcsec"] ** 2)))
|
|
1295
|
+
if len(match)
|
|
1296
|
+
else np.nan,
|
|
1297
|
+
"mad_dr_arcsec": float(_robust_sigma(_as_float_array(match["dr_arcsec"])))
|
|
1298
|
+
if len(match)
|
|
1299
|
+
else np.nan,
|
|
1300
|
+
"config": self.cfg,
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
return refined_wcs, match, diagnostics
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def refine_wcs_quadhash(
|
|
1307
|
+
obj: Table,
|
|
1308
|
+
cat: Table,
|
|
1309
|
+
wcs: WCS = None,
|
|
1310
|
+
header=None,
|
|
1311
|
+
sr: float = 10 / 3600,
|
|
1312
|
+
order: int = 2,
|
|
1313
|
+
projection: str = None,
|
|
1314
|
+
pv_deg: int = 5,
|
|
1315
|
+
cat_col_ra: str = 'RAJ2000',
|
|
1316
|
+
cat_col_dec: str = 'DEJ2000',
|
|
1317
|
+
cat_col_mag: str = 'rmag',
|
|
1318
|
+
obj_col_mag: str = None,
|
|
1319
|
+
obj_col_flux: str = 'flux',
|
|
1320
|
+
sn: float = None,
|
|
1321
|
+
n_det: int = 150,
|
|
1322
|
+
n_ref: int = 600,
|
|
1323
|
+
max_quads_tested: int = 8000,
|
|
1324
|
+
allow_reflection: bool = True,
|
|
1325
|
+
use_wcs_prior: bool = True,
|
|
1326
|
+
scale_tolerance: float = 0.30,
|
|
1327
|
+
enforce_parity: bool = True,
|
|
1328
|
+
refine_use_all: bool = False,
|
|
1329
|
+
refine_n_det: Optional[int] = None,
|
|
1330
|
+
refine_n_ref: Optional[int] = None,
|
|
1331
|
+
refine_match_radius_arcsec: Optional[float] = None,
|
|
1332
|
+
get_header: bool = False,
|
|
1333
|
+
update: bool = False,
|
|
1334
|
+
verbose: bool = False,
|
|
1335
|
+
) -> WCS:
|
|
1336
|
+
"""Refine WCS using quad-hash pattern matching algorithm.
|
|
1337
|
+
|
|
1338
|
+
Pure Python implementation of astrometric refinement using quad-hash based
|
|
1339
|
+
pattern matching with no external dependencies (only numpy, scipy, astropy).
|
|
1340
|
+
Typically achieves sub-arcsecond accuracy.
|
|
1341
|
+
|
|
1342
|
+
Parameters
|
|
1343
|
+
----------
|
|
1344
|
+
obj : astropy.table.Table
|
|
1345
|
+
List of objects on the frame, must contain at least ``x``, ``y``, and
|
|
1346
|
+
either ``flux`` or ``mag`` columns.
|
|
1347
|
+
cat : astropy.table.Table
|
|
1348
|
+
Reference astrometric catalogue with RA, Dec, and magnitude columns.
|
|
1349
|
+
wcs : astropy.wcs.WCS, optional
|
|
1350
|
+
Initial WCS solution (rough estimate).
|
|
1351
|
+
header : astropy.io.fits.Header, optional
|
|
1352
|
+
FITS header containing the initial astrometric solution (alternative to ``wcs``).
|
|
1353
|
+
sr : float, optional
|
|
1354
|
+
Matching radius in degrees for stage 2 matching.
|
|
1355
|
+
order : int, optional
|
|
1356
|
+
SIP polynomial order for the distortion solution (0–5).
|
|
1357
|
+
For TAN projection, controls the SIP polynomial degree.
|
|
1358
|
+
For ZPN projection, controls additional SIP corrections on top
|
|
1359
|
+
of the PV radial model (0 means ZPN-only, 2 is recommended for
|
|
1360
|
+
wide-field images). Ignored for other projections.
|
|
1361
|
+
projection : str or None, optional
|
|
1362
|
+
Target projection type for the output WCS. If ``None`` (default),
|
|
1363
|
+
uses the same projection as the input WCS. Supported values:
|
|
1364
|
+
|
|
1365
|
+
- ``'TAN'`` — gnomonic (TAN) with SIP distortion polynomials
|
|
1366
|
+
- ``'ZPN'`` — zenithal polynomial with PV radial terms (+ optional
|
|
1367
|
+
SIP for non-radial corrections). Best for wide-field images
|
|
1368
|
+
(FoV > 5°) as ZPN naturally handles radial distortion that
|
|
1369
|
+
TAN-SIP struggles with.
|
|
1370
|
+
- Any other FITS WCS projection code supported by astropy
|
|
1371
|
+
(``'STG'``, ``'ARC'``, ``'ZEA'``, ``'SIN'``, etc.) — linear
|
|
1372
|
+
WCS fit only (no distortion polynomials).
|
|
1373
|
+
|
|
1374
|
+
The initial WCS (``wcs``) is always used for the pattern matching
|
|
1375
|
+
phase; the projection conversion only affects the final WCS fit.
|
|
1376
|
+
pv_deg : int, optional
|
|
1377
|
+
ZPN PV polynomial degree (``PV2_0`` … ``PV2_pv_deg``). Only used
|
|
1378
|
+
when ``projection='ZPN'``. Default is 5, which is sufficient for
|
|
1379
|
+
most optical systems.
|
|
1380
|
+
cat_col_ra : str, optional
|
|
1381
|
+
Catalogue column name for Right Ascension. Default is ``'RAJ2000'``.
|
|
1382
|
+
cat_col_dec : str, optional
|
|
1383
|
+
Catalogue column name for Declination. Default is ``'DEJ2000'``.
|
|
1384
|
+
cat_col_mag : str, optional
|
|
1385
|
+
Catalogue column name for magnitude. Default is ``'rmag'``.
|
|
1386
|
+
obj_col_mag : str, optional
|
|
1387
|
+
Object list column name for magnitude. Auto-detected if not provided.
|
|
1388
|
+
obj_col_flux : str, optional
|
|
1389
|
+
Object list column name for flux. Used if magnitude column is absent.
|
|
1390
|
+
Default is ``'flux'``.
|
|
1391
|
+
sn : float, optional
|
|
1392
|
+
If provided, only objects with S/N exceeding this value will be used.
|
|
1393
|
+
n_det : int, optional
|
|
1394
|
+
Number of brightest detections to use for matching. Default is 150.
|
|
1395
|
+
n_ref : int, optional
|
|
1396
|
+
Number of brightest catalogue stars to use for matching. Default is 600.
|
|
1397
|
+
max_quads_tested : int, optional
|
|
1398
|
+
Maximum number of detection quads to test. Default is 8000.
|
|
1399
|
+
allow_reflection : bool, optional
|
|
1400
|
+
Allow reflected quad matches. Default is True.
|
|
1401
|
+
use_wcs_prior : bool, optional
|
|
1402
|
+
Filter transform hypotheses by scale (close to 1) and parity consistency
|
|
1403
|
+
in pixel space. Default is True.
|
|
1404
|
+
scale_tolerance : float, optional
|
|
1405
|
+
Fractional tolerance for scale filtering. Default is 0.30 (±30%).
|
|
1406
|
+
enforce_parity : bool, optional
|
|
1407
|
+
Enforce parity consistency with the initial WCS. Default is True.
|
|
1408
|
+
refine_use_all : bool, optional
|
|
1409
|
+
If True, expand the final refinement to a larger pool of objects/catalogue entries.
|
|
1410
|
+
refine_n_det : int or None, optional
|
|
1411
|
+
Max number of detections in the expanded refinement pool. None means all.
|
|
1412
|
+
refine_n_ref : int or None, optional
|
|
1413
|
+
Max number of catalogue stars in the expanded refinement pool. None means all.
|
|
1414
|
+
refine_match_radius_arcsec : float or None, optional
|
|
1415
|
+
Matching radius in arcsec for the expanded refinement pool.
|
|
1416
|
+
Defaults to the stage 2 radius.
|
|
1417
|
+
get_header : bool, optional
|
|
1418
|
+
If True, return the FITS header object instead of WCS solution.
|
|
1419
|
+
update : bool, optional
|
|
1420
|
+
If True, update object sky coordinates in-place using the refined WCS.
|
|
1421
|
+
verbose : bool or callable, optional
|
|
1422
|
+
Whether to show verbose messages. May be boolean or a ``print``-like callable.
|
|
1423
|
+
|
|
1424
|
+
Returns
|
|
1425
|
+
-------
|
|
1426
|
+
astropy.wcs.WCS or astropy.io.fits.Header or None
|
|
1427
|
+
Refined WCS solution, or FITS header if ``get_header=True``, or None on failure.
|
|
1428
|
+
|
|
1429
|
+
Examples
|
|
1430
|
+
--------
|
|
1431
|
+
>>> from stdpipe.astrometry_quad import refine_wcs_quadhash
|
|
1432
|
+
>>> wcs_refined = refine_wcs_quadhash(
|
|
1433
|
+
... obj, cat, wcs_init,
|
|
1434
|
+
... sr=10/3600, # 10 arcsec matching radius
|
|
1435
|
+
... order=2, # quadratic SIP distortion
|
|
1436
|
+
... sn=5, # use only S/N > 5 objects
|
|
1437
|
+
... verbose=True
|
|
1438
|
+
... )
|
|
1439
|
+
|
|
1440
|
+
For wide-field images (FoV > 5 degrees), ZPN projection with SIP
|
|
1441
|
+
corrections typically gives lower systematic residuals at field edges:
|
|
1442
|
+
|
|
1443
|
+
>>> wcs_refined = refine_wcs_quadhash(
|
|
1444
|
+
... obj, cat, wcs_init,
|
|
1445
|
+
... sr=30/3600,
|
|
1446
|
+
... order=2, # SIP order on top of ZPN radial model
|
|
1447
|
+
... projection='ZPN', # zenithal polynomial projection
|
|
1448
|
+
... pv_deg=5, # PV radial polynomial degree
|
|
1449
|
+
... verbose=True
|
|
1450
|
+
... )
|
|
1451
|
+
"""
|
|
1452
|
+
|
|
1453
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
1454
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
1455
|
+
|
|
1456
|
+
# Get WCS from header if needed
|
|
1457
|
+
if wcs is None and header is not None:
|
|
1458
|
+
wcs = WCS(header)
|
|
1459
|
+
|
|
1460
|
+
if wcs is None or not wcs.is_celestial:
|
|
1461
|
+
log("Error: Valid initial WCS required")
|
|
1462
|
+
return None
|
|
1463
|
+
|
|
1464
|
+
# Prepare catalog columns
|
|
1465
|
+
if cat_col_ra not in cat.colnames or cat_col_dec not in cat.colnames:
|
|
1466
|
+
log(f"Error: Catalog must contain '{cat_col_ra}' and '{cat_col_dec}' columns")
|
|
1467
|
+
return None
|
|
1468
|
+
|
|
1469
|
+
if cat_col_mag not in cat.colnames:
|
|
1470
|
+
log(
|
|
1471
|
+
f"Warning: Catalog magnitude column '{cat_col_mag}' not found, using first magnitude column"
|
|
1472
|
+
)
|
|
1473
|
+
# Try to find any magnitude column
|
|
1474
|
+
mag_cols = [c for c in cat.colnames if 'mag' in c.lower()]
|
|
1475
|
+
if mag_cols:
|
|
1476
|
+
cat_col_mag = mag_cols[0]
|
|
1477
|
+
log(f" Using '{cat_col_mag}' as magnitude column")
|
|
1478
|
+
else:
|
|
1479
|
+
log("Error: No magnitude column found in catalog")
|
|
1480
|
+
return None
|
|
1481
|
+
|
|
1482
|
+
# Prepare object columns
|
|
1483
|
+
if obj_col_mag is None:
|
|
1484
|
+
# Auto-detect magnitude column
|
|
1485
|
+
if 'mag' in obj.colnames:
|
|
1486
|
+
obj_col_mag = 'mag'
|
|
1487
|
+
else:
|
|
1488
|
+
obj_col_mag = None
|
|
1489
|
+
|
|
1490
|
+
has_mag = obj_col_mag is not None and obj_col_mag in obj.colnames
|
|
1491
|
+
has_flux = obj_col_flux in obj.colnames
|
|
1492
|
+
|
|
1493
|
+
if not has_mag and not has_flux:
|
|
1494
|
+
log(
|
|
1495
|
+
f"Error: Object list must contain either '{obj_col_mag or 'mag'}' or '{obj_col_flux}' column"
|
|
1496
|
+
)
|
|
1497
|
+
return None
|
|
1498
|
+
|
|
1499
|
+
# Apply S/N filtering if requested
|
|
1500
|
+
obj_filtered = obj
|
|
1501
|
+
if sn is not None:
|
|
1502
|
+
if 'fluxerr' in obj.colnames or 'flux_err' in obj.colnames:
|
|
1503
|
+
err_col = 'fluxerr' if 'fluxerr' in obj.colnames else 'flux_err'
|
|
1504
|
+
flux_col = obj_col_flux if has_flux else None
|
|
1505
|
+
|
|
1506
|
+
if flux_col and flux_col in obj.colnames:
|
|
1507
|
+
obj_sn = obj[flux_col] / obj[err_col]
|
|
1508
|
+
mask = obj_sn > sn
|
|
1509
|
+
obj_filtered = obj[mask]
|
|
1510
|
+
log(f"S/N filtering: {len(obj_filtered)}/{len(obj)} objects with S/N > {sn}")
|
|
1511
|
+
else:
|
|
1512
|
+
log(f"Warning: Cannot apply S/N filter - flux column '{flux_col}' not found")
|
|
1513
|
+
else:
|
|
1514
|
+
log("Warning: Cannot apply S/N filter - no flux error column found")
|
|
1515
|
+
|
|
1516
|
+
# Create standardized tables with required columns
|
|
1517
|
+
cat_std = Table()
|
|
1518
|
+
cat_std['ra'] = cat[cat_col_ra]
|
|
1519
|
+
cat_std['dec'] = cat[cat_col_dec]
|
|
1520
|
+
cat_std['mag'] = cat[cat_col_mag]
|
|
1521
|
+
|
|
1522
|
+
obj_std = Table()
|
|
1523
|
+
obj_std['x'] = obj_filtered['x']
|
|
1524
|
+
obj_std['y'] = obj_filtered['y']
|
|
1525
|
+
|
|
1526
|
+
if has_mag:
|
|
1527
|
+
obj_std['mag'] = obj_filtered[obj_col_mag]
|
|
1528
|
+
if has_flux:
|
|
1529
|
+
obj_std['flux'] = obj_filtered[obj_col_flux]
|
|
1530
|
+
|
|
1531
|
+
# Configure refinement
|
|
1532
|
+
# Compute pixel scale from input WCS for scale-aware defaults
|
|
1533
|
+
try:
|
|
1534
|
+
_pscales = wcs.proj_plane_pixel_scales()
|
|
1535
|
+
if hasattr(_pscales[0], 'to_value'):
|
|
1536
|
+
_pixel_scale_deg = float(np.mean([s.to_value(u.deg) for s in _pscales]))
|
|
1537
|
+
else:
|
|
1538
|
+
_pixel_scale_deg = float(np.mean(_pscales))
|
|
1539
|
+
pixel_scale_arcsec = _pixel_scale_deg * 3600.0
|
|
1540
|
+
except Exception:
|
|
1541
|
+
pixel_scale_arcsec = 1.0 # fallback: assume 1 arcsec/pixel
|
|
1542
|
+
_pixel_scale_deg = pixel_scale_arcsec / 3600.0
|
|
1543
|
+
|
|
1544
|
+
# Estimate FOV diagonal in degrees for scaling source counts
|
|
1545
|
+
if wcs.pixel_shape:
|
|
1546
|
+
nx, ny = wcs.pixel_shape
|
|
1547
|
+
else:
|
|
1548
|
+
nx = ny = 2048
|
|
1549
|
+
log("Warning: WCS has no pixel shape, assuming 2048x2048 for FOV-based scaling")
|
|
1550
|
+
fov_diag_deg = np.hypot(nx, ny) * _pixel_scale_deg
|
|
1551
|
+
|
|
1552
|
+
# Scale-aware matching radii: ensure at least a few pixels even for coarse plates
|
|
1553
|
+
sr_arcsec = sr * 3600
|
|
1554
|
+
stage2_arcsec = max(sr_arcsec, 2.0 * pixel_scale_arcsec)
|
|
1555
|
+
stage1_arcsec = max(sr_arcsec * 2, 3.0 * pixel_scale_arcsec)
|
|
1556
|
+
|
|
1557
|
+
# Scale n_det/n_ref with FOV: for larger fields use more sources
|
|
1558
|
+
# Base: n_det=150, n_ref=600 for a ~0.5 deg field; scale with sqrt(area)
|
|
1559
|
+
fov_scale = max(1.0, fov_diag_deg / 0.5)
|
|
1560
|
+
scaled_n_det = min(int(n_det * np.sqrt(fov_scale)), len(obj_std))
|
|
1561
|
+
scaled_n_ref = min(int(n_ref * np.sqrt(fov_scale)), len(cat_std))
|
|
1562
|
+
|
|
1563
|
+
log("Starting quad-hash WCS refinement...")
|
|
1564
|
+
log(f" Catalog: {len(cat_std)} stars")
|
|
1565
|
+
log(f" Detections: {len(obj_std)} objects")
|
|
1566
|
+
log(f" Initial WCS center: RA={wcs.wcs.crval[0]:.6f}, Dec={wcs.wcs.crval[1]:.6f}")
|
|
1567
|
+
log(
|
|
1568
|
+
f" Pixel scale: {pixel_scale_arcsec:.2f} arcsec/pixel, FOV diagonal: {fov_diag_deg:.2f} deg"
|
|
1569
|
+
)
|
|
1570
|
+
log(
|
|
1571
|
+
f" Matching radii: stage1={stage1_arcsec:.1f} arcsec ({stage1_arcsec / pixel_scale_arcsec:.1f} pix), "
|
|
1572
|
+
f"stage2={stage2_arcsec:.1f} arcsec ({stage2_arcsec / pixel_scale_arcsec:.1f} pix)"
|
|
1573
|
+
)
|
|
1574
|
+
log(f" Source counts: n_det={scaled_n_det}, n_ref={scaled_n_ref}")
|
|
1575
|
+
log(f" SIP order: {order}")
|
|
1576
|
+
|
|
1577
|
+
# Build projection template for the WCS fitting phase
|
|
1578
|
+
fit_projection = None
|
|
1579
|
+
if projection is not None:
|
|
1580
|
+
from stdpipe.astrometry_wcs import convert_wcs_projection
|
|
1581
|
+
|
|
1582
|
+
proj_code = projection.upper().strip()
|
|
1583
|
+
fit_projection = convert_wcs_projection(wcs, proj_code, pv_deg=pv_deg)
|
|
1584
|
+
log(f" Output projection: {proj_code} (fit CTYPE: {fit_projection.wcs.ctype})")
|
|
1585
|
+
|
|
1586
|
+
if proj_code == 'ZPN':
|
|
1587
|
+
log(f" ZPN PV degree: {pv_deg}")
|
|
1588
|
+
elif proj_code != 'TAN' and order > 0:
|
|
1589
|
+
log(f" Note: SIP order={order} ignored for {proj_code} projection (linear fit only)")
|
|
1590
|
+
|
|
1591
|
+
config = AstrometryConfig(
|
|
1592
|
+
n_det=scaled_n_det,
|
|
1593
|
+
n_ref=scaled_n_ref,
|
|
1594
|
+
stage1_radius_arcsec=stage1_arcsec,
|
|
1595
|
+
stage2_radius_arcsec=stage2_arcsec,
|
|
1596
|
+
max_quads_tested=max_quads_tested,
|
|
1597
|
+
allow_reflection=allow_reflection,
|
|
1598
|
+
use_wcs_prior=use_wcs_prior,
|
|
1599
|
+
scale_tolerance=scale_tolerance,
|
|
1600
|
+
enforce_parity=enforce_parity,
|
|
1601
|
+
sip_degree=max(0, min(5, order)), # Clamp to 0-5
|
|
1602
|
+
refine_use_all=refine_use_all,
|
|
1603
|
+
refine_n_det=refine_n_det,
|
|
1604
|
+
refine_n_ref=refine_n_ref,
|
|
1605
|
+
refine_match_radius_arcsec=refine_match_radius_arcsec,
|
|
1606
|
+
)
|
|
1607
|
+
|
|
1608
|
+
try:
|
|
1609
|
+
solver = QuadHashAstrometry(config=config)
|
|
1610
|
+
wcs_refined, match, diagnostics = solver.refine(
|
|
1611
|
+
obj=obj_std, cat=cat_std, wcs_init=wcs,
|
|
1612
|
+
fit_projection=fit_projection, pv_deg=pv_deg,
|
|
1613
|
+
)
|
|
1614
|
+
|
|
1615
|
+
log(f"Refinement successful:")
|
|
1616
|
+
log(f" Pattern matches: {diagnostics['pattern_inliers_stage2']}")
|
|
1617
|
+
log(f" Final matches: {diagnostics['final_matches']}")
|
|
1618
|
+
log(f" RMS residual: {diagnostics['rms_dr_arcsec']:.3f} arcsec")
|
|
1619
|
+
log(f" MAD residual: {diagnostics['mad_dr_arcsec']:.3f} arcsec")
|
|
1620
|
+
log(
|
|
1621
|
+
f" Refined WCS center: RA={wcs_refined.wcs.crval[0]:.6f}, Dec={wcs_refined.wcs.crval[1]:.6f}"
|
|
1622
|
+
)
|
|
1623
|
+
|
|
1624
|
+
# Update object table with sky coordinates if requested
|
|
1625
|
+
if update and wcs_refined is not None:
|
|
1626
|
+
ra_obj, dec_obj = wcs_refined.all_pix2world(obj['x'], obj['y'], 0)
|
|
1627
|
+
obj['ra'] = ra_obj
|
|
1628
|
+
obj['dec'] = dec_obj
|
|
1629
|
+
log(f"Updated object table with ra/dec coordinates")
|
|
1630
|
+
|
|
1631
|
+
# Return header if requested
|
|
1632
|
+
if get_header and wcs_refined is not None:
|
|
1633
|
+
return wcs_refined.to_header(relax=True)
|
|
1634
|
+
|
|
1635
|
+
return wcs_refined
|
|
1636
|
+
|
|
1637
|
+
except Exception as e:
|
|
1638
|
+
log(f"Refinement failed: {type(e).__name__}: {e}")
|
|
1639
|
+
if verbose:
|
|
1640
|
+
import traceback
|
|
1641
|
+
|
|
1642
|
+
traceback.print_exc()
|
|
1643
|
+
return None
|