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/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