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/subtraction.py
ADDED
|
@@ -0,0 +1,1324 @@
|
|
|
1
|
+
import os, shutil, tempfile, shlex
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
from astropy.io import fits
|
|
5
|
+
from astropy.stats import mad_std
|
|
6
|
+
from astropy.table import Table
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Drop-in replacement for numpy.fft which is supposedly faster
|
|
10
|
+
import pyfftw
|
|
11
|
+
import pyfftw.interfaces.numpy_fft as fft
|
|
12
|
+
|
|
13
|
+
pyfftw.interfaces.cache.enable()
|
|
14
|
+
pyfftw.interfaces.cache.set_keepalive_time(1.0)
|
|
15
|
+
|
|
16
|
+
from scipy import ndimage
|
|
17
|
+
from scipy.optimize import least_squares
|
|
18
|
+
import statsmodels.api as sm
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import sep_x as sep
|
|
22
|
+
except ImportError:
|
|
23
|
+
import sep
|
|
24
|
+
|
|
25
|
+
from . import astrometry
|
|
26
|
+
from . import pipeline
|
|
27
|
+
from . import psf
|
|
28
|
+
from . import sfft as sfft_module
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_hotpants(
|
|
32
|
+
image,
|
|
33
|
+
template,
|
|
34
|
+
mask=None,
|
|
35
|
+
template_mask=None,
|
|
36
|
+
err=None,
|
|
37
|
+
template_err=None,
|
|
38
|
+
extra=None,
|
|
39
|
+
image_fwhm=None,
|
|
40
|
+
template_fwhm=None,
|
|
41
|
+
image_gain=None,
|
|
42
|
+
template_gain=1000,
|
|
43
|
+
rel_r=3,
|
|
44
|
+
rel_rss=4,
|
|
45
|
+
nx=1,
|
|
46
|
+
ny=None,
|
|
47
|
+
obj=None,
|
|
48
|
+
get_convolved=False,
|
|
49
|
+
get_scaled=False,
|
|
50
|
+
get_noise=False,
|
|
51
|
+
get_kernel=False,
|
|
52
|
+
get_header=False,
|
|
53
|
+
_tmpdir=None,
|
|
54
|
+
_workdir=None,
|
|
55
|
+
_exe=None,
|
|
56
|
+
verbose=False,
|
|
57
|
+
):
|
|
58
|
+
"""Wrapper for running HOTPANTS to subtract a template from science image.
|
|
59
|
+
|
|
60
|
+
To better understand the logic and available subtraction options you may check
|
|
61
|
+
HOTPANTS documentation at https://github.com/acbecker/hotpants
|
|
62
|
+
|
|
63
|
+
The routine tries to be clever and select reasonable defaults for running HOTPANTS,
|
|
64
|
+
but you may always directly specify any HOTPANTS option manually through `extra` parameter.
|
|
65
|
+
|
|
66
|
+
The noise model for both science and template images may optionally be provided as an
|
|
67
|
+
external parameters (`err` and `template_err`). Moreover, if these parameters are set
|
|
68
|
+
to `True`, the routine will try to build noise models itself. To do so, it will estimate
|
|
69
|
+
the image background and background RMS. Then, this background RMS map is used as a primary
|
|
70
|
+
component of the noise map. On top of that, the contribution of the sources is added by
|
|
71
|
+
subtracting the background from original image, smoothing it a bit to mitigate pixel-level
|
|
72
|
+
noise a bit, and then converting everything above zero to corresponding Poissonian noise
|
|
73
|
+
contribution by dividing with gain value and taking a square root.
|
|
74
|
+
|
|
75
|
+
`rel_r` and `rel_rss` define the scaling of corresponding HOTPANTS parameters (which are
|
|
76
|
+
the convolution kernel half width and half-width of the sub-stamps used for kernel fitting)
|
|
77
|
+
with image FWHM.
|
|
78
|
+
|
|
79
|
+
The routine also uses "officially recommedned" logic for defining HOTPANTS `ng` parameter,
|
|
80
|
+
that is to set it to :code:`[3, 6, 0.5*sigma_match, 4, 1.0*sigma_match, 2, 2.0*sigma_match]`
|
|
81
|
+
where :code:`sigma_match = np.sqrt(image_fwhm**2 - template_fwhm**2) / 2.35`. This approach
|
|
82
|
+
assumes that :code:`image_fwhm > template_fwhm`, and of course needs `image_fwhm` and
|
|
83
|
+
`template_fwhm` to be provided.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
image : numpy.ndarray
|
|
88
|
+
Input science image as a NumPy array.
|
|
89
|
+
template : numpy.ndarray
|
|
90
|
+
Input template image, should have the same shape as the science image.
|
|
91
|
+
mask : numpy.ndarray, optional
|
|
92
|
+
Science image mask as a boolean array (True values will be masked).
|
|
93
|
+
template_mask : numpy.ndarray, optional
|
|
94
|
+
Template image mask as a boolean array (True values will be masked).
|
|
95
|
+
err : numpy.ndarray or bool, optional
|
|
96
|
+
Science image error map (expected RMS of every pixel). If set to True,
|
|
97
|
+
the code will try to build the noise map directly from the image and
|
|
98
|
+
``image_gain`` parameter.
|
|
99
|
+
template_err : numpy.ndarray or bool, optional
|
|
100
|
+
Template image error map. If set to True, the code will try to build the
|
|
101
|
+
noise map directly from the template and ``template_gain`` parameter.
|
|
102
|
+
extra : dict, optional
|
|
103
|
+
Extra parameters to be passed to HOTPANTS executable, with parameter names as keys.
|
|
104
|
+
image_fwhm : float, optional
|
|
105
|
+
FWHM of science image in pixels.
|
|
106
|
+
template_fwhm : float, optional
|
|
107
|
+
FWHM of template image in pixels.
|
|
108
|
+
image_gain : float, optional
|
|
109
|
+
Gain of science image.
|
|
110
|
+
template_gain : float, optional
|
|
111
|
+
Gain of template image.
|
|
112
|
+
rel_r : float, optional
|
|
113
|
+
If specified, HOTPANTS ``r`` parameters will be set to :code:`image_fwhm*rel_r`.
|
|
114
|
+
rel_rss : float, optional
|
|
115
|
+
If specified, HOTPANTS ``rss`` parameters will be set to :code:`image_fwhm*rel_rss`.
|
|
116
|
+
nx : int, optional
|
|
117
|
+
Number of image sub-regions in ``x`` direction.
|
|
118
|
+
ny : int, optional
|
|
119
|
+
Number of image sub-regions in ``y`` direction.
|
|
120
|
+
obj : astropy.table.Table, optional
|
|
121
|
+
List of objects detected on science image. If provided, it will be used to
|
|
122
|
+
better place the sub-stamps used to derive the convolution kernel.
|
|
123
|
+
get_convolved : bool, optional
|
|
124
|
+
Whether to also return the convolved template image.
|
|
125
|
+
get_scaled : bool, optional
|
|
126
|
+
Whether to also return noise-normalized difference image.
|
|
127
|
+
get_noise : bool, optional
|
|
128
|
+
Whether to also return difference image noise model.
|
|
129
|
+
get_kernel : bool, optional
|
|
130
|
+
Whether to also return convolution kernel used in the subtraction.
|
|
131
|
+
get_header : bool, optional
|
|
132
|
+
Whether to also return the FITS header from HOTPANTS output file.
|
|
133
|
+
_workdir : str, optional
|
|
134
|
+
If specified, all temporary files will be created in this directory, and will
|
|
135
|
+
be kept intact after running HOTPANTS. May be used for debugging.
|
|
136
|
+
_tmpdir : str, optional
|
|
137
|
+
If specified, all temporary files will be created in a dedicated directory
|
|
138
|
+
(that will be deleted after running the executable) inside this path.
|
|
139
|
+
_exe : str, optional
|
|
140
|
+
Full path to HOTPANTS executable. If not provided, the code tries to locate
|
|
141
|
+
it automatically in your :envvar:`PATH`.
|
|
142
|
+
verbose : bool or callable, optional
|
|
143
|
+
Whether to show verbose messages during the run of the function or not.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
numpy.ndarray or list
|
|
148
|
+
The difference image and, optionally, other kinds of images as requested
|
|
149
|
+
by the ``get_*`` options above.
|
|
150
|
+
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
154
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
155
|
+
|
|
156
|
+
# Find the binary
|
|
157
|
+
binname = None
|
|
158
|
+
|
|
159
|
+
if _exe is not None:
|
|
160
|
+
# Check user-provided binary path, and fail if not found
|
|
161
|
+
if os.path.isfile(_exe):
|
|
162
|
+
binname = _exe
|
|
163
|
+
else:
|
|
164
|
+
# Find HOTPANTS binary in common paths
|
|
165
|
+
for exe in ['hotpants']:
|
|
166
|
+
binname = shutil.which(exe)
|
|
167
|
+
if binname is not None:
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
if binname is None:
|
|
171
|
+
log("Can't find HOTPANTS binary")
|
|
172
|
+
return None
|
|
173
|
+
# else:
|
|
174
|
+
# log("Using HOTPANTS binary at", binname)
|
|
175
|
+
|
|
176
|
+
if mask is None:
|
|
177
|
+
mask = ~np.isfinite(image)
|
|
178
|
+
else:
|
|
179
|
+
mask = mask | ~np.isfinite(image)
|
|
180
|
+
|
|
181
|
+
if template_mask is None:
|
|
182
|
+
template_mask = ~np.isfinite(template)
|
|
183
|
+
else:
|
|
184
|
+
template_mask = template_mask | ~np.isfinite(template)
|
|
185
|
+
|
|
186
|
+
imin, imax = np.min(image[~mask]), np.max(image[~mask])
|
|
187
|
+
tmin, tmax = np.min(template[~template_mask]), np.max(template[~template_mask])
|
|
188
|
+
|
|
189
|
+
# As HOTPANTS uses inclusive checks for high values, let's extend their range just a bit
|
|
190
|
+
imax += 0.01 * (imax - imin)
|
|
191
|
+
tmax += 0.01 * (tmax - tmin)
|
|
192
|
+
|
|
193
|
+
# Logic from https://arxiv.org/pdf/1608.01006.pdf
|
|
194
|
+
imin = np.median(image[~mask]) - 10 * mad_std(image[~mask])
|
|
195
|
+
tmin = np.median(template[~template_mask]) - 10 * mad_std(template[~template_mask])
|
|
196
|
+
|
|
197
|
+
_nan = 1e-30 #
|
|
198
|
+
|
|
199
|
+
workdir = _workdir if _workdir is not None else tempfile.mkdtemp(prefix='hotpants', dir=_tmpdir)
|
|
200
|
+
|
|
201
|
+
image = image.copy()
|
|
202
|
+
image[~np.isfinite(image)] = np.nanmedian(image) # _nan
|
|
203
|
+
# image[mask] = _nan
|
|
204
|
+
imagename = os.path.join(workdir, 'image.fits')
|
|
205
|
+
fits.writeto(imagename, image, overwrite=True)
|
|
206
|
+
imaskname = os.path.join(workdir, 'imask.fits')
|
|
207
|
+
fits.writeto(imaskname, mask.astype(np.uint16), overwrite=True)
|
|
208
|
+
|
|
209
|
+
template = template.copy()
|
|
210
|
+
template[~np.isfinite(template)] = np.nanmedian(template) # _nan
|
|
211
|
+
# template[template_mask] = _nan
|
|
212
|
+
templatename = os.path.join(workdir, 'template.fits')
|
|
213
|
+
fits.writeto(templatename, template, overwrite=True)
|
|
214
|
+
tmaskname = os.path.join(workdir, 'tmask.fits')
|
|
215
|
+
fits.writeto(tmaskname, template_mask.astype(np.uint16), overwrite=True)
|
|
216
|
+
|
|
217
|
+
outname = os.path.join(workdir, 'diff.fits')
|
|
218
|
+
|
|
219
|
+
stampname = os.path.join(workdir, 'stamps.reg')
|
|
220
|
+
|
|
221
|
+
if not ny:
|
|
222
|
+
ny = nx
|
|
223
|
+
|
|
224
|
+
params = {
|
|
225
|
+
'inim': imagename,
|
|
226
|
+
'tmplim': templatename,
|
|
227
|
+
'outim': outname,
|
|
228
|
+
'savexy': stampname,
|
|
229
|
+
# Masks
|
|
230
|
+
'imi': imaskname,
|
|
231
|
+
'tmi': tmaskname,
|
|
232
|
+
# Lower and upper valid values for image
|
|
233
|
+
'il': imin,
|
|
234
|
+
'iu': imax,
|
|
235
|
+
# Lower and upper valid values for template
|
|
236
|
+
'tl': tmin,
|
|
237
|
+
'tu': tmax,
|
|
238
|
+
'tuk': tmax, # Limit used for kernel estimation, why not the same as 'tu'?..
|
|
239
|
+
# Normalize result to input image
|
|
240
|
+
'n': 'i',
|
|
241
|
+
# Return all possible image planes as a result
|
|
242
|
+
'allm': True,
|
|
243
|
+
# Convolve template image
|
|
244
|
+
'c': 't',
|
|
245
|
+
# Add kernel info to the output
|
|
246
|
+
'hki': True,
|
|
247
|
+
# Number of sub-regions
|
|
248
|
+
'nrx': nx,
|
|
249
|
+
'nry': ny,
|
|
250
|
+
# Disable positional variance of the kernel and background
|
|
251
|
+
'ko': 0,
|
|
252
|
+
'bgo': 0,
|
|
253
|
+
'v': 2,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
# Error map for input image
|
|
257
|
+
if err is not None:
|
|
258
|
+
if type(err) is bool and err == True:
|
|
259
|
+
err, _ = _build_err_map(image, mask, gain=image_gain, verbose_log=log)
|
|
260
|
+
|
|
261
|
+
if hasattr(err, 'shape'):
|
|
262
|
+
errname = os.path.join(workdir, 'err.fits')
|
|
263
|
+
params['ini'] = errname
|
|
264
|
+
fits.writeto(errname, err, overwrite=True)
|
|
265
|
+
|
|
266
|
+
if template_err is not None:
|
|
267
|
+
if type(template_err) is bool and template_err == True:
|
|
268
|
+
template_err, _ = _build_err_map(
|
|
269
|
+
template, template_mask, gain=template_gain, verbose_log=log
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if hasattr(template_err, 'shape'):
|
|
273
|
+
terrname = os.path.join(workdir, 'terr.fits')
|
|
274
|
+
params['tni'] = terrname
|
|
275
|
+
fits.writeto(terrname, template_err, overwrite=True)
|
|
276
|
+
|
|
277
|
+
if image_fwhm is not None and template_fwhm is not None:
|
|
278
|
+
# Recommended logic for sigma_match
|
|
279
|
+
if image_fwhm > template_fwhm:
|
|
280
|
+
sigma_match = np.sqrt(image_fwhm**2 - template_fwhm**2) / 2.35
|
|
281
|
+
sigma_match = max(1.0, sigma_match)
|
|
282
|
+
params['ng'] = [
|
|
283
|
+
3,
|
|
284
|
+
6,
|
|
285
|
+
0.5 * sigma_match,
|
|
286
|
+
4,
|
|
287
|
+
1.0 * sigma_match,
|
|
288
|
+
2,
|
|
289
|
+
2.0 * sigma_match,
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
elif image_fwhm < template_fwhm:
|
|
293
|
+
sigma_match = np.sqrt(template_fwhm**2 - image_fwhm**2) / 2.35
|
|
294
|
+
sigma_match = max(1.0, sigma_match)
|
|
295
|
+
params['ng'] = [
|
|
296
|
+
3,
|
|
297
|
+
6,
|
|
298
|
+
0.5 * sigma_match,
|
|
299
|
+
4,
|
|
300
|
+
1.0 * sigma_match,
|
|
301
|
+
2,
|
|
302
|
+
2.0 * sigma_match,
|
|
303
|
+
]
|
|
304
|
+
# TODO: switch to convolve-image mode?..
|
|
305
|
+
|
|
306
|
+
if image_fwhm is not None:
|
|
307
|
+
# Logic from https://arxiv.org/pdf/1608.01006.pdf suggests 2.5 and 6 here
|
|
308
|
+
params['r'] = int(np.ceil(image_fwhm * rel_r))
|
|
309
|
+
params['rss'] = int(np.ceil(image_fwhm * rel_rss))
|
|
310
|
+
|
|
311
|
+
if image_gain is not None:
|
|
312
|
+
params['ig'] = image_gain
|
|
313
|
+
|
|
314
|
+
if template_gain is not None:
|
|
315
|
+
params['tg'] = template_gain
|
|
316
|
+
|
|
317
|
+
if extra is not None:
|
|
318
|
+
params.update(extra)
|
|
319
|
+
|
|
320
|
+
# Set the stamp locations from detected objects
|
|
321
|
+
if obj is not None and len(obj):
|
|
322
|
+
log('Using %d external positions for substamps' % len(obj))
|
|
323
|
+
xyname = os.path.join(workdir, 'objects.xy')
|
|
324
|
+
np.savetxt(xyname, [[_['x'] + 1, _['y'] + 1] for _ in Table(obj)], fmt='%.1f')
|
|
325
|
+
params['ssf'] = xyname
|
|
326
|
+
|
|
327
|
+
# Build command line
|
|
328
|
+
command = [binname]
|
|
329
|
+
|
|
330
|
+
for key in params.keys():
|
|
331
|
+
if params[key] is None:
|
|
332
|
+
pass
|
|
333
|
+
elif type(params[key]) == bool:
|
|
334
|
+
if params[key]:
|
|
335
|
+
command.append('-' + key)
|
|
336
|
+
else:
|
|
337
|
+
pass
|
|
338
|
+
else:
|
|
339
|
+
if type(params[key]) == str:
|
|
340
|
+
# Quote string if necessary
|
|
341
|
+
value = shlex.quote(params[key])
|
|
342
|
+
elif hasattr(params[key], '__len__'):
|
|
343
|
+
# List or array, for multi-valued arguments
|
|
344
|
+
value = " ".join([str(_) for _ in params[key]])
|
|
345
|
+
else:
|
|
346
|
+
value = str(params[key])
|
|
347
|
+
|
|
348
|
+
command.append('-' + key + ' ' + value)
|
|
349
|
+
|
|
350
|
+
command = " ".join(command)
|
|
351
|
+
|
|
352
|
+
if not verbose:
|
|
353
|
+
command += " >/dev/null 2>/dev/null"
|
|
354
|
+
|
|
355
|
+
log('Will run HOTPANTS like that:')
|
|
356
|
+
log(command)
|
|
357
|
+
|
|
358
|
+
if os.path.exists(outname):
|
|
359
|
+
os.unlink(outname)
|
|
360
|
+
|
|
361
|
+
# Run the command
|
|
362
|
+
os.system(command)
|
|
363
|
+
|
|
364
|
+
if not os.path.exists(outname):
|
|
365
|
+
log('HOTPANTS run failed')
|
|
366
|
+
return None
|
|
367
|
+
elif not np.any(fits.getdata(outname, 0)):
|
|
368
|
+
log('HOTPANTS failed to perform subtraction')
|
|
369
|
+
return None
|
|
370
|
+
else:
|
|
371
|
+
log('HOTPANTS run succeeded')
|
|
372
|
+
|
|
373
|
+
# Difference image
|
|
374
|
+
result = [fits.getdata(outname, 0)]
|
|
375
|
+
|
|
376
|
+
header = fits.getheader(outname)
|
|
377
|
+
|
|
378
|
+
if os.path.exists(stampname):
|
|
379
|
+
with open(stampname, 'r') as f:
|
|
380
|
+
lines = f.readlines()
|
|
381
|
+
lines = lines[1:]
|
|
382
|
+
|
|
383
|
+
header['NSTAMPS'] = len(lines)
|
|
384
|
+
|
|
385
|
+
log('%d stamps used' % len(lines))
|
|
386
|
+
|
|
387
|
+
if get_convolved:
|
|
388
|
+
# Convolved image
|
|
389
|
+
conv = fits.getdata(outname, 1)
|
|
390
|
+
result.append(conv)
|
|
391
|
+
|
|
392
|
+
if get_scaled:
|
|
393
|
+
# Noise-scaled difference image
|
|
394
|
+
sdiff = fits.getdata(outname, 2)
|
|
395
|
+
result.append(sdiff)
|
|
396
|
+
|
|
397
|
+
if get_noise:
|
|
398
|
+
# Noise image
|
|
399
|
+
noise = fits.getdata(outname, 3)
|
|
400
|
+
result.append(noise)
|
|
401
|
+
|
|
402
|
+
if get_kernel:
|
|
403
|
+
# Kernel information
|
|
404
|
+
kernel = Table.read(outname, format='fits', hdu=4)
|
|
405
|
+
result.append(kernel)
|
|
406
|
+
|
|
407
|
+
if get_header:
|
|
408
|
+
# Header with metadata
|
|
409
|
+
result.append(header)
|
|
410
|
+
|
|
411
|
+
if _workdir is None:
|
|
412
|
+
shutil.rmtree(workdir)
|
|
413
|
+
|
|
414
|
+
if len(result) == 1:
|
|
415
|
+
return result[0]
|
|
416
|
+
else:
|
|
417
|
+
return result
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def run_zogy(
|
|
421
|
+
image,
|
|
422
|
+
template,
|
|
423
|
+
mask=None,
|
|
424
|
+
template_mask=None,
|
|
425
|
+
err=None,
|
|
426
|
+
template_err=None,
|
|
427
|
+
image_psf=None,
|
|
428
|
+
template_psf=None,
|
|
429
|
+
image_gain=None,
|
|
430
|
+
template_gain=None,
|
|
431
|
+
image_fwhm=None,
|
|
432
|
+
template_fwhm=None,
|
|
433
|
+
scale=None,
|
|
434
|
+
psf_clean=0,
|
|
435
|
+
dx=0.25,
|
|
436
|
+
dy=0.25,
|
|
437
|
+
nx=1,
|
|
438
|
+
ny=None,
|
|
439
|
+
overlap=50,
|
|
440
|
+
fit_scale=True,
|
|
441
|
+
fit_shift=True,
|
|
442
|
+
good_regions=None,
|
|
443
|
+
image_obj=None,
|
|
444
|
+
template_obj=None,
|
|
445
|
+
get_psf=False,
|
|
446
|
+
get_Fpsf=False,
|
|
447
|
+
nthreads=0,
|
|
448
|
+
verbose=False,
|
|
449
|
+
**kwargs,
|
|
450
|
+
):
|
|
451
|
+
"""Image subtraction using Zackay–Ofek–Gal-Yam (ZOGY) algorithm, as described in
|
|
452
|
+
http://dx.doi.org/10.3847/0004-637X/830/1/27
|
|
453
|
+
|
|
454
|
+
The science and template images should be already aligned astrometrically, and roughly
|
|
455
|
+
matched photometrically, i.e. have similar flux scale. There is an option to fit for
|
|
456
|
+
the small difference in flux scales (`fit_scale`), which is enabled by default. Also,
|
|
457
|
+
optionally (if `fit_shift` is set) it may fit for a small systematic positional error
|
|
458
|
+
(sub-pixel shift) of the template.
|
|
459
|
+
|
|
460
|
+
Required inputs are science and template images (plus optionally their
|
|
461
|
+
masks), their noise maps and PSFs, as well as their relative flux scale. If
|
|
462
|
+
noise maps are not provided, they will be constructed from the input images
|
|
463
|
+
using estimated background noise and gain values for source contributions.
|
|
464
|
+
If PSFs are not specified, they will be also estimated from the images
|
|
465
|
+
using :func:`stdpipe.psf.run_psfex`. Flux normalization for PSF estimation
|
|
466
|
+
also requires FWHMs of the images, which may either be specified directly,
|
|
467
|
+
or estimated from the images too.
|
|
468
|
+
|
|
469
|
+
Relative template image flux scaling may either be provided as `scale`
|
|
470
|
+
argument, or it may be estimated from the lists of objects detected in both
|
|
471
|
+
images. These may either be provided directly (`image_obj` and
|
|
472
|
+
`template_obj`), or derived during PSF estimation. These objects will also
|
|
473
|
+
be used for restricting the scale/shift fitting to their vicinities
|
|
474
|
+
only. Alternatively, the map of good regions may be directly provided as
|
|
475
|
+
`good_regions` argument.
|
|
476
|
+
|
|
477
|
+
The image will be optionally split into given number (`nx`x`ny`) of
|
|
478
|
+
sub-images, subtracted independently, and then the results will be stitched
|
|
479
|
+
back. Sub-images will overlap by `overlap` pixels.
|
|
480
|
+
|
|
481
|
+
Parameters
|
|
482
|
+
----------
|
|
483
|
+
image : numpy.ndarray
|
|
484
|
+
Input science image as a NumPy array.
|
|
485
|
+
template : numpy.ndarray
|
|
486
|
+
Input template image, should have the same shape as the science image.
|
|
487
|
+
mask : numpy.ndarray, optional
|
|
488
|
+
Science image mask as a boolean array (True values will be masked).
|
|
489
|
+
template_mask : numpy.ndarray, optional
|
|
490
|
+
Template image mask as a boolean array (True values will be masked).
|
|
491
|
+
err : numpy.ndarray, optional
|
|
492
|
+
Science image noise map (expected RMS of every pixel). If not set, the code
|
|
493
|
+
will try to build the noise map directly from the image and ``image_gain`` parameter.
|
|
494
|
+
template_err : numpy.ndarray, optional
|
|
495
|
+
Template image noise map. If not set, the code will try to build the noise map
|
|
496
|
+
directly from the template and ``template_gain`` parameter.
|
|
497
|
+
image_gain : float, optional
|
|
498
|
+
Gain of science image, to be used for construction of the noise map.
|
|
499
|
+
template_gain : float, optional
|
|
500
|
+
Gain of template image, to be used for construction of the noise map.
|
|
501
|
+
image_psf : dict, optional
|
|
502
|
+
PSF for science image. If not provided, will be estimated by
|
|
503
|
+
:func:`stdpipe.psf.run_psfex`.
|
|
504
|
+
template_psf : dict, optional
|
|
505
|
+
PSF for template image. If not provided, will be estimated by
|
|
506
|
+
:func:`stdpipe.psf.run_psfex`.
|
|
507
|
+
image_fwhm : float, optional
|
|
508
|
+
FWHM of science image, to be used for estimated PSF normalization.
|
|
509
|
+
template_fwhm : float, optional
|
|
510
|
+
FWHM of template image, to be used for estimated PSF normalization.
|
|
511
|
+
scale : float, optional
|
|
512
|
+
Template image flux scale relative to science image.
|
|
513
|
+
psf_clean : float, optional
|
|
514
|
+
If non-zero, will clean the PSF regions with relative values less than this factor.
|
|
515
|
+
dx : float, optional
|
|
516
|
+
Astrometric uncertainty (sigma) in x coordinate.
|
|
517
|
+
dy : float, optional
|
|
518
|
+
Astrometric uncertainty (sigma) in y coordinate.
|
|
519
|
+
nx : int, optional
|
|
520
|
+
Number of sub-images in ``x`` direction.
|
|
521
|
+
ny : int, optional
|
|
522
|
+
Number of sub-images in ``y`` direction.
|
|
523
|
+
overlap : int, optional
|
|
524
|
+
If set, defines how much sub-images will overlap, in pixels.
|
|
525
|
+
fit_scale : bool, optional
|
|
526
|
+
If set, will fit for the difference in flux scales between template and science images.
|
|
527
|
+
fit_shift : bool, optional
|
|
528
|
+
If set, will also fit for the sub-pixel shift between template and science images.
|
|
529
|
+
good_regions : numpy.ndarray, optional
|
|
530
|
+
If set, this boolean map will be used to restrict scale/shift fitting to only these regions.
|
|
531
|
+
image_obj : astropy.table.Table, optional
|
|
532
|
+
List of objects detected in science image. If provided, they will be used for placing good regions.
|
|
533
|
+
template_obj : astropy.table.Table, optional
|
|
534
|
+
List of objects detected in template image. If provided, they will be used for deriving
|
|
535
|
+
template flux scale.
|
|
536
|
+
get_psf : bool, optional
|
|
537
|
+
If set, will also return the PSF of the difference image.
|
|
538
|
+
get_Fpsf : bool, optional
|
|
539
|
+
If set, will also return the optimal PSF photometry image and its error.
|
|
540
|
+
nthreads : int, optional
|
|
541
|
+
Set the number of threads to use for FFTW routines (0 for auto).
|
|
542
|
+
verbose : bool or callable, optional
|
|
543
|
+
Whether to show verbose messages during the run of the function or not.
|
|
544
|
+
**kwargs
|
|
545
|
+
Extra parameters to be passed to :func:`stdpipe.psf.run_psfex` for PSF estimation.
|
|
546
|
+
|
|
547
|
+
Returns
|
|
548
|
+
-------
|
|
549
|
+
list
|
|
550
|
+
The list of the difference image and other subtraction results:
|
|
551
|
+
|
|
552
|
+
- D: Subtracted image
|
|
553
|
+
- S_corr: Corrected subtracted image
|
|
554
|
+
- P_D: PSF of subtracted image, if :code:`get_psf=True`
|
|
555
|
+
- Fpsf: optimal PSF photometry image, if :code:`get_Fpsf=True`
|
|
556
|
+
- Fpsf_err: optimal PSF photometry error image, if :code:`get_Fpsf=True`
|
|
557
|
+
|
|
558
|
+
"""
|
|
559
|
+
|
|
560
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
561
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
562
|
+
|
|
563
|
+
if mask is None:
|
|
564
|
+
mask = ~np.isfinite(image)
|
|
565
|
+
else:
|
|
566
|
+
mask = mask | ~np.isfinite(image)
|
|
567
|
+
|
|
568
|
+
if template_mask is None:
|
|
569
|
+
template_mask = ~np.isfinite(template)
|
|
570
|
+
else:
|
|
571
|
+
template_mask = template_mask | ~np.isfinite(template)
|
|
572
|
+
|
|
573
|
+
# Build noise models and subtract background
|
|
574
|
+
if err is None:
|
|
575
|
+
U_N_full, image_bg = _build_err_map(image, mask, gain=image_gain, verbose_log=log)
|
|
576
|
+
SN = image_bg.globalrms
|
|
577
|
+
else:
|
|
578
|
+
image_bg = sep.Background(image, mask)
|
|
579
|
+
U_N_full = err
|
|
580
|
+
SN = np.median(err)
|
|
581
|
+
|
|
582
|
+
if template_err is None:
|
|
583
|
+
U_R_full, template_bg = _build_err_map(
|
|
584
|
+
template.astype(np.double), template_mask, gain=template_gain, verbose_log=log
|
|
585
|
+
)
|
|
586
|
+
SR = template_bg.globalrms
|
|
587
|
+
else:
|
|
588
|
+
template_bg = sep.Background(template.astype(np.double), template_mask)
|
|
589
|
+
U_R_full = template_err
|
|
590
|
+
SR = np.median(template_err)
|
|
591
|
+
|
|
592
|
+
N_full = image - image_bg.back()
|
|
593
|
+
R_full = template - template_bg.back()
|
|
594
|
+
# Set to zero the regions where we have no data
|
|
595
|
+
N_full[~np.isfinite(image)] = 0
|
|
596
|
+
R_full[~np.isfinite(template)] = 0
|
|
597
|
+
|
|
598
|
+
# Artificially assign large uncertainty to the regions we set to zero
|
|
599
|
+
U_N_full[~np.isfinite(image)] = np.nanmax(U_N_full)
|
|
600
|
+
U_R_full[~np.isfinite(template)] = np.nanmax(U_R_full)
|
|
601
|
+
|
|
602
|
+
# Estimate image PSF, if not specified
|
|
603
|
+
if image_psf is None:
|
|
604
|
+
log("Estimating image PSF")
|
|
605
|
+
# TODO: should we overwrite image_obj if it was set by the user?..
|
|
606
|
+
image_psf, image_obj = psf.run_psfex(
|
|
607
|
+
image,
|
|
608
|
+
mask=mask,
|
|
609
|
+
gain=image_gain,
|
|
610
|
+
aper=2.0 * image_fwhm if image_fwhm else None,
|
|
611
|
+
verbose=verbose,
|
|
612
|
+
get_obj=True,
|
|
613
|
+
**kwargs,
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
# Estimate template PSF, if not specified
|
|
617
|
+
if template_psf is None:
|
|
618
|
+
log("Estimating template PSF")
|
|
619
|
+
# TODO: should we overwrite image_obj if it was set by the user?..
|
|
620
|
+
template_psf, template_obj = psf.run_psfex(
|
|
621
|
+
template,
|
|
622
|
+
mask=template_mask,
|
|
623
|
+
gain=template_gain,
|
|
624
|
+
aper=2.0 * template_fwhm if template_fwhm else None,
|
|
625
|
+
verbose=verbose,
|
|
626
|
+
get_obj=True,
|
|
627
|
+
**kwargs,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
# "Good" regions for flux scale refinement
|
|
631
|
+
if good_regions is not None and isinstance(good_regions, np.ndarray):
|
|
632
|
+
good_regions = good_regions.astype(np.bool)
|
|
633
|
+
log("Will use user-provided regions for scale/shift fitting")
|
|
634
|
+
else:
|
|
635
|
+
good_regions = np.zeros_like(image, dtype=bool)
|
|
636
|
+
|
|
637
|
+
# Guess initial relative flux scale
|
|
638
|
+
if image_obj and template_obj:
|
|
639
|
+
# Use half FWHM radius for matching
|
|
640
|
+
sr = 0.5 * max(image_psf['fwhm'], template_psf['fwhm'])
|
|
641
|
+
iidx, tidx, _ = astrometry.planar_match(
|
|
642
|
+
image_obj['x'], image_obj['y'], template_obj['x'], template_obj['y'], sr
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
if len(iidx):
|
|
646
|
+
# Exclude all flagged objects
|
|
647
|
+
fidx = (image_obj['flags'][iidx] & 0x100) == 0
|
|
648
|
+
fidx &= (template_obj['flags'][tidx] & 0x100) == 0
|
|
649
|
+
|
|
650
|
+
if np.sum(fidx) > 5:
|
|
651
|
+
# Compute photometric zero point shift between two object lists
|
|
652
|
+
ZP = template_obj['mag'][tidx][fidx] - image_obj['mag'][iidx][fidx]
|
|
653
|
+
dZP = np.hypot(image_obj['magerr'][iidx][fidx], template_obj['magerr'][tidx][fidx])
|
|
654
|
+
X = np.ones_like(ZP).T
|
|
655
|
+
# Weighted robust regression
|
|
656
|
+
C = sm.RLM(ZP / dZP, (X.T / dZP).T).fit()
|
|
657
|
+
scale = 10 ** (-0.4 * C.params[0])
|
|
658
|
+
log("Estimated initial template flux scale Fr = %.3g" % scale)
|
|
659
|
+
else:
|
|
660
|
+
log("Not enough matched objects to guess template flux scale")
|
|
661
|
+
|
|
662
|
+
if len(iidx) > 0 and (fit_scale or fit_shift) and np.sum(good_regions) == 0:
|
|
663
|
+
# Use matched objects (they are all unflagged) to define good regions
|
|
664
|
+
size = int(6 * sr) # 3*FWHM
|
|
665
|
+
N = 0
|
|
666
|
+
for i in range(len(iidx)):
|
|
667
|
+
x0 = int(image_obj['x'][iidx][i])
|
|
668
|
+
y0 = int(image_obj['y'][iidx][i])
|
|
669
|
+
|
|
670
|
+
# Exclude objects too close to the edges
|
|
671
|
+
if (
|
|
672
|
+
x0 < size
|
|
673
|
+
or x0 >= image.shape[1] - size
|
|
674
|
+
or y0 < size
|
|
675
|
+
or y0 >= image.shape[0] - size
|
|
676
|
+
):
|
|
677
|
+
continue
|
|
678
|
+
|
|
679
|
+
# Exclude sub-regions containing masked pixels
|
|
680
|
+
if (
|
|
681
|
+
np.sum((mask | template_mask)[y0 - size : y0 + size, x0 - size : x0 + size])
|
|
682
|
+
== 0
|
|
683
|
+
):
|
|
684
|
+
good_regions[y0 - size : y0 + size, x0 - size : x0 + size] = True
|
|
685
|
+
N += 1
|
|
686
|
+
|
|
687
|
+
if N > 0:
|
|
688
|
+
log(N, "regions selected for scale/shift fitting")
|
|
689
|
+
|
|
690
|
+
else:
|
|
691
|
+
log("Initial template flux scale Fr = %.3g" % scale)
|
|
692
|
+
|
|
693
|
+
if (fit_scale or fit_shift) and np.sum(good_regions) == 0:
|
|
694
|
+
good_regions = np.ones_like(good_regions, dtype=bool)
|
|
695
|
+
log("Will fit for scale/shift using whole image")
|
|
696
|
+
|
|
697
|
+
# Split the image into sub-images to later stitch it back
|
|
698
|
+
if not ny:
|
|
699
|
+
ny = nx
|
|
700
|
+
|
|
701
|
+
D_full = np.zeros_like(image, dtype=np.double)
|
|
702
|
+
S_corr_full = np.zeros_like(image, dtype=np.double)
|
|
703
|
+
D_full = np.zeros_like(image, dtype=np.double)
|
|
704
|
+
Fpsf_full = np.zeros_like(image, dtype=np.double)
|
|
705
|
+
Fpsf_err_full = np.zeros_like(image, dtype=np.double)
|
|
706
|
+
|
|
707
|
+
for i, x0, y0, N, R, U_N, U_R, good_reg in pipeline.split_image(
|
|
708
|
+
N_full,
|
|
709
|
+
R_full,
|
|
710
|
+
U_N_full,
|
|
711
|
+
U_R_full,
|
|
712
|
+
good_regions,
|
|
713
|
+
nx=nx,
|
|
714
|
+
ny=ny,
|
|
715
|
+
overlap=overlap,
|
|
716
|
+
get_index=True,
|
|
717
|
+
get_origin=True,
|
|
718
|
+
verbose=True if nx > 1 and ny > 1 else False,
|
|
719
|
+
):
|
|
720
|
+
# PSF stamps at the centers of sub-image
|
|
721
|
+
P_N_small = psf.get_psf_stamp(
|
|
722
|
+
image_psf, x=x0 + N.shape[1] / 2, y=y0 + N.shape[0] / 2, dx=0, dy=0, normalize=True
|
|
723
|
+
)
|
|
724
|
+
P_R_small = psf.get_psf_stamp(
|
|
725
|
+
template_psf, x=x0 + N.shape[1] / 2, y=y0 + N.shape[0] / 2, dx=0, dy=0, normalize=True
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
if psf_clean:
|
|
729
|
+
# Set to zero the wings with relative amplitude less than psf_clean factor
|
|
730
|
+
P_N_small[P_N_small < psf_clean * np.max(P_N_small)] = 0
|
|
731
|
+
P_N_small /= np.sum(P_N_small)
|
|
732
|
+
|
|
733
|
+
P_R_small[P_R_small < psf_clean * np.max(P_R_small)] = 0
|
|
734
|
+
P_R_small /= np.sum(P_R_small)
|
|
735
|
+
|
|
736
|
+
Fn = 1 # Fixed, so that the difference will be in science image flux scale
|
|
737
|
+
Fr = scale if scale else 1 # Will be optionally adjusted later
|
|
738
|
+
|
|
739
|
+
# Place PSF at the center of image with same size as new / reference
|
|
740
|
+
P_N = np.zeros_like(N)
|
|
741
|
+
P_R = np.zeros_like(R)
|
|
742
|
+
idxN = tuple(
|
|
743
|
+
[
|
|
744
|
+
slice(
|
|
745
|
+
int(N.shape[0] / 2) - int(P_N_small.shape[0] / 2),
|
|
746
|
+
int(N.shape[0] / 2) + int(P_N_small.shape[0] / 2) + 1,
|
|
747
|
+
),
|
|
748
|
+
slice(
|
|
749
|
+
int(N.shape[1] / 2) - int(P_N_small.shape[1] / 2),
|
|
750
|
+
int(N.shape[1] / 2) + int(P_N_small.shape[1] / 2) + 1,
|
|
751
|
+
),
|
|
752
|
+
]
|
|
753
|
+
)
|
|
754
|
+
idxR = tuple(
|
|
755
|
+
[
|
|
756
|
+
slice(
|
|
757
|
+
int(R.shape[0] / 2) - int(P_R_small.shape[0] / 2),
|
|
758
|
+
int(R.shape[0] / 2) + int(P_R_small.shape[0] / 2) + 1,
|
|
759
|
+
),
|
|
760
|
+
slice(
|
|
761
|
+
int(R.shape[1] / 2) - int(P_R_small.shape[1] / 2),
|
|
762
|
+
int(R.shape[1] / 2) + int(P_R_small.shape[1] / 2) + 1,
|
|
763
|
+
),
|
|
764
|
+
]
|
|
765
|
+
)
|
|
766
|
+
P_N[idxN] = P_N_small
|
|
767
|
+
P_R[idxR] = P_R_small
|
|
768
|
+
|
|
769
|
+
# Shift the PSF to the origin so it will not introduce a shift
|
|
770
|
+
P_N = fft.fftshift(P_N)
|
|
771
|
+
P_R = fft.fftshift(P_R)
|
|
772
|
+
|
|
773
|
+
# Take all the Fourier Transforms
|
|
774
|
+
N_hat = fft.fft2(N, threads=nthreads)
|
|
775
|
+
R_hat = fft.fft2(R, threads=nthreads)
|
|
776
|
+
|
|
777
|
+
P_N_hat = fft.fft2(P_N, threads=nthreads)
|
|
778
|
+
P_R_hat = fft.fft2(P_R, threads=nthreads)
|
|
779
|
+
|
|
780
|
+
if fit_scale or fit_shift:
|
|
781
|
+
# Estimate beta=Fn/Fr and shift using Equations 37-39
|
|
782
|
+
def fn(par):
|
|
783
|
+
Fn = 1
|
|
784
|
+
Fr = par[0]
|
|
785
|
+
|
|
786
|
+
D_hat_den = np.sqrt(
|
|
787
|
+
SN**2 * Fr**2 * np.abs(P_R_hat**2) + SR**2 * Fn**2 * np.abs(P_N_hat**2)
|
|
788
|
+
)
|
|
789
|
+
D_hat_n = P_R_hat * N_hat / D_hat_den
|
|
790
|
+
D_hat_r = P_N_hat * R_hat / D_hat_den
|
|
791
|
+
|
|
792
|
+
if len(par) > 1:
|
|
793
|
+
D_hat_r = ndimage.fourier_shift(D_hat_r, par[1:])
|
|
794
|
+
|
|
795
|
+
DD_n = np.real(fft.ifft2(D_hat_n, threads=0))
|
|
796
|
+
DD_r = np.real(fft.ifft2(D_hat_r, threads=0))
|
|
797
|
+
|
|
798
|
+
return (Fr * DD_n - Fn * DD_r)[good_reg].flatten()
|
|
799
|
+
|
|
800
|
+
if np.sum(good_reg) > 10:
|
|
801
|
+
if fit_scale and fit_shift:
|
|
802
|
+
log('Fitting for flux scale difference and sub-pixel template shift')
|
|
803
|
+
C = least_squares(
|
|
804
|
+
fn, [scale, 0, 0], bounds=((0, -0.5, -0.5), (np.inf, 0.5, 0.5)), verbose=0
|
|
805
|
+
)
|
|
806
|
+
elif fit_scale:
|
|
807
|
+
log('Fitting for flux scale difference')
|
|
808
|
+
C = least_squares(fn, [scale], bounds=(0, np.inf), verbose=0)
|
|
809
|
+
else:
|
|
810
|
+
log('Fitting for sub-pixel template shift')
|
|
811
|
+
C = least_squares(
|
|
812
|
+
fn,
|
|
813
|
+
[scale, 0, 0],
|
|
814
|
+
bounds=((scale * 0.9999, -0.5, -0.5), (scale * 1.0001, 0.5, 0.5)),
|
|
815
|
+
verbose=0,
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
if C.success:
|
|
819
|
+
if fit_scale:
|
|
820
|
+
Fr = C.x[0]
|
|
821
|
+
log('Template flux scale Fr = %.3g' % Fr)
|
|
822
|
+
if len(C.x) > 1:
|
|
823
|
+
log('Shift is dy = %.2f dx = %.2f' % (C.x[1], C.x[2]))
|
|
824
|
+
R_hat = ndimage.fourier_shift(R_hat, C.x[1:])
|
|
825
|
+
else:
|
|
826
|
+
log('Fitting failed')
|
|
827
|
+
else:
|
|
828
|
+
log('Not enough good pixels for fitting')
|
|
829
|
+
|
|
830
|
+
# Fourier Transform of Difference Image (Equation 13)
|
|
831
|
+
D_hat_num = Fr * P_R_hat * N_hat - Fn * P_N_hat * R_hat
|
|
832
|
+
D_hat_den = np.sqrt(SN**2 * Fr**2 * np.abs(P_R_hat**2) + SR**2 * Fn**2 * np.abs(P_N_hat**2))
|
|
833
|
+
D_hat = D_hat_num / D_hat_den
|
|
834
|
+
|
|
835
|
+
# Flux-based zero point (Equation 15)
|
|
836
|
+
FD = Fr * Fn / np.sqrt(SN**2 * Fr**2 + SR**2 * Fn**2)
|
|
837
|
+
|
|
838
|
+
# Difference image corrected for correlated noise
|
|
839
|
+
D = np.real(fft.ifft2(D_hat, threads=nthreads)) / FD
|
|
840
|
+
|
|
841
|
+
# Fourier Transform of PSF of Subtraction Image (Equation 14)
|
|
842
|
+
P_D_hat = Fr * Fn * P_R_hat * P_N_hat / FD / D_hat_den
|
|
843
|
+
|
|
844
|
+
# PSF of Subtraction Image D
|
|
845
|
+
P_D = np.real(fft.ifft2(P_D_hat, threads=nthreads))
|
|
846
|
+
P_D = fft.ifftshift(P_D)
|
|
847
|
+
P_D = P_D[idxN]
|
|
848
|
+
|
|
849
|
+
# Fourier Transform of Score Image (Equation 17)
|
|
850
|
+
S_hat = FD * D_hat * np.conj(P_D_hat)
|
|
851
|
+
|
|
852
|
+
# Score Image
|
|
853
|
+
S = np.real(fft.ifft2(S_hat, threads=nthreads))
|
|
854
|
+
|
|
855
|
+
# Now start calculating Scorr matrix (including all noise terms)
|
|
856
|
+
|
|
857
|
+
# Start out with source noise
|
|
858
|
+
|
|
859
|
+
# Sigma to variance
|
|
860
|
+
V_N = U_N**2
|
|
861
|
+
V_R = U_R**2
|
|
862
|
+
|
|
863
|
+
# Fourier Transform of variance images
|
|
864
|
+
V_N_hat = fft.fft2(V_N, threads=nthreads)
|
|
865
|
+
V_R_hat = fft.fft2(V_R, threads=nthreads)
|
|
866
|
+
|
|
867
|
+
# Equation 28
|
|
868
|
+
kr_hat = Fr * Fn**2 * np.conj(P_R_hat) * np.abs(P_N_hat**2) / (D_hat_den**2)
|
|
869
|
+
kr = np.real(fft.ifft2(kr_hat, threads=nthreads))
|
|
870
|
+
|
|
871
|
+
# Equation 29
|
|
872
|
+
kn_hat = Fn * Fr**2 * np.conj(P_N_hat) * np.abs(P_R_hat**2) / (D_hat_den**2)
|
|
873
|
+
kn = np.real(fft.ifft2(kn_hat, threads=nthreads))
|
|
874
|
+
|
|
875
|
+
# Noise in New Image: Equation 26
|
|
876
|
+
V_S_N = np.real(fft.ifft2(V_N_hat * fft.fft2(kn**2, threads=nthreads), threads=nthreads))
|
|
877
|
+
|
|
878
|
+
# Noise in Reference Image: Equation 27
|
|
879
|
+
V_S_R = np.real(fft.ifft2(V_R_hat * fft.fft2(kr**2, threads=nthreads), threads=nthreads))
|
|
880
|
+
|
|
881
|
+
# Astrometric Noise
|
|
882
|
+
# Equation 31
|
|
883
|
+
S_N = np.real(fft.ifft2(kn_hat * N_hat, threads=nthreads))
|
|
884
|
+
dSNdx = S_N - np.roll(S_N, 1, axis=1)
|
|
885
|
+
dSNdy = S_N - np.roll(S_N, 1, axis=0)
|
|
886
|
+
|
|
887
|
+
# Equation 30
|
|
888
|
+
V_ast_S_N = dx**2 * dSNdx**2 + dy**2 * dSNdy**2
|
|
889
|
+
|
|
890
|
+
# Equation 33
|
|
891
|
+
S_R = np.real(fft.ifft2(kr_hat * R_hat))
|
|
892
|
+
dSRdx = S_R - np.roll(S_R, 1, axis=1)
|
|
893
|
+
dSRdy = S_R - np.roll(S_R, 1, axis=0)
|
|
894
|
+
|
|
895
|
+
# Equation 32
|
|
896
|
+
V_ast_S_R = dx**2 * dSRdx**2 + dy**2 * dSRdy**2
|
|
897
|
+
|
|
898
|
+
# Calculate Scorr
|
|
899
|
+
S_corr = S / np.sqrt(V_S_N + V_S_R + V_ast_S_N + V_ast_S_R)
|
|
900
|
+
|
|
901
|
+
# PSF photometry (Equations 41-43)
|
|
902
|
+
F_S = np.sum(Fn**2 * Fr**2 * np.abs(P_N_hat**2) * np.abs(P_R_hat**2) / (D_hat_den**2))
|
|
903
|
+
F_S /= S.shape[1] * S.shape[1] # divide by the number of pixels due to FFT normalization
|
|
904
|
+
Fpsf = S / F_S # optimal PSF photometry, alpha in Equation 41
|
|
905
|
+
Fpsf_err = np.sqrt(V_S_N + V_S_R) / F_S
|
|
906
|
+
|
|
907
|
+
# Stitch the piece back to the tapestry
|
|
908
|
+
D_full[y0 : y0 + N.shape[0], x0 : x0 + N.shape[1]] = D
|
|
909
|
+
S_corr_full[y0 : y0 + N.shape[0], x0 : x0 + N.shape[1]] = S_corr
|
|
910
|
+
Fpsf_full[y0 : y0 + N.shape[0], x0 : x0 + N.shape[1]] = Fpsf
|
|
911
|
+
Fpsf_err_full[y0 : y0 + N.shape[0], x0 : x0 + N.shape[1]] = Fpsf_err
|
|
912
|
+
|
|
913
|
+
result = [D_full, S_corr_full]
|
|
914
|
+
|
|
915
|
+
if get_psf:
|
|
916
|
+
result += [P_D]
|
|
917
|
+
|
|
918
|
+
if get_Fpsf:
|
|
919
|
+
result += [Fpsf_full, Fpsf_err_full]
|
|
920
|
+
|
|
921
|
+
return result
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _build_err_map(image, mask, gain=None, verbose_log=None):
|
|
925
|
+
"""Build a noise map from image and gain, following the HOTPANTS/ZOGY convention.
|
|
926
|
+
|
|
927
|
+
Combines background RMS from SEP with source Poisson noise contribution.
|
|
928
|
+
|
|
929
|
+
Returns
|
|
930
|
+
-------
|
|
931
|
+
err : (ny, nx) noise map (per-pixel standard deviation)
|
|
932
|
+
bg : sep.Background object
|
|
933
|
+
"""
|
|
934
|
+
log = verbose_log or (lambda *a, **k: None)
|
|
935
|
+
|
|
936
|
+
bg = sep.Background(image.astype(np.double), mask)
|
|
937
|
+
err = bg.rms() # Background noise level
|
|
938
|
+
|
|
939
|
+
# Source Poisson noise contribution
|
|
940
|
+
source = np.abs(image - bg.back())
|
|
941
|
+
if gain is not None and gain > 0:
|
|
942
|
+
source /= gain
|
|
943
|
+
else:
|
|
944
|
+
source[:] = 0
|
|
945
|
+
err = np.sqrt(err**2 + source)
|
|
946
|
+
|
|
947
|
+
log('Noise model: background %.1f, rms %.2f ADU' % (bg.globalback, bg.globalrms))
|
|
948
|
+
|
|
949
|
+
return err, bg
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def run_sfft(
|
|
953
|
+
image,
|
|
954
|
+
template,
|
|
955
|
+
mask=None,
|
|
956
|
+
template_mask=None,
|
|
957
|
+
err=None,
|
|
958
|
+
template_err=None,
|
|
959
|
+
image_gain=None,
|
|
960
|
+
template_gain=None,
|
|
961
|
+
kernel_shape=(7, 7),
|
|
962
|
+
kernel_poly_order=2,
|
|
963
|
+
bg_poly_order=2,
|
|
964
|
+
flux_poly_order=1,
|
|
965
|
+
flux_penalty=1e3,
|
|
966
|
+
ridge=1e-6,
|
|
967
|
+
sigma_clip=3.0,
|
|
968
|
+
max_iter=5,
|
|
969
|
+
obj=None,
|
|
970
|
+
obj_size=None,
|
|
971
|
+
obj_grid=4,
|
|
972
|
+
obj_per_cell=10,
|
|
973
|
+
obj_max_masked_fraction=0.5,
|
|
974
|
+
get_convolved=False,
|
|
975
|
+
get_scaled=False,
|
|
976
|
+
get_noise=False,
|
|
977
|
+
get_kernel=False,
|
|
978
|
+
verbose=False,
|
|
979
|
+
):
|
|
980
|
+
"""Image subtraction using SFFT with spatially varying kernel.
|
|
981
|
+
|
|
982
|
+
Wrapper around :func:`stdpipe.sfft.solve` that handles noise model
|
|
983
|
+
construction, difference image noise propagation, and follows the same
|
|
984
|
+
interface conventions as :func:`run_hotpants` and :func:`run_zogy`.
|
|
985
|
+
|
|
986
|
+
The noise model for both science and template images may be provided
|
|
987
|
+
directly (as arrays), or built automatically from the images using
|
|
988
|
+
background estimation and gain values (same approach as HOTPANTS).
|
|
989
|
+
If ``err`` or ``template_err`` is set to ``True``, the routine builds
|
|
990
|
+
a noise map from the image using SEP background estimation and the
|
|
991
|
+
corresponding gain parameter.
|
|
992
|
+
|
|
993
|
+
Parameters
|
|
994
|
+
----------
|
|
995
|
+
image : numpy.ndarray
|
|
996
|
+
Input science image as a NumPy array.
|
|
997
|
+
template : numpy.ndarray
|
|
998
|
+
Input template image, same shape as science image.
|
|
999
|
+
mask : numpy.ndarray, optional
|
|
1000
|
+
Science image mask (True = bad).
|
|
1001
|
+
template_mask : numpy.ndarray, optional
|
|
1002
|
+
Template image mask (True = bad).
|
|
1003
|
+
err : numpy.ndarray or bool, optional
|
|
1004
|
+
Science image noise map (per-pixel RMS). If True, built from the image
|
|
1005
|
+
and ``image_gain``.
|
|
1006
|
+
template_err : numpy.ndarray or bool, optional
|
|
1007
|
+
Template image noise map. If True, built from the template and
|
|
1008
|
+
``template_gain``. Used both for the difference image noise map and
|
|
1009
|
+
for fit weighting: the solver performs one reweighting pass with the
|
|
1010
|
+
template variance propagated through the fitted kernel (see
|
|
1011
|
+
:func:`stdpipe.sfft.solve`).
|
|
1012
|
+
image_gain : float, optional
|
|
1013
|
+
Gain of science image in e-/ADU.
|
|
1014
|
+
template_gain : float, optional
|
|
1015
|
+
Gain of template image in e-/ADU.
|
|
1016
|
+
kernel_shape : tuple of int, optional
|
|
1017
|
+
(ky, kx) kernel size, must be odd. Default (7, 7).
|
|
1018
|
+
kernel_poly_order : int, optional
|
|
1019
|
+
Polynomial order for kernel spatial variation. Default 2.
|
|
1020
|
+
bg_poly_order : int, optional
|
|
1021
|
+
Polynomial order for differential background. Default 2.
|
|
1022
|
+
flux_poly_order : int, optional
|
|
1023
|
+
Polynomial order for kernel-sum (flux scale) constraint. Default 1.
|
|
1024
|
+
flux_penalty : float, optional
|
|
1025
|
+
Penalty weight for kernel-sum constraint. Default 1e3.
|
|
1026
|
+
ridge : float, optional
|
|
1027
|
+
Tikhonov regularization. Default 1e-6.
|
|
1028
|
+
sigma_clip : float, optional
|
|
1029
|
+
Sigma threshold for outlier rejection. Default 3.0.
|
|
1030
|
+
max_iter : int, optional
|
|
1031
|
+
Maximum sigma-clipping iterations. Default 5.
|
|
1032
|
+
obj : astropy.table.Table, optional
|
|
1033
|
+
List of detected objects with ``x``, ``y`` columns (and optionally
|
|
1034
|
+
``flux``, ``fluxerr``, ``flags``, ``fwhm``). If provided, kernel
|
|
1035
|
+
fitting is restricted to square regions around selected objects,
|
|
1036
|
+
analogous to HOTPANTS stamp placement. This concentrates the fit on
|
|
1037
|
+
high-S/N regions and can improve kernel quality. Objects with
|
|
1038
|
+
nonzero ``flags`` are skipped, and the selection is spatially
|
|
1039
|
+
stratified (see ``obj_grid`` / ``obj_per_cell``) so the kernel and
|
|
1040
|
+
background spatial polynomials stay constrained across the field.
|
|
1041
|
+
If the selected regions provide too few pixels for the model
|
|
1042
|
+
parameters, the fit falls back to the whole image.
|
|
1043
|
+
obj_size : int, optional
|
|
1044
|
+
Half-size in pixels of the fitting region around each object. If None
|
|
1045
|
+
(default), derived from the median ``fwhm`` column of *obj*
|
|
1046
|
+
(``3 × median(fwhm)``), or 15 pixels if ``fwhm`` is not available.
|
|
1047
|
+
obj_grid : int, optional
|
|
1048
|
+
Number of grid cells per axis for spatially stratified object
|
|
1049
|
+
selection. Default 4.
|
|
1050
|
+
obj_per_cell : int, optional
|
|
1051
|
+
Maximum number of objects used per grid cell, highest S/N first
|
|
1052
|
+
(``flux``/``fluxerr``, or ``flux``, or input order). Default 10.
|
|
1053
|
+
Set to None to disable the per-cell cap.
|
|
1054
|
+
obj_max_masked_fraction : float, optional
|
|
1055
|
+
Maximum fraction of masked pixels allowed in an object region;
|
|
1056
|
+
regions above it are skipped. Default 0.5. Isolated masked pixels
|
|
1057
|
+
within accepted regions are harmless — the fit excludes them
|
|
1058
|
+
individually via the weight map.
|
|
1059
|
+
get_convolved : bool, optional
|
|
1060
|
+
Whether to also return the convolved template.
|
|
1061
|
+
get_scaled : bool, optional
|
|
1062
|
+
Whether to also return noise-normalized difference. Pixels where the
|
|
1063
|
+
difference is unreliable (image edges and kernel-footprint
|
|
1064
|
+
neighbourhoods of template defects, see
|
|
1065
|
+
:attr:`stdpipe.sfft.SFFTResult.dmask`) are set to zero.
|
|
1066
|
+
get_noise : bool, optional
|
|
1067
|
+
Whether to also return the difference image noise map.
|
|
1068
|
+
get_kernel : bool, optional
|
|
1069
|
+
Whether to also return the :class:`~stdpipe.sfft.SFFTResult` object
|
|
1070
|
+
containing kernel coefficients, fit metadata, and the ``dmask``
|
|
1071
|
+
attribute flagging unreliable difference pixels.
|
|
1072
|
+
verbose : bool or callable, optional
|
|
1073
|
+
Whether to show verbose messages.
|
|
1074
|
+
|
|
1075
|
+
Returns
|
|
1076
|
+
-------
|
|
1077
|
+
numpy.ndarray or list
|
|
1078
|
+
The difference image, or a list of requested planes in the same
|
|
1079
|
+
order as :func:`run_hotpants`:
|
|
1080
|
+
``[diff, convolved, scaled, noise, kernel]``, with unrequested
|
|
1081
|
+
planes omitted.
|
|
1082
|
+
"""
|
|
1083
|
+
|
|
1084
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
1085
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
1086
|
+
|
|
1087
|
+
# --- Masks ---
|
|
1088
|
+
if mask is None:
|
|
1089
|
+
mask = ~np.isfinite(image)
|
|
1090
|
+
else:
|
|
1091
|
+
mask = np.asarray(mask, dtype=bool) | ~np.isfinite(image)
|
|
1092
|
+
|
|
1093
|
+
if template_mask is None:
|
|
1094
|
+
template_mask = ~np.isfinite(template)
|
|
1095
|
+
else:
|
|
1096
|
+
template_mask = np.asarray(template_mask, dtype=bool) | ~np.isfinite(template)
|
|
1097
|
+
|
|
1098
|
+
combined_mask = mask | template_mask
|
|
1099
|
+
|
|
1100
|
+
# --- Build noise models ---
|
|
1101
|
+
image_f = np.asarray(image, dtype=np.float64)
|
|
1102
|
+
template_f = np.asarray(template, dtype=np.float64)
|
|
1103
|
+
|
|
1104
|
+
if err is True:
|
|
1105
|
+
log('Building noise model from the image')
|
|
1106
|
+
# SEP cannot handle NaN — replace with median for background estimation
|
|
1107
|
+
image_clean = image_f.copy()
|
|
1108
|
+
image_clean[~np.isfinite(image_clean)] = np.nanmedian(image_clean)
|
|
1109
|
+
err, _ = _build_err_map(image_clean, mask, gain=image_gain, verbose_log=log)
|
|
1110
|
+
elif err is not None:
|
|
1111
|
+
err = np.asarray(err, dtype=np.float64)
|
|
1112
|
+
|
|
1113
|
+
if template_err is True:
|
|
1114
|
+
log('Building noise model from the template')
|
|
1115
|
+
template_clean = template_f.copy()
|
|
1116
|
+
template_clean[~np.isfinite(template_clean)] = np.nanmedian(template_clean)
|
|
1117
|
+
template_err, _ = _build_err_map(
|
|
1118
|
+
template_clean,
|
|
1119
|
+
template_mask,
|
|
1120
|
+
gain=template_gain,
|
|
1121
|
+
verbose_log=log,
|
|
1122
|
+
)
|
|
1123
|
+
elif template_err is not None:
|
|
1124
|
+
template_err = np.asarray(template_err, dtype=np.float64)
|
|
1125
|
+
|
|
1126
|
+
# SFFT uses inverse-variance weighting internally when err is provided;
|
|
1127
|
+
# template_err additionally triggers a reweighting pass with template
|
|
1128
|
+
# noise propagated through the fitted kernel
|
|
1129
|
+
sfft_err = err # may be None for uniform weighting
|
|
1130
|
+
|
|
1131
|
+
# --- Build fitting mask from object positions ---
|
|
1132
|
+
fitting_mask = combined_mask
|
|
1133
|
+
if obj is not None and len(obj):
|
|
1134
|
+
obj = Table(obj)
|
|
1135
|
+
ny_img, nx_img = image.shape[:2]
|
|
1136
|
+
|
|
1137
|
+
# Determine region half-size
|
|
1138
|
+
if obj_size is not None:
|
|
1139
|
+
size = int(obj_size)
|
|
1140
|
+
elif 'fwhm' in obj.colnames:
|
|
1141
|
+
size = int(np.ceil(3 * np.nanmedian(obj['fwhm'])))
|
|
1142
|
+
else:
|
|
1143
|
+
size = 15
|
|
1144
|
+
|
|
1145
|
+
# Reject objects with detection quality flags (saturation, blending,
|
|
1146
|
+
# truncation, ...) — they should not constrain the kernel
|
|
1147
|
+
if 'flags' in obj.colnames:
|
|
1148
|
+
n_before = len(obj)
|
|
1149
|
+
obj = obj[np.asarray(obj['flags']) == 0]
|
|
1150
|
+
if len(obj) < n_before:
|
|
1151
|
+
log('Rejected %d flagged objects out of %d' % (n_before - len(obj), n_before))
|
|
1152
|
+
|
|
1153
|
+
# Order by S/N, brightest first, for the per-cell selection
|
|
1154
|
+
if 'flux' in obj.colnames:
|
|
1155
|
+
flux = np.asarray(obj['flux'], dtype=np.float64)
|
|
1156
|
+
if 'fluxerr' in obj.colnames:
|
|
1157
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
1158
|
+
sn = flux / np.asarray(obj['fluxerr'], dtype=np.float64)
|
|
1159
|
+
else:
|
|
1160
|
+
sn = flux
|
|
1161
|
+
order = np.argsort(-np.nan_to_num(sn, nan=-np.inf))
|
|
1162
|
+
else:
|
|
1163
|
+
order = np.arange(len(obj))
|
|
1164
|
+
|
|
1165
|
+
# Spatially stratified selection: up to obj_per_cell highest-S/N
|
|
1166
|
+
# objects per grid cell, so that the kernel and background spatial
|
|
1167
|
+
# polynomials are constrained across the whole field
|
|
1168
|
+
grid = max(1, int(obj_grid)) if obj_grid else 1
|
|
1169
|
+
cell_counts = np.zeros((grid, grid), dtype=int)
|
|
1170
|
+
|
|
1171
|
+
good_regions = np.zeros(image.shape[:2], dtype=bool)
|
|
1172
|
+
n_used = 0
|
|
1173
|
+
|
|
1174
|
+
for i in order:
|
|
1175
|
+
row = obj[int(i)]
|
|
1176
|
+
x0 = int(round(row['x']))
|
|
1177
|
+
y0 = int(round(row['y']))
|
|
1178
|
+
|
|
1179
|
+
# Skip objects too close to edges
|
|
1180
|
+
if x0 < size or x0 >= nx_img - size or y0 < size or y0 >= ny_img - size:
|
|
1181
|
+
continue
|
|
1182
|
+
|
|
1183
|
+
cell = (min(y0 * grid // ny_img, grid - 1), min(x0 * grid // nx_img, grid - 1))
|
|
1184
|
+
if obj_per_cell and cell_counts[cell] >= obj_per_cell:
|
|
1185
|
+
continue
|
|
1186
|
+
|
|
1187
|
+
# Skip regions dominated by masked pixels; isolated masked pixels
|
|
1188
|
+
# are harmless as the fit excludes them individually via weights
|
|
1189
|
+
region_mask = combined_mask[y0 - size : y0 + size + 1, x0 - size : x0 + size + 1]
|
|
1190
|
+
if np.mean(region_mask) > obj_max_masked_fraction:
|
|
1191
|
+
continue
|
|
1192
|
+
|
|
1193
|
+
good_regions[y0 - size : y0 + size + 1, x0 - size : x0 + size + 1] = True
|
|
1194
|
+
cell_counts[cell] += 1
|
|
1195
|
+
n_used += 1
|
|
1196
|
+
|
|
1197
|
+
# Restrict the fit only if the regions provide enough pixels for the
|
|
1198
|
+
# number of model parameters; otherwise fall back to the whole image
|
|
1199
|
+
n_params = kernel_shape[0] * kernel_shape[1] * sfft_module._n_poly(
|
|
1200
|
+
kernel_poly_order
|
|
1201
|
+
) + sfft_module._n_poly(bg_poly_order)
|
|
1202
|
+
n_region_pixels = int(np.sum(good_regions & ~combined_mask))
|
|
1203
|
+
|
|
1204
|
+
if n_used > 0 and n_region_pixels >= 10 * n_params:
|
|
1205
|
+
log(
|
|
1206
|
+
'Using %d object regions (%d px half-size, %d good pixels) '
|
|
1207
|
+
'for kernel fitting' % (n_used, size, n_region_pixels)
|
|
1208
|
+
)
|
|
1209
|
+
fitting_mask = combined_mask | ~good_regions
|
|
1210
|
+
elif n_used > 0:
|
|
1211
|
+
log(
|
|
1212
|
+
'Object regions provide only %d good pixels for %d parameters, '
|
|
1213
|
+
'fitting on whole image' % (n_region_pixels, n_params)
|
|
1214
|
+
)
|
|
1215
|
+
else:
|
|
1216
|
+
log('No usable object regions, fitting on whole image')
|
|
1217
|
+
|
|
1218
|
+
# --- Run SFFT ---
|
|
1219
|
+
log('Running SFFT subtraction')
|
|
1220
|
+
|
|
1221
|
+
sfft_result = sfft_module.solve(
|
|
1222
|
+
image_f,
|
|
1223
|
+
template_f,
|
|
1224
|
+
mask=fitting_mask,
|
|
1225
|
+
template_mask=template_mask,
|
|
1226
|
+
err=sfft_err,
|
|
1227
|
+
template_err=template_err,
|
|
1228
|
+
kernel_shape=kernel_shape,
|
|
1229
|
+
kernel_poly_order=kernel_poly_order,
|
|
1230
|
+
bg_poly_order=bg_poly_order,
|
|
1231
|
+
flux_poly_order=flux_poly_order,
|
|
1232
|
+
flux_penalty=flux_penalty,
|
|
1233
|
+
ridge=ridge,
|
|
1234
|
+
sigma_clip=sigma_clip,
|
|
1235
|
+
max_iter=max_iter,
|
|
1236
|
+
verbose=verbose,
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
diff = sfft_result.diff
|
|
1240
|
+
|
|
1241
|
+
result = [diff]
|
|
1242
|
+
|
|
1243
|
+
# --- Convolved template ---
|
|
1244
|
+
if get_convolved:
|
|
1245
|
+
# model = convolved_template + background
|
|
1246
|
+
# Extract background contribution to get convolved template alone
|
|
1247
|
+
bg_model = sfft_module.evaluate_background(
|
|
1248
|
+
sfft_result,
|
|
1249
|
+
*np.meshgrid(np.arange(diff.shape[1]), np.arange(diff.shape[0])),
|
|
1250
|
+
diff.shape,
|
|
1251
|
+
)
|
|
1252
|
+
convolved = sfft_result.model - bg_model
|
|
1253
|
+
result.append(convolved)
|
|
1254
|
+
|
|
1255
|
+
# --- Noise map for difference image ---
|
|
1256
|
+
noise = None
|
|
1257
|
+
|
|
1258
|
+
if get_noise or get_scaled:
|
|
1259
|
+
noise = _compute_diff_noise(sfft_result, err, template_err, combined_mask, log)
|
|
1260
|
+
|
|
1261
|
+
# --- Noise-scaled difference ---
|
|
1262
|
+
if get_scaled:
|
|
1263
|
+
scaled = np.zeros_like(diff)
|
|
1264
|
+
# dmask covers image edges and kernel-footprint neighbourhoods of
|
|
1265
|
+
# template defects, where the difference is unreliable
|
|
1266
|
+
good = (noise > 0) & np.isfinite(noise) & ~combined_mask & ~sfft_result.dmask
|
|
1267
|
+
scaled[good] = diff[good] / noise[good]
|
|
1268
|
+
result.append(scaled)
|
|
1269
|
+
|
|
1270
|
+
if get_noise:
|
|
1271
|
+
result.append(noise)
|
|
1272
|
+
|
|
1273
|
+
# --- Kernel info ---
|
|
1274
|
+
if get_kernel:
|
|
1275
|
+
result.append(sfft_result)
|
|
1276
|
+
|
|
1277
|
+
if len(result) == 1:
|
|
1278
|
+
return result[0]
|
|
1279
|
+
else:
|
|
1280
|
+
return result
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def _compute_diff_noise(sfft_result, err, template_err, mask, log):
|
|
1284
|
+
"""Compute per-pixel noise map for the SFFT difference image.
|
|
1285
|
+
|
|
1286
|
+
diff = science - model, where model = Σ_α a_α(x,y) · R_α(x,y) + bg(x,y)
|
|
1287
|
+
|
|
1288
|
+
var_diff(x,y) = var_sci(x,y) + Σ_α a_α²(x,y) · var_tmpl(x+dy, x+dx)
|
|
1289
|
+
|
|
1290
|
+
If template_err is not available, assumes the template contributes no
|
|
1291
|
+
noise (deep template limit) and returns just the science noise.
|
|
1292
|
+
"""
|
|
1293
|
+
|
|
1294
|
+
ny, nx = sfft_result.diff.shape
|
|
1295
|
+
|
|
1296
|
+
# Science noise contribution
|
|
1297
|
+
if err is not None and hasattr(err, 'shape'):
|
|
1298
|
+
var_sci = err**2
|
|
1299
|
+
else:
|
|
1300
|
+
# Estimate from difference image residuals
|
|
1301
|
+
rms = sfft_result.rms if np.isfinite(sfft_result.rms) else mad_std(sfft_result.diff[~mask])
|
|
1302
|
+
var_sci = np.full((ny, nx), rms**2, dtype=np.float64)
|
|
1303
|
+
log('No science err provided, using constant RMS = %.2f for noise map' % rms)
|
|
1304
|
+
|
|
1305
|
+
# Template noise contribution (convolved through the kernel)
|
|
1306
|
+
if template_err is not None and hasattr(template_err, 'shape'):
|
|
1307
|
+
log('Computing convolved template noise contribution')
|
|
1308
|
+
|
|
1309
|
+
var_tmpl = template_err**2
|
|
1310
|
+
|
|
1311
|
+
# var_convolved = Σ_α a_α²(x,y) · var_tmpl_shifted(x,y)
|
|
1312
|
+
var_conv = sfft_module._propagate_template_variance(
|
|
1313
|
+
sfft_result.kernel_coeffs,
|
|
1314
|
+
sfft_result.kernel_shape,
|
|
1315
|
+
sfft_result.kernel_poly_order,
|
|
1316
|
+
var_tmpl,
|
|
1317
|
+
)
|
|
1318
|
+
var_total = var_sci + var_conv
|
|
1319
|
+
else:
|
|
1320
|
+
var_total = var_sci
|
|
1321
|
+
|
|
1322
|
+
noise = np.sqrt(np.clip(var_total, 0, None))
|
|
1323
|
+
|
|
1324
|
+
return noise
|