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
stdpipe/sfft.py
ADDED
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SFFT — Space-Frequency Fourier Transform image subtraction.
|
|
3
|
+
|
|
4
|
+
Implements spatially varying kernel fitting in a single global least-squares
|
|
5
|
+
solve, following the approach of Hu et al. (2022, ApJ, 936, 157).
|
|
6
|
+
|
|
7
|
+
The model is::
|
|
8
|
+
|
|
9
|
+
science(x,y) = Σ_α Σ_β c_{α,β} · [P_β(x,y) · R_α(x,y)] + Σ_γ d_γ · P_γ(x,y)
|
|
10
|
+
|
|
11
|
+
where R_α is the reference shifted by kernel offset α, P_β are polynomial
|
|
12
|
+
basis functions encoding spatial variation, and c_{α,β} / d_γ are scalar
|
|
13
|
+
coefficients solved for globally.
|
|
14
|
+
|
|
15
|
+
The normal equations are assembled via batched BLAS matmuls over polynomial
|
|
16
|
+
term pairs, avoiding materializing the full design matrix.
|
|
17
|
+
|
|
18
|
+
A soft kernel-sum constraint enforces that Σ_α a_α(x,y) = f(x,y) where f is
|
|
19
|
+
a low-order polynomial modelling smooth flux-scale variation across the image.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from scipy import linalg as scipy_linalg
|
|
25
|
+
from typing import Tuple
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Data containers
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class SFFTResult:
|
|
35
|
+
"""Result of SFFT image subtraction."""
|
|
36
|
+
|
|
37
|
+
diff: np.ndarray
|
|
38
|
+
"""Difference image (science - model)."""
|
|
39
|
+
|
|
40
|
+
model: np.ndarray
|
|
41
|
+
"""Convolved reference + background model."""
|
|
42
|
+
|
|
43
|
+
kernel_coeffs: np.ndarray
|
|
44
|
+
"""Kernel coefficients, shape (n_kernel, n_kpoly)."""
|
|
45
|
+
|
|
46
|
+
bg_coeffs: np.ndarray
|
|
47
|
+
"""Background coefficients, shape (n_bgpoly,)."""
|
|
48
|
+
|
|
49
|
+
kernel_shape: Tuple[int, int]
|
|
50
|
+
"""Kernel support size (ky, kx)."""
|
|
51
|
+
|
|
52
|
+
kernel_poly_order: int
|
|
53
|
+
"""Polynomial order for kernel spatial variation."""
|
|
54
|
+
|
|
55
|
+
bg_poly_order: int
|
|
56
|
+
"""Polynomial order for differential background."""
|
|
57
|
+
|
|
58
|
+
flux_poly_order: int
|
|
59
|
+
"""Polynomial order for the kernel-sum (flux scale) constraint."""
|
|
60
|
+
|
|
61
|
+
flux_poly_coeffs: np.ndarray
|
|
62
|
+
"""Fitted flux-scale polynomial coefficients."""
|
|
63
|
+
|
|
64
|
+
n_iter: int
|
|
65
|
+
"""Number of solver passes performed (sigma-clipping iterations plus
|
|
66
|
+
the template-noise reweighting pass, if any)."""
|
|
67
|
+
|
|
68
|
+
rms: float
|
|
69
|
+
"""Final RMS of residuals over good (unmasked, unclipped) pixels."""
|
|
70
|
+
|
|
71
|
+
n_good: int
|
|
72
|
+
"""Number of good (unmasked, unclipped) pixels in final iteration."""
|
|
73
|
+
|
|
74
|
+
dmask: np.ndarray = None
|
|
75
|
+
"""Boolean mask (True = unreliable) of difference pixels whose model is
|
|
76
|
+
built from missing template data: image edges and pixels within the
|
|
77
|
+
kernel footprint of template defects (NaN/Inf or ``template_mask``)."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Polynomial helpers
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _poly_terms_2d(x, y, order):
|
|
86
|
+
"""Triangular 2-D polynomial basis evaluated on coordinate arrays.
|
|
87
|
+
|
|
88
|
+
Term ordering: (0,0), (1,0), (0,1), (2,0), (1,1), (0,2), ...
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
x, y : ndarray, same shape (any)
|
|
93
|
+
order : int >= 0
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
terms : ndarray, shape (n_terms, *x.shape)
|
|
98
|
+
"""
|
|
99
|
+
terms = []
|
|
100
|
+
for total in range(order + 1):
|
|
101
|
+
for px in range(total + 1):
|
|
102
|
+
py = total - px
|
|
103
|
+
terms.append(x**px * y**py)
|
|
104
|
+
return np.array(terms, dtype=np.float64)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _n_poly(order):
|
|
108
|
+
"""Number of terms in a 2-D triangular polynomial of given order."""
|
|
109
|
+
return (order + 1) * (order + 2) // 2
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _norm_coords(ny, nx):
|
|
113
|
+
"""Pixel coordinate arrays normalized to [-1, 1]."""
|
|
114
|
+
yy, xx = np.indices((ny, nx), dtype=np.float64)
|
|
115
|
+
cx, cy = 0.5 * (nx - 1), 0.5 * (ny - 1)
|
|
116
|
+
sx = max(1.0, cx)
|
|
117
|
+
sy = max(1.0, cy)
|
|
118
|
+
return (xx - cx) / sx, (yy - cy) / sy
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _norm_xy(x, y, image_shape):
|
|
122
|
+
"""Normalize pixel coordinate value(s) to [-1, 1], matching :func:`_norm_coords`."""
|
|
123
|
+
ny, nx = image_shape[:2]
|
|
124
|
+
xn = (np.asarray(x, dtype=np.float64) - 0.5 * (nx - 1)) / max(1.0, 0.5 * (nx - 1))
|
|
125
|
+
yn = (np.asarray(y, dtype=np.float64) - 0.5 * (ny - 1)) / max(1.0, 0.5 * (ny - 1))
|
|
126
|
+
return xn, yn
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Kernel offset helpers
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _kernel_offsets(ky, kx):
|
|
135
|
+
"""List of (dy, dx) offsets for a kernel of shape (ky, kx). Both must be odd."""
|
|
136
|
+
if ky % 2 == 0 or kx % 2 == 0:
|
|
137
|
+
raise ValueError("kernel dimensions must be odd, got (%d, %d)" % (ky, kx))
|
|
138
|
+
hy, hx = ky // 2, kx // 2
|
|
139
|
+
return [(dy, dx) for dy in range(-hy, hy + 1) for dx in range(-hx, hx + 1)]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _shift_image(img, dy, dx):
|
|
143
|
+
"""Shift 2-D array by integer (dy, dx), zero-filling exposed edges."""
|
|
144
|
+
ny, nx = img.shape
|
|
145
|
+
out = np.zeros_like(img)
|
|
146
|
+
sy0, sy1 = max(0, -dy), min(ny, ny - dy)
|
|
147
|
+
dy0 = sy0 + dy
|
|
148
|
+
sx0, sx1 = max(0, -dx), min(nx, nx - dx)
|
|
149
|
+
dx0 = sx0 + dx
|
|
150
|
+
if sy1 > sy0 and sx1 > sx0:
|
|
151
|
+
out[dy0 : dy0 + (sy1 - sy0), dx0 : dx0 + (sx1 - sx0)] = img[sy0:sy1, sx0:sx1]
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _shift_band(img, dy, dx, r0, r1, out):
|
|
156
|
+
"""Rows ``r0:r1`` of ``_shift_image(img, dy, dx)``, written into ``out``.
|
|
157
|
+
|
|
158
|
+
``out`` must be a 2-D array of shape (r1 - r0, nx). Used to assemble
|
|
159
|
+
the normal equations in row bands without materializing full shifted
|
|
160
|
+
copies of the image.
|
|
161
|
+
"""
|
|
162
|
+
ny, nx = img.shape
|
|
163
|
+
out[:] = 0.0
|
|
164
|
+
# Valid output rows of the full shift are [max(0, dy), min(ny, ny + dy))
|
|
165
|
+
y0 = max(r0, dy)
|
|
166
|
+
y1 = min(r1, ny + dy)
|
|
167
|
+
sx0, sx1 = max(0, -dx), min(nx, nx - dx)
|
|
168
|
+
dx0 = sx0 + dx
|
|
169
|
+
if y1 > y0 and sx1 > sx0:
|
|
170
|
+
out[y0 - r0 : y1 - r0, dx0 : dx0 + (sx1 - sx0)] = img[y0 - dy : y1 - dy, sx0:sx1]
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Core SFFT solve
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# Target size (in elements) for the per-band (n_kernel, band_pix) work arrays
|
|
180
|
+
# used by _assemble_normal_equations: 2**25 float64 elements = 256 MB.
|
|
181
|
+
_ASSEMBLE_BAND_ELEMENTS = 2**25
|
|
182
|
+
|
|
183
|
+
# Downdate the accumulated normal equations by the clipped pixels'
|
|
184
|
+
# contributions instead of reassembling from scratch after each
|
|
185
|
+
# sigma-clipping iteration. Exact up to floating-point roundoff; the flag
|
|
186
|
+
# exists for testing and debugging.
|
|
187
|
+
_USE_INCREMENTAL_UPDATES = True
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _accumulate_normal_blocks(H, g, srefs, w_c, s_c, pk_c, pbg_c, n_kernel_params):
|
|
191
|
+
"""Accumulate a pixel subset's contributions into H (upper blocks) and g.
|
|
192
|
+
|
|
193
|
+
Every term is linear in the pixel weight ``w_c``, so calling this with
|
|
194
|
+
negated weights exactly subtracts (downdates) previously accumulated
|
|
195
|
+
contributions.
|
|
196
|
+
|
|
197
|
+
Only the upper triangle of the kernel-kernel block structure (b1 <= b2
|
|
198
|
+
polynomial pairs) and the kernel→background cross rows are filled;
|
|
199
|
+
use :func:`_mirror_normal_matrix` to complete the symmetric matrix.
|
|
200
|
+
|
|
201
|
+
Parameters
|
|
202
|
+
----------
|
|
203
|
+
H : (n_total, n_total), modified in place
|
|
204
|
+
g : (n_total,), modified in place
|
|
205
|
+
srefs : (n_kernel, n_pix) shifted reference values at the subset pixels
|
|
206
|
+
w_c : (n_pix,) pixel weights (may be negative for downdating)
|
|
207
|
+
s_c : (n_pix,) science values
|
|
208
|
+
pk_c : (n_kpoly, n_pix) kernel polynomial terms at the subset pixels
|
|
209
|
+
pbg_c : (n_bgpoly, n_pix) background polynomial terms at the subset pixels
|
|
210
|
+
n_kernel_params : number of kernel parameters (offset × poly terms)
|
|
211
|
+
"""
|
|
212
|
+
n_kpoly = pk_c.shape[0]
|
|
213
|
+
|
|
214
|
+
with np.errstate(over='ignore', invalid='ignore', divide='ignore'):
|
|
215
|
+
# === Background-background block ===
|
|
216
|
+
w_poly_bg = pbg_c * w_c[np.newaxis, :] # (n_bgpoly, n_pix)
|
|
217
|
+
H[n_kernel_params:, n_kernel_params:] += w_poly_bg @ pbg_c.T
|
|
218
|
+
g[n_kernel_params:] += w_poly_bg @ s_c
|
|
219
|
+
|
|
220
|
+
# === Kernel RHS and kernel-background cross-block ===
|
|
221
|
+
# g[α*n_kpoly + β] = Σ_pix w * P_β * R_α * S
|
|
222
|
+
# H[α*n_kpoly+β, n_kernel_params+γ] = Σ_pix w * P_β * R_α * Q_γ
|
|
223
|
+
ws_c = w_c * s_c
|
|
224
|
+
for b in range(n_kpoly):
|
|
225
|
+
g[b:n_kernel_params:n_kpoly] += srefs @ (ws_c * pk_c[b])
|
|
226
|
+
wPb_Q = pbg_c * (w_c * pk_c[b])[np.newaxis, :] # (n_bgpoly, n_pix)
|
|
227
|
+
# Rows α*n_kpoly+b for all α form the strided slice below
|
|
228
|
+
H[b:n_kernel_params:n_kpoly, n_kernel_params:] += srefs @ wPb_Q.T
|
|
229
|
+
|
|
230
|
+
# === Kernel-kernel block (the main bottleneck) ===
|
|
231
|
+
# H[α1*n_kpoly+β1, α2*n_kpoly+β2] = Σ_pix w * P_β1 * R_α1 * P_β2 * R_α2
|
|
232
|
+
# For fixed (β1, β2):
|
|
233
|
+
# M[α1, α2] = (shifted_refs * (w*P_β1)) @ (shifted_refs * P_β2).T
|
|
234
|
+
# This is n_kpoly*(n_kpoly+1)/2 matmuls of size (n_kernel × n_pix) @ (n_pix × n_kernel);
|
|
235
|
+
# only the b1 <= b2 blocks are accumulated.
|
|
236
|
+
for b1 in range(n_kpoly):
|
|
237
|
+
wPb1_refs = srefs * (w_c * pk_c[b1])[np.newaxis, :] # (n_kernel, n_pix)
|
|
238
|
+
for b2 in range(b1, n_kpoly):
|
|
239
|
+
M = wPb1_refs @ (srefs * pk_c[b2][np.newaxis, :]).T
|
|
240
|
+
H[b1:n_kernel_params:n_kpoly, b2:n_kernel_params:n_kpoly] += M
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _mirror_normal_matrix(H, n_kernel_params, n_kpoly):
|
|
244
|
+
"""Mirror the accumulated upper blocks of H into the lower triangle."""
|
|
245
|
+
for b1 in range(n_kpoly):
|
|
246
|
+
for b2 in range(b1 + 1, n_kpoly):
|
|
247
|
+
H[b2:n_kernel_params:n_kpoly, b1:n_kernel_params:n_kpoly] = H[
|
|
248
|
+
b1:n_kernel_params:n_kpoly, b2:n_kernel_params:n_kpoly
|
|
249
|
+
].T
|
|
250
|
+
H[n_kernel_params:, :n_kernel_params] = H[:n_kernel_params, n_kernel_params:].T
|
|
251
|
+
return H
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _assemble_normal_equations(
|
|
255
|
+
reference, science, weight, kernel_shape, kernel_poly_order, bg_poly_order, x_norm, y_norm
|
|
256
|
+
):
|
|
257
|
+
"""Assemble normal equations H·θ = g via polynomial-pair batching.
|
|
258
|
+
|
|
259
|
+
Instead of iterating over O(n_kernel²) offset pairs with small matmuls,
|
|
260
|
+
iterates over O(n_kpoly²) polynomial term pairs with large BLAS matmuls.
|
|
261
|
+
For typical parameters (7×7 kernel, poly=2), this is ~60× fewer iterations
|
|
262
|
+
with much better BLAS utilization (49×49 matmuls vs 6×6).
|
|
263
|
+
|
|
264
|
+
All contributions are plain sums over pixels, so the image is processed
|
|
265
|
+
in bands of rows: peak memory is bounded by a few (n_kernel, band_pix)
|
|
266
|
+
work arrays (~256 MB each) instead of the full (n_kernel, npix) set of
|
|
267
|
+
shifted references, which would reach tens of GB for 4k images.
|
|
268
|
+
|
|
269
|
+
Parameters
|
|
270
|
+
----------
|
|
271
|
+
reference : (ny, nx)
|
|
272
|
+
science : (ny, nx)
|
|
273
|
+
weight : (ny, nx)
|
|
274
|
+
kernel_shape, kernel_poly_order, bg_poly_order : model specification
|
|
275
|
+
x_norm, y_norm : (ny, nx) normalized coordinates
|
|
276
|
+
|
|
277
|
+
Returns
|
|
278
|
+
-------
|
|
279
|
+
H : (n_total, n_total) normal matrix, upper-block form — pass through
|
|
280
|
+
:func:`_mirror_normal_matrix` before solving
|
|
281
|
+
g : (n_total,) right-hand side
|
|
282
|
+
"""
|
|
283
|
+
ky, kx = kernel_shape
|
|
284
|
+
offsets = _kernel_offsets(ky, kx)
|
|
285
|
+
n_kernel_pixels = ky * kx
|
|
286
|
+
n_kpoly = _n_poly(kernel_poly_order)
|
|
287
|
+
n_bgpoly = _n_poly(bg_poly_order)
|
|
288
|
+
n_kernel_params = n_kernel_pixels * n_kpoly
|
|
289
|
+
n_total = n_kernel_params + n_bgpoly
|
|
290
|
+
|
|
291
|
+
ny, nx = reference.shape
|
|
292
|
+
npix = ny * nx
|
|
293
|
+
|
|
294
|
+
poly_k = _poly_terms_2d(x_norm, y_norm, kernel_poly_order) # (n_kpoly, ny, nx)
|
|
295
|
+
poly_bg = _poly_terms_2d(x_norm, y_norm, bg_poly_order) # (n_bgpoly, ny, nx)
|
|
296
|
+
|
|
297
|
+
# Flatten everything for matrix ops
|
|
298
|
+
poly_k_flat = poly_k.reshape(n_kpoly, npix) # (n_kpoly, npix)
|
|
299
|
+
poly_bg_flat = poly_bg.reshape(n_bgpoly, npix) # (n_bgpoly, npix)
|
|
300
|
+
w_flat = weight.ravel() # (npix,)
|
|
301
|
+
s_flat = science.ravel() # (npix,)
|
|
302
|
+
|
|
303
|
+
H = np.zeros((n_total, n_total), dtype=np.float64)
|
|
304
|
+
g = np.zeros(n_total, dtype=np.float64)
|
|
305
|
+
|
|
306
|
+
band_rows = max(1, min(ny, _ASSEMBLE_BAND_ELEMENTS // (n_kernel_pixels * nx)))
|
|
307
|
+
shifted_refs = np.empty((n_kernel_pixels, band_rows * nx), dtype=np.float64)
|
|
308
|
+
|
|
309
|
+
for r0 in range(0, ny, band_rows):
|
|
310
|
+
r1 = min(ny, r0 + band_rows)
|
|
311
|
+
bpix = (r1 - r0) * nx
|
|
312
|
+
sl = slice(r0 * nx, r1 * nx)
|
|
313
|
+
|
|
314
|
+
# Shifted references restricted to this band: (n_kernel, bpix)
|
|
315
|
+
srefs = shifted_refs[:, :bpix]
|
|
316
|
+
for a, (dy, dx) in enumerate(offsets):
|
|
317
|
+
_shift_band(reference, dy, dx, r0, r1, srefs[a].reshape(r1 - r0, nx))
|
|
318
|
+
|
|
319
|
+
_accumulate_normal_blocks(
|
|
320
|
+
H, g, srefs, w_flat[sl], s_flat[sl],
|
|
321
|
+
poly_k_flat[:, sl], poly_bg_flat[:, sl], n_kernel_params,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
return H, g
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _downdate_normal_equations(
|
|
328
|
+
H, g, reference, science, w_pix, ys, xs,
|
|
329
|
+
kernel_shape, kernel_poly_order, bg_poly_order, x_norm, y_norm,
|
|
330
|
+
):
|
|
331
|
+
"""Subtract individual pixels' contributions from accumulated normal equations.
|
|
332
|
+
|
|
333
|
+
Since every term of H and g is linear in the pixel weight, removing
|
|
334
|
+
pixels (sigma-clipping) can be done exactly by accumulating their
|
|
335
|
+
contributions with negated weights — far cheaper than reassembling
|
|
336
|
+
from the full image when only a small fraction of pixels is clipped.
|
|
337
|
+
|
|
338
|
+
``H`` must be in the upper-block (pre-mirror) form produced by
|
|
339
|
+
:func:`_assemble_normal_equations`.
|
|
340
|
+
|
|
341
|
+
Parameters
|
|
342
|
+
----------
|
|
343
|
+
H, g : accumulated normal equations, modified in place
|
|
344
|
+
reference, science : (ny, nx) images
|
|
345
|
+
w_pix : (n_pix,) weights the pixels contributed with
|
|
346
|
+
ys, xs : (n_pix,) pixel coordinates
|
|
347
|
+
kernel_shape, kernel_poly_order, bg_poly_order : model specification
|
|
348
|
+
x_norm, y_norm : (ny, nx) normalized coordinates
|
|
349
|
+
"""
|
|
350
|
+
ky, kx = kernel_shape
|
|
351
|
+
hy, hx = ky // 2, kx // 2
|
|
352
|
+
offsets = _kernel_offsets(ky, kx)
|
|
353
|
+
n_kpoly = _n_poly(kernel_poly_order)
|
|
354
|
+
n_kernel_params = ky * kx * n_kpoly
|
|
355
|
+
|
|
356
|
+
# Shifted reference values R_α(p) = ref(p - offset_α) at scattered pixels,
|
|
357
|
+
# gathered from a zero-padded copy so out-of-image offsets read 0 exactly
|
|
358
|
+
# like _shift_image does
|
|
359
|
+
ref_pad = np.pad(reference, ((hy, hy), (hx, hx)))
|
|
360
|
+
dys = np.array([o[0] for o in offsets])
|
|
361
|
+
dxs = np.array([o[1] for o in offsets])
|
|
362
|
+
srefs = ref_pad[
|
|
363
|
+
ys[np.newaxis, :] - dys[:, np.newaxis] + hy,
|
|
364
|
+
xs[np.newaxis, :] - dxs[:, np.newaxis] + hx,
|
|
365
|
+
] # (n_kernel, n_pix)
|
|
366
|
+
|
|
367
|
+
pk_c = _poly_terms_2d(x_norm[ys, xs], y_norm[ys, xs], kernel_poly_order)
|
|
368
|
+
pbg_c = _poly_terms_2d(x_norm[ys, xs], y_norm[ys, xs], bg_poly_order)
|
|
369
|
+
|
|
370
|
+
_accumulate_normal_blocks(
|
|
371
|
+
H, g, srefs, -w_pix, science[ys, xs], pk_c, pbg_c, n_kernel_params
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _build_kernel_sum_constraint(kernel_shape, kernel_poly_order, flux_poly_order):
|
|
376
|
+
"""Build constraint matrix for kernel sum = flux polynomial.
|
|
377
|
+
|
|
378
|
+
The kernel-sum at position (x,y) is:
|
|
379
|
+
Σ_α a_α(x,y) = Σ_α Σ_β c_{α,β} · P_β(x,y)
|
|
380
|
+
= Σ_β [Σ_α c_{α,β}] · P_β(x,y)
|
|
381
|
+
|
|
382
|
+
We want this to equal a flux polynomial:
|
|
383
|
+
f(x,y) = Σ_γ f_γ · Q_γ(x,y)
|
|
384
|
+
|
|
385
|
+
where Q_γ are polynomial terms up to flux_poly_order.
|
|
386
|
+
|
|
387
|
+
If flux_poly_order <= kernel_poly_order, Q_γ ⊂ P_β, so the constraint is:
|
|
388
|
+
Σ_α c_{α,β} = f_β for β ≤ flux_poly_order terms
|
|
389
|
+
Σ_α c_{α,β} = 0 for higher-order β terms
|
|
390
|
+
|
|
391
|
+
This gives n_kpoly linear constraints on the kernel coefficients.
|
|
392
|
+
|
|
393
|
+
Returns
|
|
394
|
+
-------
|
|
395
|
+
C : (n_constraints, n_kernel_params) or None
|
|
396
|
+
Homogeneous constraint rows (C·θ_kernel = 0), or None if nothing
|
|
397
|
+
needs constraining.
|
|
398
|
+
n_flux_free : number of free flux polynomial coefficients
|
|
399
|
+
"""
|
|
400
|
+
n_kernel_pixels = kernel_shape[0] * kernel_shape[1]
|
|
401
|
+
n_kpoly = _n_poly(kernel_poly_order)
|
|
402
|
+
n_flux = _n_poly(flux_poly_order)
|
|
403
|
+
|
|
404
|
+
# We constrain: for each kernel poly term β with order > flux_poly_order,
|
|
405
|
+
# the sum Σ_α c_{α,β} = 0.
|
|
406
|
+
# For terms β within flux_poly_order, we don't constrain (those define
|
|
407
|
+
# the free flux scale polynomial).
|
|
408
|
+
|
|
409
|
+
# Count terms with total degree > flux_poly_order
|
|
410
|
+
n_constrained = n_kpoly - n_flux
|
|
411
|
+
if n_constrained <= 0:
|
|
412
|
+
return None, n_flux
|
|
413
|
+
|
|
414
|
+
# Build constraint rows
|
|
415
|
+
# Parameter layout: [c_{0,0}, c_{0,1}, ..., c_{0,n_kpoly-1},
|
|
416
|
+
# c_{1,0}, ..., c_{n_kernel-1, n_kpoly-1},
|
|
417
|
+
# d_0, ..., d_{n_bgpoly-1}]
|
|
418
|
+
# Total kernel params = n_kernel_pixels * n_kpoly
|
|
419
|
+
# For poly term β: params at indices [α * n_kpoly + β for α in range(n_kernel_pixels)]
|
|
420
|
+
|
|
421
|
+
n_total_kernel = n_kernel_pixels * n_kpoly
|
|
422
|
+
C_rows = []
|
|
423
|
+
|
|
424
|
+
# Identify which poly terms have total degree > flux_poly_order
|
|
425
|
+
term_idx = 0
|
|
426
|
+
for total in range(kernel_poly_order + 1):
|
|
427
|
+
for px in range(total + 1):
|
|
428
|
+
if total > flux_poly_order:
|
|
429
|
+
# This term β should have Σ_α c_{α,β} = 0
|
|
430
|
+
row = np.zeros(n_total_kernel, dtype=np.float64)
|
|
431
|
+
for alpha in range(n_kernel_pixels):
|
|
432
|
+
row[alpha * n_kpoly + term_idx] = 1.0
|
|
433
|
+
C_rows.append(row)
|
|
434
|
+
term_idx += 1
|
|
435
|
+
|
|
436
|
+
C = np.array(C_rows, dtype=np.float64)
|
|
437
|
+
return C, n_flux
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _reconstruct_model(
|
|
441
|
+
theta, reference, kernel_shape, kernel_poly_order, bg_poly_order, x_norm, y_norm
|
|
442
|
+
):
|
|
443
|
+
"""Reconstruct the model image from solved coefficients.
|
|
444
|
+
|
|
445
|
+
model(x,y) = Σ_α [Σ_β c_{α,β} · P_β(x,y)] · R_α(x,y) + Σ_γ d_γ · Q_γ(x,y)
|
|
446
|
+
|
|
447
|
+
Parameters
|
|
448
|
+
----------
|
|
449
|
+
theta : (n_total,) solved coefficients
|
|
450
|
+
reference : (ny, nx)
|
|
451
|
+
kernel_shape, kernel_poly_order, bg_poly_order : model specification
|
|
452
|
+
x_norm, y_norm : (ny, nx) normalized coordinates
|
|
453
|
+
|
|
454
|
+
Returns
|
|
455
|
+
-------
|
|
456
|
+
model : (ny, nx)
|
|
457
|
+
"""
|
|
458
|
+
ky, kx = kernel_shape
|
|
459
|
+
offsets = _kernel_offsets(ky, kx)
|
|
460
|
+
n_kpoly = _n_poly(kernel_poly_order)
|
|
461
|
+
n_bgpoly = _n_poly(bg_poly_order)
|
|
462
|
+
n_kernel_pixels = ky * kx
|
|
463
|
+
|
|
464
|
+
poly_k = _poly_terms_2d(x_norm, y_norm, kernel_poly_order) # (n_kpoly, ny, nx)
|
|
465
|
+
poly_bg = _poly_terms_2d(x_norm, y_norm, bg_poly_order) # (n_bgpoly, ny, nx)
|
|
466
|
+
|
|
467
|
+
kernel_coeffs = theta[: n_kernel_pixels * n_kpoly].reshape(n_kernel_pixels, n_kpoly)
|
|
468
|
+
bg_coeffs = theta[n_kernel_pixels * n_kpoly :]
|
|
469
|
+
|
|
470
|
+
ny, nx = reference.shape
|
|
471
|
+
npix = ny * nx
|
|
472
|
+
|
|
473
|
+
# model = Σ_α a_α(x,y) · R_α(x,y) where a_α = Σ_β c_{α,β} · P_β
|
|
474
|
+
# Accumulate one offset at a time to avoid materializing the full
|
|
475
|
+
# (n_kernel, npix) arrays of coefficient maps and shifted references.
|
|
476
|
+
poly_k_flat = poly_k.reshape(n_kpoly, npix)
|
|
477
|
+
model_flat = np.zeros(npix, dtype=np.float64)
|
|
478
|
+
with np.errstate(over='ignore', invalid='ignore', divide='ignore'):
|
|
479
|
+
for alpha, (dy, dx) in enumerate(offsets):
|
|
480
|
+
a_map = kernel_coeffs[alpha] @ poly_k_flat # (npix,)
|
|
481
|
+
model_flat += a_map * _shift_image(reference, dy, dx).ravel()
|
|
482
|
+
|
|
483
|
+
model = model_flat.reshape(ny, nx)
|
|
484
|
+
|
|
485
|
+
# Background contribution (vectorized)
|
|
486
|
+
model += np.tensordot(bg_coeffs, poly_bg, axes=(0, 0))
|
|
487
|
+
|
|
488
|
+
return model
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _propagate_template_variance(kernel_coeffs, kernel_shape, kernel_poly_order, var_tmpl):
|
|
492
|
+
"""Propagate template variance through the spatially varying kernel.
|
|
493
|
+
|
|
494
|
+
var_conv(x,y) = Σ_α a_α²(x,y) · var_tmpl(x - offset_α)
|
|
495
|
+
|
|
496
|
+
where a_α(x,y) are the kernel coefficient maps. This is the template
|
|
497
|
+
noise contribution to the difference image (and to proper fit weights).
|
|
498
|
+
|
|
499
|
+
Parameters
|
|
500
|
+
----------
|
|
501
|
+
kernel_coeffs : (n_kernel, n_kpoly) solved kernel coefficients
|
|
502
|
+
kernel_shape : (ky, kx)
|
|
503
|
+
kernel_poly_order : polynomial order of kernel spatial variation
|
|
504
|
+
var_tmpl : (ny, nx) template variance map
|
|
505
|
+
|
|
506
|
+
Returns
|
|
507
|
+
-------
|
|
508
|
+
var_conv : (ny, nx) kernel-convolved template variance
|
|
509
|
+
"""
|
|
510
|
+
ny, nx = var_tmpl.shape
|
|
511
|
+
npix = ny * nx
|
|
512
|
+
offsets = _kernel_offsets(*kernel_shape)
|
|
513
|
+
n_kpoly = _n_poly(kernel_poly_order)
|
|
514
|
+
|
|
515
|
+
x_norm, y_norm = _norm_coords(ny, nx)
|
|
516
|
+
poly_k_flat = _poly_terms_2d(x_norm, y_norm, kernel_poly_order).reshape(n_kpoly, npix)
|
|
517
|
+
|
|
518
|
+
# Accumulate one offset at a time, computing each coefficient map
|
|
519
|
+
# a_α(x,y) = kernel_coeffs[α] @ poly_k on the fly to avoid
|
|
520
|
+
# materializing the full (n_kernel, npix) array
|
|
521
|
+
var_conv = np.zeros(npix, dtype=np.float64)
|
|
522
|
+
with np.errstate(over='ignore', invalid='ignore', divide='ignore'):
|
|
523
|
+
for alpha, (dy, dx) in enumerate(offsets):
|
|
524
|
+
a_map = kernel_coeffs[alpha] @ poly_k_flat # (npix,)
|
|
525
|
+
shifted_var = _shift_image(var_tmpl, dy, dx).ravel()
|
|
526
|
+
var_conv += a_map**2 * shifted_var
|
|
527
|
+
|
|
528
|
+
return var_conv.reshape(ny, nx)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _extract_flux_poly(theta, kernel_shape, kernel_poly_order, flux_poly_order):
|
|
532
|
+
"""Extract the fitted flux-scale polynomial from kernel coefficients.
|
|
533
|
+
|
|
534
|
+
flux(x,y) = Σ_α a_α(x,y) = Σ_β [Σ_α c_{α,β}] · P_β(x,y)
|
|
535
|
+
|
|
536
|
+
Only terms with total degree ≤ flux_poly_order contribute (higher
|
|
537
|
+
terms are constrained to zero by the kernel-sum constraint).
|
|
538
|
+
|
|
539
|
+
Returns
|
|
540
|
+
-------
|
|
541
|
+
flux_coeffs : (n_flux,) polynomial coefficients for the flux scale
|
|
542
|
+
"""
|
|
543
|
+
n_kernel_pixels = kernel_shape[0] * kernel_shape[1]
|
|
544
|
+
n_kpoly = _n_poly(kernel_poly_order)
|
|
545
|
+
n_flux = _n_poly(flux_poly_order)
|
|
546
|
+
|
|
547
|
+
kernel_coeffs = theta[: n_kernel_pixels * n_kpoly].reshape(n_kernel_pixels, n_kpoly)
|
|
548
|
+
|
|
549
|
+
# Sum over all kernel pixels for each poly term
|
|
550
|
+
sum_per_poly = kernel_coeffs.sum(axis=0) # (n_kpoly,)
|
|
551
|
+
|
|
552
|
+
# Return only the flux_poly_order terms
|
|
553
|
+
return sum_per_poly[:n_flux]
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# ---------------------------------------------------------------------------
|
|
557
|
+
# Public API
|
|
558
|
+
# ---------------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def solve(
|
|
562
|
+
image,
|
|
563
|
+
template,
|
|
564
|
+
mask=None,
|
|
565
|
+
template_mask=None,
|
|
566
|
+
err=None,
|
|
567
|
+
template_err=None,
|
|
568
|
+
kernel_shape=(7, 7),
|
|
569
|
+
kernel_poly_order=2,
|
|
570
|
+
bg_poly_order=2,
|
|
571
|
+
flux_poly_order=1,
|
|
572
|
+
flux_penalty=1e3,
|
|
573
|
+
ridge=1e-6,
|
|
574
|
+
sigma_clip=3.0,
|
|
575
|
+
max_iter=5,
|
|
576
|
+
verbose=False,
|
|
577
|
+
):
|
|
578
|
+
"""SFFT image subtraction with spatially varying kernel.
|
|
579
|
+
|
|
580
|
+
Solves for a spatially varying convolution kernel and differential
|
|
581
|
+
background in a single global least-squares problem. The kernel
|
|
582
|
+
at each pixel is modelled as a delta-function basis with polynomial
|
|
583
|
+
spatial variation. A soft constraint enforces that the kernel sum
|
|
584
|
+
varies smoothly as a low-order polynomial (modelling flux-scale
|
|
585
|
+
differences between science and template).
|
|
586
|
+
|
|
587
|
+
Iterative sigma-clipping rejects outlier pixels (transients, cosmic
|
|
588
|
+
rays, artifacts) that would otherwise bias the kernel solution.
|
|
589
|
+
|
|
590
|
+
Since the template acts as the basis of the model, any template defect
|
|
591
|
+
(NaN/Inf or ``template_mask``) corrupts the model within a full kernel
|
|
592
|
+
footprint around it. Such pixels are excluded from the fit, and flagged
|
|
593
|
+
in the ``dmask`` attribute of the result — the difference image values
|
|
594
|
+
there (and in the kernel half-width band along image edges) are
|
|
595
|
+
unreliable and should not be searched for transients.
|
|
596
|
+
|
|
597
|
+
Parameters
|
|
598
|
+
----------
|
|
599
|
+
image : numpy.ndarray
|
|
600
|
+
Science image as a 2-D NumPy array.
|
|
601
|
+
template : numpy.ndarray
|
|
602
|
+
Template/reference image, same shape, aligned to science.
|
|
603
|
+
mask : numpy.ndarray, optional
|
|
604
|
+
Boolean mask where True = bad pixels to exclude from the fit
|
|
605
|
+
(science-side defects, or regions to ignore). Unlike template
|
|
606
|
+
defects, these only affect their own pixel.
|
|
607
|
+
template_mask : numpy.ndarray, optional
|
|
608
|
+
Mask of template defects (True = bad). Grown by the kernel footprint,
|
|
609
|
+
since the model at every pixel within a kernel half-width of a
|
|
610
|
+
template defect depends on the missing data. Pass template-side
|
|
611
|
+
defects here rather than merging them into ``mask``.
|
|
612
|
+
err : numpy.ndarray, optional
|
|
613
|
+
Per-pixel error (standard deviation) map for inverse-variance
|
|
614
|
+
weighting. If None, uniform weights are used. A constant error map
|
|
615
|
+
(or True) is equivalent to uniform weighting and does not change
|
|
616
|
+
the solution (unless ``template_err`` is also given).
|
|
617
|
+
template_err : numpy.ndarray, optional
|
|
618
|
+
Per-pixel error map of the template. If given, one reweighting pass
|
|
619
|
+
is performed: after an initial solve, the template variance is
|
|
620
|
+
propagated through the fitted kernel and the weights are rebuilt as
|
|
621
|
+
``1 / (err² + Σ_α a_α² · template_err²)``, followed by a re-solve.
|
|
622
|
+
This properly de-weights pixels dominated by template noise and
|
|
623
|
+
matters when the template is not much deeper than the science image.
|
|
624
|
+
Costs one extra solver pass.
|
|
625
|
+
kernel_shape : tuple of int, optional
|
|
626
|
+
(ky, kx) size of the convolution kernel, must be odd. Default (7, 7).
|
|
627
|
+
Larger kernels handle bigger PSF differences but are slower.
|
|
628
|
+
kernel_poly_order : int, optional
|
|
629
|
+
Polynomial order for spatial variation of each kernel coefficient.
|
|
630
|
+
Default 2 (quadratic). Higher orders capture more complex PSF variation
|
|
631
|
+
but need more pixels.
|
|
632
|
+
bg_poly_order : int, optional
|
|
633
|
+
Polynomial order for the differential background model. Default 2.
|
|
634
|
+
flux_poly_order : int, optional
|
|
635
|
+
Polynomial order for the kernel-sum constraint (flux scale variation).
|
|
636
|
+
Default 1 (linear gradient). Set to 0 for constant flux scale.
|
|
637
|
+
flux_penalty : float, optional
|
|
638
|
+
Dimensionless penalty weight for the kernel-sum constraint, relative
|
|
639
|
+
to the median diagonal of the normal matrix (so its effect does not
|
|
640
|
+
depend on image size or flux units). Default 1e3, which enforces the
|
|
641
|
+
constraint nearly strictly. Set to 0 to disable the constraint
|
|
642
|
+
entirely.
|
|
643
|
+
ridge : float, optional
|
|
644
|
+
Dimensionless Tikhonov regularization, relative to the median
|
|
645
|
+
diagonal of the normal matrix. Default 1e-6.
|
|
646
|
+
sigma_clip : float, optional
|
|
647
|
+
Sigma threshold for iterative outlier rejection. Default 3.0. Set to
|
|
648
|
+
None or 0 to disable clipping.
|
|
649
|
+
max_iter : int, optional
|
|
650
|
+
Maximum number of sigma-clipping iterations. Default 5. Must be at
|
|
651
|
+
least 1. The reweighting pass triggered by ``template_err`` is
|
|
652
|
+
performed in addition to these.
|
|
653
|
+
verbose : bool or callable, optional
|
|
654
|
+
If True, print progress. If callable, use as log function.
|
|
655
|
+
|
|
656
|
+
Returns
|
|
657
|
+
-------
|
|
658
|
+
SFFTResult
|
|
659
|
+
Result object with difference image and all fit metadata. Pixels
|
|
660
|
+
flagged in its ``dmask`` attribute are unreliable in the difference.
|
|
661
|
+
"""
|
|
662
|
+
|
|
663
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
664
|
+
|
|
665
|
+
# --- Input validation and preparation ---
|
|
666
|
+
sci = np.asarray(image, dtype=np.float64)
|
|
667
|
+
ref = np.asarray(template, dtype=np.float64)
|
|
668
|
+
|
|
669
|
+
if sci.shape != ref.shape:
|
|
670
|
+
raise ValueError(
|
|
671
|
+
"science and template must have the same shape, got %s vs %s" % (sci.shape, ref.shape)
|
|
672
|
+
)
|
|
673
|
+
if sci.ndim != 2:
|
|
674
|
+
raise ValueError("images must be 2-D arrays")
|
|
675
|
+
|
|
676
|
+
ny, nx = sci.shape
|
|
677
|
+
ky, kx = kernel_shape
|
|
678
|
+
|
|
679
|
+
if ky % 2 == 0 or kx % 2 == 0:
|
|
680
|
+
raise ValueError("kernel_shape must be odd, got (%d, %d)" % (ky, kx))
|
|
681
|
+
|
|
682
|
+
if flux_poly_order > kernel_poly_order:
|
|
683
|
+
raise ValueError(
|
|
684
|
+
"flux_poly_order (%d) must be <= kernel_poly_order (%d)"
|
|
685
|
+
% (flux_poly_order, kernel_poly_order)
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
if max_iter < 1:
|
|
689
|
+
raise ValueError("max_iter must be at least 1, got %d" % max_iter)
|
|
690
|
+
|
|
691
|
+
# Replace NaN/Inf with 0 to prevent propagation through matmuls.
|
|
692
|
+
# These pixels are masked via the weight map, so the values don't matter.
|
|
693
|
+
nan_sci = ~np.isfinite(sci)
|
|
694
|
+
nan_ref = ~np.isfinite(ref)
|
|
695
|
+
if np.any(nan_sci) or np.any(nan_ref):
|
|
696
|
+
sci = sci.copy()
|
|
697
|
+
ref = ref.copy()
|
|
698
|
+
sci[nan_sci] = 0.0
|
|
699
|
+
ref[nan_ref] = 0.0
|
|
700
|
+
|
|
701
|
+
n_kernel_pixels = ky * kx
|
|
702
|
+
n_kpoly = _n_poly(kernel_poly_order)
|
|
703
|
+
n_bgpoly = _n_poly(bg_poly_order)
|
|
704
|
+
n_kernel_params = n_kernel_pixels * n_kpoly
|
|
705
|
+
n_total = n_kernel_params + n_bgpoly
|
|
706
|
+
|
|
707
|
+
n_fpoly = _n_poly(flux_poly_order)
|
|
708
|
+
log(
|
|
709
|
+
"SFFT: image %dx%d, kernel %dx%d, kernel_poly=%d (%d terms), "
|
|
710
|
+
"bg_poly=%d (%d terms), flux_poly=%d (%d terms)"
|
|
711
|
+
% (
|
|
712
|
+
nx,
|
|
713
|
+
ny,
|
|
714
|
+
kx,
|
|
715
|
+
ky,
|
|
716
|
+
kernel_poly_order,
|
|
717
|
+
n_kpoly,
|
|
718
|
+
bg_poly_order,
|
|
719
|
+
n_bgpoly,
|
|
720
|
+
flux_poly_order,
|
|
721
|
+
n_fpoly,
|
|
722
|
+
)
|
|
723
|
+
)
|
|
724
|
+
log(
|
|
725
|
+
"SFFT: %d kernel + %d background = %d total parameters"
|
|
726
|
+
% (n_kernel_params, n_bgpoly, n_total)
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
# --- Build good-pixel mask ---
|
|
730
|
+
# `good` tracks which pixels participate in the fit; it only ever shrinks
|
|
731
|
+
# (masking, clipping). Float weights are derived from it separately so
|
|
732
|
+
# that reweighting cannot resurrect clipped pixels.
|
|
733
|
+
good = np.ones((ny, nx), dtype=bool)
|
|
734
|
+
|
|
735
|
+
if mask is not None:
|
|
736
|
+
good[np.asarray(mask, dtype=bool)] = False
|
|
737
|
+
|
|
738
|
+
# Science NaN/Inf pixels only corrupt their own residual
|
|
739
|
+
if np.any(nan_sci):
|
|
740
|
+
good[nan_sci] = False
|
|
741
|
+
|
|
742
|
+
# Template defects (masked or non-finite) corrupt the model everywhere
|
|
743
|
+
# the kernel footprint overlaps them: the shifted template values enter
|
|
744
|
+
# the design matrix of every pixel within a kernel half-width. Grow the
|
|
745
|
+
# defect mask by the kernel footprint before excluding those pixels.
|
|
746
|
+
bad_tmpl = nan_ref.copy()
|
|
747
|
+
if template_mask is not None:
|
|
748
|
+
bad_tmpl |= np.asarray(template_mask, dtype=bool)
|
|
749
|
+
|
|
750
|
+
dmask = np.zeros((ny, nx), dtype=bool)
|
|
751
|
+
if np.any(bad_tmpl):
|
|
752
|
+
for dy, dx in _kernel_offsets(ky, kx):
|
|
753
|
+
dmask |= _shift_image(bad_tmpl, dy, dx)
|
|
754
|
+
n_grown = int(np.sum(dmask & ~bad_tmpl))
|
|
755
|
+
if n_grown:
|
|
756
|
+
log(
|
|
757
|
+
"SFFT: template defects grown by kernel footprint: "
|
|
758
|
+
"%d -> %d pixels" % (int(np.sum(bad_tmpl)), int(np.sum(dmask)))
|
|
759
|
+
)
|
|
760
|
+
good[dmask] = False
|
|
761
|
+
|
|
762
|
+
# Edge pixels are likewise affected by zero-filled kernel shifts
|
|
763
|
+
hy, hx = ky // 2, kx // 2
|
|
764
|
+
if hy > 0:
|
|
765
|
+
good[:hy, :] = False
|
|
766
|
+
good[-hy:, :] = False
|
|
767
|
+
dmask[:hy, :] = True
|
|
768
|
+
dmask[-hy:, :] = True
|
|
769
|
+
if hx > 0:
|
|
770
|
+
good[:, :hx] = False
|
|
771
|
+
good[:, -hx:] = False
|
|
772
|
+
dmask[:, :hx] = True
|
|
773
|
+
dmask[:, -hx:] = True
|
|
774
|
+
|
|
775
|
+
# --- Inverse-variance base weights ---
|
|
776
|
+
def _make_weight(var_total):
|
|
777
|
+
"""Weight map 1/var with invalid entries zeroed; None = uniform."""
|
|
778
|
+
w = np.ones((ny, nx), dtype=np.float64)
|
|
779
|
+
if var_total is not None:
|
|
780
|
+
valid = (var_total > 0) & np.isfinite(var_total)
|
|
781
|
+
w[valid] = 1.0 / var_total[valid]
|
|
782
|
+
w[~valid] = 0.0
|
|
783
|
+
return w
|
|
784
|
+
|
|
785
|
+
if err is not None and err is not True:
|
|
786
|
+
err_map = np.asarray(err, dtype=np.float64)
|
|
787
|
+
var_sci = err_map**2
|
|
788
|
+
else:
|
|
789
|
+
var_sci = None
|
|
790
|
+
if err is True:
|
|
791
|
+
# A constant error map rescales all weights uniformly and does
|
|
792
|
+
# not change the solution, so uniform weighting is used instead.
|
|
793
|
+
log("SFFT: err=True has no effect; pass a per-pixel error map for weighting")
|
|
794
|
+
|
|
795
|
+
if template_err is not None:
|
|
796
|
+
template_err = np.asarray(template_err, dtype=np.float64)
|
|
797
|
+
if template_err.shape != (ny, nx):
|
|
798
|
+
raise ValueError("template_err must have the same shape as the images")
|
|
799
|
+
|
|
800
|
+
base_weight = _make_weight(var_sci)
|
|
801
|
+
|
|
802
|
+
# --- Normalized coordinates ---
|
|
803
|
+
x_norm, y_norm = _norm_coords(ny, nx)
|
|
804
|
+
|
|
805
|
+
# --- Build kernel-sum constraint ---
|
|
806
|
+
C_kernel, _ = _build_kernel_sum_constraint(
|
|
807
|
+
kernel_shape, kernel_poly_order, flux_poly_order
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
if C_kernel is not None and flux_penalty > 0:
|
|
811
|
+
log(
|
|
812
|
+
"SFFT: kernel-sum constraint: %d equations, flux_poly_order=%d, "
|
|
813
|
+
"penalty=%.1e" % (C_kernel.shape[0], flux_poly_order, flux_penalty)
|
|
814
|
+
)
|
|
815
|
+
# The constraint only involves kernel parameters; its normal-equation
|
|
816
|
+
# contribution (CᵀC) is constant across iterations
|
|
817
|
+
with np.errstate(over='ignore', invalid='ignore', divide='ignore'):
|
|
818
|
+
CtC_kernel = C_kernel.T @ C_kernel # (n_kernel_params, n_kernel_params)
|
|
819
|
+
else:
|
|
820
|
+
CtC_kernel = None
|
|
821
|
+
|
|
822
|
+
# --- Iterative sigma-clipping solve ---
|
|
823
|
+
do_clip = sigma_clip is not None and sigma_clip > 0 and max_iter > 1
|
|
824
|
+
# One extra solver pass to rebuild the weights with template noise
|
|
825
|
+
# propagated through the fitted kernel (which needs a first-pass solve)
|
|
826
|
+
reweight_pending = template_err is not None
|
|
827
|
+
total_iters = max_iter + (1 if reweight_pending else 0)
|
|
828
|
+
|
|
829
|
+
n_iter_done = 0
|
|
830
|
+
final_rms = np.nan
|
|
831
|
+
# Accumulated data-term normal equations (upper-block form). Kept pristine
|
|
832
|
+
# (no penalty/ridge) so clipped pixels can be downdated incrementally;
|
|
833
|
+
# set to None whenever the weights change globally (reweighting).
|
|
834
|
+
H_data = None
|
|
835
|
+
g_data = None
|
|
836
|
+
|
|
837
|
+
for iteration in range(total_iters):
|
|
838
|
+
n_iter_done = iteration + 1
|
|
839
|
+
weight = np.where(good, base_weight, 0.0)
|
|
840
|
+
n_good = int(np.sum(weight > 0))
|
|
841
|
+
|
|
842
|
+
if n_good < n_total + 10:
|
|
843
|
+
raise RuntimeError("Too few good pixels (%d) for %d parameters" % (n_good, n_total))
|
|
844
|
+
|
|
845
|
+
if H_data is None:
|
|
846
|
+
# Assemble normal equations (processed in row bands to bound memory)
|
|
847
|
+
log("SFFT: assembling normal equations (%d parameters)" % n_total)
|
|
848
|
+
H_data, g_data = _assemble_normal_equations(
|
|
849
|
+
ref, sci, weight, kernel_shape, kernel_poly_order, bg_poly_order, x_norm, y_norm
|
|
850
|
+
)
|
|
851
|
+
else:
|
|
852
|
+
log("SFFT: reusing normal equations downdated by clipped pixels")
|
|
853
|
+
|
|
854
|
+
H = _mirror_normal_matrix(H_data.copy(), n_kernel_params, n_kpoly)
|
|
855
|
+
g = g_data.copy()
|
|
856
|
+
|
|
857
|
+
# Typical magnitude of the data term, used to make the penalty and
|
|
858
|
+
# ridge scale-free (independent of image size, flux units, weights)
|
|
859
|
+
h_diag = np.diagonal(H)[:n_kernel_params]
|
|
860
|
+
h_pos = h_diag[h_diag > 0]
|
|
861
|
+
h_scale = np.median(h_pos) if len(h_pos) else 1.0
|
|
862
|
+
|
|
863
|
+
# Add kernel-sum penalty to normal equations
|
|
864
|
+
if CtC_kernel is not None:
|
|
865
|
+
H[:n_kernel_params, :n_kernel_params] += (flux_penalty * h_scale) * CtC_kernel
|
|
866
|
+
|
|
867
|
+
# Solve with regularization; H is symmetric positive-definite,
|
|
868
|
+
# so try Cholesky first
|
|
869
|
+
H[np.diag_indices(n_total)] += ridge * h_scale
|
|
870
|
+
try:
|
|
871
|
+
theta = scipy_linalg.solve(H, g, assume_a='pos')
|
|
872
|
+
except np.linalg.LinAlgError:
|
|
873
|
+
log("SFFT: WARNING - singular normal matrix, increasing ridge")
|
|
874
|
+
H[np.diag_indices(n_total)] += 1e-3 * h_scale
|
|
875
|
+
theta = np.linalg.solve(H, g)
|
|
876
|
+
|
|
877
|
+
# Reconstruct model and compute residuals
|
|
878
|
+
model = _reconstruct_model(
|
|
879
|
+
theta, ref, kernel_shape, kernel_poly_order, bg_poly_order, x_norm, y_norm
|
|
880
|
+
)
|
|
881
|
+
residual = sci - model
|
|
882
|
+
|
|
883
|
+
# Compute RMS on good pixels
|
|
884
|
+
good_mask = weight > 0
|
|
885
|
+
if np.any(good_mask):
|
|
886
|
+
final_rms = np.sqrt(np.mean(residual[good_mask] ** 2))
|
|
887
|
+
else:
|
|
888
|
+
final_rms = np.nan
|
|
889
|
+
|
|
890
|
+
log(
|
|
891
|
+
"SFFT: iteration %d/%d: n_good=%d, rms=%.4f"
|
|
892
|
+
% (iteration + 1, total_iters, n_good, final_rms)
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
# Reweighting pass: rebuild the weights including template noise
|
|
896
|
+
# propagated through the first-pass kernel, then re-solve
|
|
897
|
+
if reweight_pending:
|
|
898
|
+
reweight_pending = False
|
|
899
|
+
kc = theta[:n_kernel_params].reshape(n_kernel_pixels, n_kpoly)
|
|
900
|
+
var_conv = _propagate_template_variance(
|
|
901
|
+
kc, kernel_shape, kernel_poly_order, template_err**2
|
|
902
|
+
)
|
|
903
|
+
if var_sci is not None:
|
|
904
|
+
var_total = var_sci + var_conv
|
|
905
|
+
else:
|
|
906
|
+
# Science noise unknown: estimate a constant floor from the
|
|
907
|
+
# residual scatter in excess of the template contribution
|
|
908
|
+
abs_resid = np.abs(residual)
|
|
909
|
+
robust_var = (np.median(abs_resid[good_mask]) * 1.4826) ** 2
|
|
910
|
+
var_sci_const = max(robust_var - np.median(var_conv[good_mask]), 0.0)
|
|
911
|
+
var_total = var_sci_const + var_conv
|
|
912
|
+
log(
|
|
913
|
+
"SFFT: science noise not given, using constant variance %.4g "
|
|
914
|
+
"estimated from residuals" % var_sci_const
|
|
915
|
+
)
|
|
916
|
+
base_weight = _make_weight(var_total)
|
|
917
|
+
# Weights changed globally — the accumulated data term is invalid
|
|
918
|
+
H_data = None
|
|
919
|
+
g_data = None
|
|
920
|
+
log("SFFT: reweighting with kernel-propagated template noise")
|
|
921
|
+
continue
|
|
922
|
+
|
|
923
|
+
# Sigma-clipping
|
|
924
|
+
if not do_clip or iteration == total_iters - 1:
|
|
925
|
+
break
|
|
926
|
+
|
|
927
|
+
if np.any(good_mask) and np.isfinite(final_rms) and final_rms > 0:
|
|
928
|
+
# Use MAD for robust scale estimate
|
|
929
|
+
abs_resid = np.abs(residual)
|
|
930
|
+
robust_sigma = np.median(abs_resid[good_mask]) * 1.4826
|
|
931
|
+
if robust_sigma > 0:
|
|
932
|
+
newly_clipped = (abs_resid > sigma_clip * robust_sigma) & good_mask
|
|
933
|
+
n_clipped = int(np.sum(newly_clipped))
|
|
934
|
+
if n_clipped == 0:
|
|
935
|
+
log("SFFT: no pixels clipped, converged")
|
|
936
|
+
break
|
|
937
|
+
|
|
938
|
+
good[newly_clipped] = False
|
|
939
|
+
if _USE_INCREMENTAL_UPDATES:
|
|
940
|
+
# Remove just the clipped pixels' contributions instead
|
|
941
|
+
# of reassembling from the full image (exact: all terms
|
|
942
|
+
# are linear in the pixel weight)
|
|
943
|
+
ys, xs = np.nonzero(newly_clipped)
|
|
944
|
+
_downdate_normal_equations(
|
|
945
|
+
H_data, g_data, ref, sci, weight[ys, xs], ys, xs,
|
|
946
|
+
kernel_shape, kernel_poly_order, bg_poly_order, x_norm, y_norm,
|
|
947
|
+
)
|
|
948
|
+
else:
|
|
949
|
+
H_data = None
|
|
950
|
+
g_data = None
|
|
951
|
+
log("SFFT: clipped %d pixels (%.3f%%)" % (n_clipped, 100.0 * n_clipped / n_good))
|
|
952
|
+
else:
|
|
953
|
+
break
|
|
954
|
+
else:
|
|
955
|
+
# No usable residual statistics — further iterations would
|
|
956
|
+
# re-solve the identical system
|
|
957
|
+
break
|
|
958
|
+
|
|
959
|
+
# --- Extract results ---
|
|
960
|
+
kernel_coeffs = theta[:n_kernel_params].reshape(n_kernel_pixels, n_kpoly)
|
|
961
|
+
bg_coeffs = theta[n_kernel_params:]
|
|
962
|
+
flux_coeffs = _extract_flux_poly(theta, kernel_shape, kernel_poly_order, flux_poly_order)
|
|
963
|
+
|
|
964
|
+
diff = sci - model
|
|
965
|
+
n_good_final = int(np.sum(np.where(good, base_weight, 0.0) > 0))
|
|
966
|
+
|
|
967
|
+
log(
|
|
968
|
+
"SFFT: done. Final RMS=%.4f, n_good=%d (%.1f%%)"
|
|
969
|
+
% (final_rms, n_good_final, 100.0 * n_good_final / (ny * nx))
|
|
970
|
+
)
|
|
971
|
+
log(
|
|
972
|
+
"SFFT: flux scale polynomial coeffs: %s"
|
|
973
|
+
% np.array2string(flux_coeffs, precision=4, separator=', ')
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
return SFFTResult(
|
|
977
|
+
diff=diff,
|
|
978
|
+
model=model,
|
|
979
|
+
kernel_coeffs=kernel_coeffs,
|
|
980
|
+
bg_coeffs=bg_coeffs,
|
|
981
|
+
kernel_shape=kernel_shape,
|
|
982
|
+
kernel_poly_order=kernel_poly_order,
|
|
983
|
+
bg_poly_order=bg_poly_order,
|
|
984
|
+
flux_poly_order=flux_poly_order,
|
|
985
|
+
flux_poly_coeffs=flux_coeffs,
|
|
986
|
+
n_iter=n_iter_done,
|
|
987
|
+
rms=float(final_rms),
|
|
988
|
+
n_good=n_good_final,
|
|
989
|
+
dmask=dmask,
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
# ---------------------------------------------------------------------------
|
|
994
|
+
# Convenience functions
|
|
995
|
+
# ---------------------------------------------------------------------------
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def evaluate_kernel_at(result, x, y, image_shape):
|
|
999
|
+
"""Evaluate the spatially varying kernel at a single image position.
|
|
1000
|
+
|
|
1001
|
+
The returned stamp is the convolution kernel in the usual sense:
|
|
1002
|
+
``model(p) = Σ k[dy, dx] · template(p - (dy, dx))``, with the element
|
|
1003
|
+
``k[hy + dy, hx + dx]`` at array index ``(hy + dy, hx + dx)`` for
|
|
1004
|
+
half-sizes ``hy, hx``.
|
|
1005
|
+
|
|
1006
|
+
Parameters
|
|
1007
|
+
----------
|
|
1008
|
+
result : SFFTResult
|
|
1009
|
+
:class:`SFFTResult` from :func:`solve`.
|
|
1010
|
+
x : float
|
|
1011
|
+
X pixel coordinate.
|
|
1012
|
+
y : float
|
|
1013
|
+
Y pixel coordinate.
|
|
1014
|
+
image_shape : tuple of int
|
|
1015
|
+
(ny, nx) of the original image.
|
|
1016
|
+
|
|
1017
|
+
Returns
|
|
1018
|
+
-------
|
|
1019
|
+
numpy.ndarray
|
|
1020
|
+
2-D array of shape ``result.kernel_shape``.
|
|
1021
|
+
"""
|
|
1022
|
+
xn, yn = _norm_xy(x, y, image_shape)
|
|
1023
|
+
|
|
1024
|
+
poly_k = _poly_terms_2d(xn, yn, result.kernel_poly_order) # (n_kpoly,)
|
|
1025
|
+
|
|
1026
|
+
kernel_vals = result.kernel_coeffs @ poly_k # (n_kernel_pixels,)
|
|
1027
|
+
return kernel_vals.reshape(result.kernel_shape)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def evaluate_flux_scale(result, x, y, image_shape):
|
|
1031
|
+
"""Evaluate the flux-scale polynomial at image position(s).
|
|
1032
|
+
|
|
1033
|
+
Parameters
|
|
1034
|
+
----------
|
|
1035
|
+
result : SFFTResult
|
|
1036
|
+
:class:`SFFTResult` from :func:`solve`.
|
|
1037
|
+
x : float or array-like
|
|
1038
|
+
X coordinate(s), scalar or array.
|
|
1039
|
+
y : float or array-like
|
|
1040
|
+
Y coordinate(s), scalar or array.
|
|
1041
|
+
image_shape : tuple of int
|
|
1042
|
+
(ny, nx) of the original image.
|
|
1043
|
+
|
|
1044
|
+
Returns
|
|
1045
|
+
-------
|
|
1046
|
+
float or numpy.ndarray
|
|
1047
|
+
Flux scale value(s), same shape as x/y.
|
|
1048
|
+
"""
|
|
1049
|
+
xn, yn = _norm_xy(x, y, image_shape)
|
|
1050
|
+
|
|
1051
|
+
poly = _poly_terms_2d(xn, yn, result.flux_poly_order)
|
|
1052
|
+
return np.tensordot(result.flux_poly_coeffs, poly, axes=(0, 0))
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def evaluate_background(result, x, y, image_shape):
|
|
1056
|
+
"""Evaluate the differential background model at image position(s).
|
|
1057
|
+
|
|
1058
|
+
Parameters
|
|
1059
|
+
----------
|
|
1060
|
+
result : SFFTResult
|
|
1061
|
+
:class:`SFFTResult` from :func:`solve`.
|
|
1062
|
+
x : float or array-like
|
|
1063
|
+
X coordinate(s), scalar or array.
|
|
1064
|
+
y : float or array-like
|
|
1065
|
+
Y coordinate(s), scalar or array.
|
|
1066
|
+
image_shape : tuple of int
|
|
1067
|
+
(ny, nx) of the original image.
|
|
1068
|
+
|
|
1069
|
+
Returns
|
|
1070
|
+
-------
|
|
1071
|
+
float or numpy.ndarray
|
|
1072
|
+
Background value(s), same shape as x/y.
|
|
1073
|
+
"""
|
|
1074
|
+
xn, yn = _norm_xy(x, y, image_shape)
|
|
1075
|
+
|
|
1076
|
+
poly = _poly_terms_2d(xn, yn, result.bg_poly_order)
|
|
1077
|
+
return np.tensordot(result.bg_coeffs, poly, axes=(0, 0))
|