Coverage for python/lsst/ip/diffim/zogy.py : 10%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#
2# LSST Data Management System
3# Copyright 2016 AURA/LSST.
4#
5# This product includes software developed by the
6# LSST Project (http://www.lsst.org/).
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the LSST License Statement and
19# the GNU General Public License along with this program. If not,
20# see <https://www.lsstcorp.org/LegalNotices/>.
21#
23import numpy as np
25import lsst.geom as geom
26import lsst.afw.image as afwImage
27import lsst.afw.geom as afwGeom
28import lsst.meas.algorithms as measAlg
29import lsst.afw.math as afwMath
30import lsst.pex.config as pexConfig
31import lsst.pipe.base as pipeBase
33from .imageMapReduce import (ImageMapReduceConfig, ImageMapper,
34 ImageMapReduceTask)
35from .imagePsfMatch import (ImagePsfMatchTask, ImagePsfMatchConfig,
36 subtractAlgorithmRegistry)
38__all__ = ["ZogyTask", "ZogyConfig",
39 "ZogyMapper", "ZogyMapReduceConfig",
40 "ZogyImagePsfMatchConfig", "ZogyImagePsfMatchTask"]
43"""Tasks for performing the "Proper image subtraction" algorithm of
44Zackay, et al. (2016), hereafter simply referred to as 'ZOGY (2016)'.
46`ZogyTask` contains methods to perform the basic estimation of the
47ZOGY diffim `D`, its updated PSF, and the variance-normalized
48likelihood image `S_corr`. We have implemented ZOGY using the
49proscribed methodology, computing all convolutions in Fourier space,
50and also variants in which the convolutions are performed in real
51(image) space. The former is faster and results in fewer artifacts
52when the PSFs are noisy (i.e., measured, for example, via
53`PsfEx`). The latter is presumed to be preferred as it can account for
54masks correctly with fewer "ringing" artifacts from edge effects or
55saturated stars, but noisy PSFs result in their own smaller
56artifacts. Removal of these artifacts is a subject of continuing
57research. Currently, we "pad" the PSFs when performing the
58subtractions in real space, which reduces, but does not entirely
59eliminate these artifacts.
61All methods in `ZogyTask` assume template and science images are
62already accurately photometrically and astrometrically registered.
64`ZogyMapper` is a wrapper which runs `ZogyTask` in the
65`ImageMapReduce` framework, computing of ZOGY diffim's on small,
66overlapping sub-images, thereby enabling complete ZOGY diffim's which
67account for spatially-varying noise and PSFs across the two input
68exposures. An example of the use of this task is in the `testZogy.py`
69unit test.
70"""
73class ZogyConfig(pexConfig.Config):
74 """Configuration parameters for the ZogyTask
75 """
76 inImageSpace = pexConfig.Field(
77 dtype=bool,
78 default=False,
79 doc="Perform all convolutions in real (image) space rather than Fourier space. "
80 "Currently if True, this results in artifacts when using real (noisy) PSFs."
81 )
83 padSize = pexConfig.Field(
84 dtype=int,
85 default=7,
86 doc="Number of pixels to pad PSFs to avoid artifacts (when inImageSpace is True)"
87 )
89 templateFluxScaling = pexConfig.Field(
90 dtype=float,
91 default=1.,
92 doc="Template flux scaling factor (Fr in ZOGY paper)"
93 )
95 scienceFluxScaling = pexConfig.Field(
96 dtype=float,
97 default=1.,
98 doc="Science flux scaling factor (Fn in ZOGY paper)"
99 )
101 scaleByCalibration = pexConfig.Field(
102 dtype=bool,
103 default=True,
104 doc="Compute the flux normalization scaling based on the image calibration."
105 "This overrides 'templateFluxScaling' and 'scienceFluxScaling'."
106 )
108 doTrimKernels = pexConfig.Field(
109 dtype=bool,
110 default=False,
111 doc="Trim kernels for image-space ZOGY. Speeds up convolutions and shrinks artifacts. "
112 "Subject of future research."
113 )
115 doFilterPsfs = pexConfig.Field(
116 dtype=bool,
117 default=True,
118 doc="Filter PSFs for image-space ZOGY. Aids in reducing artifacts. "
119 "Subject of future research."
120 )
122 ignoreMaskPlanes = pexConfig.ListField(
123 dtype=str,
124 default=("INTRP", "EDGE", "DETECTED", "SAT", "CR", "BAD", "NO_DATA", "DETECTED_NEGATIVE"),
125 doc="Mask planes to ignore for statistics"
126 )
129MIN_KERNEL = 1.0e-4
132class ZogyTask(pipeBase.Task):
133 """Task to perform ZOGY proper image subtraction. See module-level documentation for
134 additional details.
136 In all methods, im1 is R (reference, or template) and im2 is N (new, or science).
137 """
138 ConfigClass = ZogyConfig
139 _DefaultName = "ip_diffim_Zogy"
141 def __init__(self, templateExposure=None, scienceExposure=None, sig1=None, sig2=None,
142 psf1=None, psf2=None, *args, **kwargs):
143 """Create the ZOGY task.
145 Parameters
146 ----------
147 templateExposure : `lsst.afw.image.Exposure`
148 Template exposure ("Reference image" in ZOGY (2016)).
149 scienceExposure : `lsst.afw.image.Exposure`
150 Science exposure ("New image" in ZOGY (2016)). Must have already been
151 registered and photmetrically matched to template.
152 sig1 : `float`
153 (Optional) sqrt(variance) of `templateExposure`. If `None`, it is
154 computed from the sqrt(mean) of the `templateExposure` variance image.
155 sig2 : `float`
156 (Optional) sqrt(variance) of `scienceExposure`. If `None`, it is
157 computed from the sqrt(mean) of the `scienceExposure` variance image.
158 psf1 : 2D `numpy.array`
159 (Optional) 2D array containing the PSF image for the template. If
160 `None`, it is extracted from the PSF taken at the center of `templateExposure`.
161 psf2 : 2D `numpy.array`
162 (Optional) 2D array containing the PSF image for the science img. If
163 `None`, it is extracted from the PSF taken at the center of `scienceExposure`.
164 *args
165 additional arguments to be passed to
166 `lsst.pipe.base.Task`
167 **kwargs
168 additional keyword arguments to be passed to
169 `lsst.pipe.base.Task`
170 """
171 pipeBase.Task.__init__(self, *args, **kwargs)
172 self.template = self.science = None
173 self.setup(templateExposure=templateExposure, scienceExposure=scienceExposure,
174 sig1=sig1, sig2=sig2, psf1=psf1, psf2=psf2, *args, **kwargs)
176 def setup(self, templateExposure=None, scienceExposure=None, sig1=None, sig2=None,
177 psf1=None, psf2=None, correctBackground=False, *args, **kwargs):
178 """Set up the ZOGY task.
180 Parameters
181 ----------
182 templateExposure : `lsst.afw.image.Exposure`
183 Template exposure ("Reference image" in ZOGY (2016)).
184 scienceExposure : `lsst.afw.image.Exposure`
185 Science exposure ("New image" in ZOGY (2016)). Must have already been
186 registered and photometrically matched to template.
187 sig1 : `float`
188 (Optional) sqrt(variance) of `templateExposure`. If `None`, it is
189 computed from the sqrt(mean) of the `templateExposure` variance image.
190 sig2 : `float`
191 (Optional) sqrt(variance) of `scienceExposure`. If `None`, it is
192 computed from the sqrt(mean) of the `scienceExposure` variance image.
193 psf1 : 2D `numpy.array`
194 (Optional) 2D array containing the PSF image for the template. If
195 `None`, it is extracted from the PSF taken at the center of `templateExposure`.
196 psf2 : 2D `numpy.array`
197 (Optional) 2D array containing the PSF image for the science img. If
198 `None`, it is extracted from the PSF taken at the center of `scienceExposure`.
199 correctBackground : `bool`
200 (Optional) subtract sigma-clipped mean of exposures. Zogy doesn't correct
201 nonzero backgrounds (unlike AL) so subtract them here.
202 *args
203 additional arguments to be passed to
204 `lsst.pipe.base.Task`
205 **kwargs
206 additional keyword arguments to be passed to
207 `lsst.pipe.base.Task`
208 """
209 if self.template is None and templateExposure is None:
210 return
211 if self.science is None and scienceExposure is None:
212 return
214 self.template = templateExposure
215 self.science = scienceExposure
217 self.statsControl = afwMath.StatisticsControl()
218 self.statsControl.setNumSigmaClip(3.)
219 self.statsControl.setNumIter(3)
220 self.statsControl.setAndMask(afwImage.Mask.getPlaneBitMask(
221 self.config.ignoreMaskPlanes))
223 self.im1 = self.template.getMaskedImage().getImage().getArray()
224 self.im2 = self.science.getMaskedImage().getImage().getArray()
225 self.im1_var = self.template.getMaskedImage().getVariance().getArray()
226 self.im2_var = self.science.getMaskedImage().getVariance().getArray()
228 def selectPsf(psf, exposure):
229 if psf is not None:
230 return psf
231 else:
232 bbox1 = self.template.getBBox()
233 xcen = (bbox1.getBeginX() + bbox1.getEndX()) / 2.
234 ycen = (bbox1.getBeginY() + bbox1.getEndY()) / 2.
235 return exposure.getPsf().computeKernelImage(geom.Point2D(xcen, ycen)).getArray()
237 self.im1_psf = selectPsf(psf1, self.template)
238 self.im2_psf = selectPsf(psf2, self.science)
240 # Make sure PSFs are the same size. Messy, but should work for all cases.
241 psf1 = self.im1_psf
242 psf2 = self.im2_psf
243 pShape1 = psf1.shape
244 pShape2 = psf2.shape
245 if (pShape1[0] < pShape2[0]):
246 psf1 = np.pad(psf1, ((0, pShape2[0] - pShape1[0]), (0, 0)), mode='constant', constant_values=0.)
247 elif (pShape2[0] < pShape1[0]):
248 psf2 = np.pad(psf2, ((0, pShape1[0] - pShape2[0]), (0, 0)), mode='constant', constant_values=0.)
249 if (pShape1[1] < pShape2[1]):
250 psf1 = np.pad(psf1, ((0, 0), (0, pShape2[1] - pShape1[1])), mode='constant', constant_values=0.)
251 elif (pShape2[1] < pShape1[1]):
252 psf2 = np.pad(psf2, ((0, 0), (0, pShape1[1] - pShape2[1])), mode='constant', constant_values=0.)
254 # PSFs' centers may be offset relative to each other; now fix that!
255 maxLoc1 = np.unravel_index(np.argmax(psf1), psf1.shape)
256 maxLoc2 = np.unravel_index(np.argmax(psf2), psf2.shape)
257 # *Very* rarely happens but if they're off by >1 pixel, do it more than once.
258 while (maxLoc1[0] != maxLoc2[0]) or (maxLoc1[1] != maxLoc2[1]):
259 if maxLoc1[0] > maxLoc2[0]:
260 psf2[1:, :] = psf2[:-1, :]
261 elif maxLoc1[0] < maxLoc2[0]:
262 psf1[1:, :] = psf1[:-1, :]
263 if maxLoc1[1] > maxLoc2[1]:
264 psf2[:, 1:] = psf2[:, :-1]
265 elif maxLoc1[1] < maxLoc2[1]:
266 psf1[:, 1:] = psf1[:, :-1]
267 maxLoc1 = np.unravel_index(np.argmax(psf1), psf1.shape)
268 maxLoc2 = np.unravel_index(np.argmax(psf2), psf2.shape)
270 # Make sure there are no div-by-zeros
271 psf1[psf1 < MIN_KERNEL] = MIN_KERNEL
272 psf2[psf2 < MIN_KERNEL] = MIN_KERNEL
274 self.im1_psf = psf1
275 self.im2_psf = psf2
277 self.sig1 = np.sqrt(self._computeVarianceMean(self.template)) if sig1 is None else sig1
278 self.sig2 = np.sqrt(self._computeVarianceMean(self.science)) if sig2 is None else sig2
279 # if sig1 or sig2 are NaN, then the entire region being Zogy-ed is masked.
280 # Don't worry about it - the result will be masked but avoid warning messages.
281 if np.isnan(self.sig1) or self.sig1 == 0:
282 self.sig1 = 1.
283 if np.isnan(self.sig2) or self.sig2 == 0:
284 self.sig2 = 1.
286 # Zogy doesn't correct nonzero backgrounds (unlike AL) so subtract them here.
287 if correctBackground:
288 def _subtractImageMean(exposure):
289 """Compute the sigma-clipped mean of the image of `exposure`."""
290 mi = exposure.getMaskedImage()
291 statObj = afwMath.makeStatistics(mi.getImage(), mi.getMask(),
292 afwMath.MEANCLIP, self.statsControl)
293 mean = statObj.getValue(afwMath.MEANCLIP)
294 if not np.isnan(mean):
295 mi -= mean
297 _subtractImageMean(self.template)
298 _subtractImageMean(self.science)
300 # Define the normalization of each image from the config
301 self.Fr = self.config.templateFluxScaling # default is 1
302 self.Fn = self.config.scienceFluxScaling # default is 1
303 # If 'scaleByCalibration' is True then these norms are overwritten
304 if self.config.scaleByCalibration:
305 calib_template = self.template.getPhotoCalib()
306 calib_science = self.science.getPhotoCalib()
307 if calib_template is None:
308 self.log.warning("No calibration information available for template image.")
309 if calib_science is None:
310 self.log.warning("No calibration information available for science image.")
311 if calib_template is None or calib_science is None:
312 self.log.warning("Due to lack of calibration information, "
313 "reverting to templateFluxScaling and scienceFluxScaling.")
314 else:
315 self.Fr = 1/calib_template.getCalibrationMean()
316 self.Fn = 1/calib_science.getCalibrationMean()
318 self.log.info("Setting template image scaling to Fr=%f" % self.Fr)
319 self.log.info("Setting science image scaling to Fn=%f" % self.Fn)
321 self.padSize = self.config.padSize # default is 7
323 def _computeVarianceMean(self, exposure):
324 """Compute the sigma-clipped mean of the variance image of `exposure`.
325 """
326 statObj = afwMath.makeStatistics(exposure.getMaskedImage().getVariance(),
327 exposure.getMaskedImage().getMask(),
328 afwMath.MEANCLIP, self.statsControl)
329 var = statObj.getValue(afwMath.MEANCLIP)
330 return var
332 @staticmethod
333 def _padPsfToSize(psf, size):
334 """Zero-pad `psf` to the dimensions given by `size`.
336 Parameters
337 ----------
338 psf : 2D `numpy.array`
339 Input psf to be padded
340 size : `list`
341 Two element list containing the dimensions to pad the `psf` to
343 Returns
344 -------
345 psf : 2D `numpy.array`
346 The padded copy of the input `psf`.
347 """
348 newArr = np.zeros(size)
349 # The center of the PSF sould be placed in the center-right.
350 offset = [size[0]//2 - psf.shape[0]//2, size[1]//2 - psf.shape[1]//2]
351 tmp = newArr[offset[0]:(psf.shape[0] + offset[0]), offset[1]:(psf.shape[1] + offset[1])]
352 tmp[:, :] = psf
353 return newArr
355 def computePrereqs(self, psf1=None, psf2=None, padSize=0):
356 """Compute standard ZOGY quantities used by (nearly) all methods.
358 Many of the ZOGY calculations require similar quantities, including
359 FFTs of the PSFs, and the "denominator" term (e.g. in eq. 13 of
360 ZOGY manuscript (2016). This function consolidates many of those
361 operations.
363 Parameters
364 ----------
365 psf1 : 2D `numpy.array`
366 (Optional) Input psf of template, override if already padded
367 psf2 : 2D `numpy.array`
368 (Optional) Input psf of science image, override if already padded
369 padSize : `int`, optional
370 Number of pixels to pad the image on each side with zeroes.
372 Returns
373 -------
374 A `lsst.pipe.base.Struct` containing:
375 - Pr : 2D `numpy.array`, the (possibly zero-padded) template PSF
376 - Pn : 2D `numpy.array`, the (possibly zero-padded) science PSF
377 - Pr_hat : 2D `numpy.array`, the FFT of `Pr`
378 - Pn_hat : 2D `numpy.array`, the FFT of `Pn`
379 - denom : 2D `numpy.array`, the denominator of equation (13) in ZOGY (2016) manuscript
380 - Fd : `float`, the relative flux scaling factor between science and template
381 """
382 psf1 = self.im1_psf if psf1 is None else psf1
383 psf2 = self.im2_psf if psf2 is None else psf2
384 padSize = self.padSize if padSize is None else padSize
385 Pr, Pn = psf1, psf2
386 if padSize > 0:
387 Pr = ZogyTask._padPsfToSize(psf1, (psf1.shape[0] + padSize, psf1.shape[1] + padSize))
388 Pn = ZogyTask._padPsfToSize(psf2, (psf2.shape[0] + padSize, psf2.shape[1] + padSize))
389 # Make sure there are no div-by-zeros
390 psf1[np.abs(psf1) <= MIN_KERNEL] = MIN_KERNEL
391 psf2[np.abs(psf2) <= MIN_KERNEL] = MIN_KERNEL
393 sigR, sigN = self.sig1, self.sig2
394 Pr_hat = np.fft.fft2(Pr)
395 Pr_hat2 = np.conj(Pr_hat) * Pr_hat
396 Pn_hat = np.fft.fft2(Pn)
397 Pn_hat2 = np.conj(Pn_hat) * Pn_hat
398 denom = np.sqrt((sigN**2 * self.Fr**2 * Pr_hat2) + (sigR**2 * self.Fn**2 * Pn_hat2))
399 Fd = self.Fr * self.Fn / np.sqrt(sigN**2 * self.Fr**2 + sigR**2 * self.Fn**2)
401 res = pipeBase.Struct(
402 Pr=Pr, Pn=Pn, Pr_hat=Pr_hat, Pn_hat=Pn_hat, denom=denom, Fd=Fd
403 )
404 return res
406 def computeDiffimFourierSpace(self, debug=False, returnMatchedTemplate=False, **kwargs):
407 r"""Compute ZOGY diffim `D` as proscribed in ZOGY (2016) manuscript
409 Parameters
410 ----------
411 debug : `bool`, optional
412 If set to True, filter the kernels by setting the edges to zero.
413 returnMatchedTemplate : `bool`, optional
414 Calculate the template image.
415 If not set, the returned template will be None.
417 Notes
418 -----
419 In all functions, im1 is R (reference, or template) and im2 is N (new, or science)
420 Compute the ZOGY eqn. (13):
422 .. math::
424 \widehat{D} = \frac{Fr\widehat{Pr}\widehat{N} -
425 F_n\widehat{Pn}\widehat{R}}{\sqrt{\sigma_n^2 Fr^2
426 \|\widehat{Pr}\|^2 + \sigma_r^2 F_n^2 \|\widehat{Pn}\|^2}}
428 where :math:`D` is the optimal difference image, :math:`R` and :math:`N` are the
429 reference and "new" image, respectively, :math:`Pr` and :math:`P_n` are their
430 PSFs, :math:`Fr` and :math:`Fn` are their flux-based zero-points (which we
431 will set to one here), :math:`\sigma_r^2` and :math:`\sigma_n^2` are their
432 variance, and :math:`\widehat{D}` denotes the FT of :math:`D`.
434 Returns
435 -------
436 result : `lsst.pipe.base.Struct`
437 Result struct with components:
439 - ``D`` : 2D `numpy.array`, the proper image difference
440 - ``D_var`` : 2D `numpy.array`, the variance image for `D`
441 """
442 # Do all in fourier space (needs image-sized PSFs)
443 psf1 = ZogyTask._padPsfToSize(self.im1_psf, self.im1.shape)
444 psf2 = ZogyTask._padPsfToSize(self.im2_psf, self.im2.shape)
446 preqs = self.computePrereqs(psf1, psf2, padSize=0) # already padded the PSFs
448 def _filterKernel(K, trim_amount):
449 # Filter the wings of Kn, Kr, set to zero
450 ps = trim_amount
451 K[:ps, :] = K[-ps:, :] = 0
452 K[:, :ps] = K[:, -ps:] = 0
453 return K
455 Kr_hat = self.Fr * preqs.Pr_hat / preqs.denom
456 Kn_hat = self.Fn * preqs.Pn_hat / preqs.denom
457 if debug and self.config.doTrimKernels: # default False
458 # Suggestion from Barak to trim Kr and Kn to remove artifacts
459 # Here we just filter them (in image space) to keep them the same size
460 ps = (Kn_hat.shape[1] - 80)//2
461 Kn = _filterKernel(np.fft.ifft2(Kn_hat), ps)
462 Kn_hat = np.fft.fft2(Kn)
463 Kr = _filterKernel(np.fft.ifft2(Kr_hat), ps)
464 Kr_hat = np.fft.fft2(Kr)
466 def processImages(im1, im2, doAdd=False):
467 # Some masked regions are NaN or infinite!, and FFTs no likey.
468 im1[np.isinf(im1)] = np.nan
469 im1[np.isnan(im1)] = np.nanmean(im1)
470 im2[np.isinf(im2)] = np.nan
471 im2[np.isnan(im2)] = np.nanmean(im2)
473 R_hat = np.fft.fft2(im1)
474 N_hat = np.fft.fft2(im2)
476 D_hat = Kr_hat * N_hat
477 D_hat_R = Kn_hat * R_hat
478 if not doAdd:
479 D_hat -= D_hat_R
480 else:
481 D_hat += D_hat_R
483 D = np.fft.ifft2(D_hat)
484 D = np.fft.ifftshift(D.real) / preqs.Fd
486 R = None
487 if returnMatchedTemplate:
488 R = np.fft.ifft2(D_hat_R)
489 R = np.fft.ifftshift(R.real) / preqs.Fd
491 return D, R
493 # First do the image
494 D, R = processImages(self.im1, self.im2, doAdd=False)
495 # Do the exact same thing to the var images, except add them
496 D_var, R_var = processImages(self.im1_var, self.im2_var, doAdd=True)
498 return pipeBase.Struct(D=D, D_var=D_var, R=R, R_var=R_var)
500 def _doConvolve(self, exposure, kernel, recenterKernel=False):
501 """Convolve an Exposure with a decorrelation convolution kernel.
503 Parameters
504 ----------
505 exposure : `lsst.afw.image.Exposure`
506 Input exposure to be convolved.
507 kernel : `numpy.array`
508 2D `numpy.array` to convolve the image with
509 recenterKernel : `bool`, optional
510 Force the kernel center to the pixel with the maximum value.
512 Returns
513 -------
514 A new `lsst.afw.image.Exposure` with the convolved pixels and the (possibly
515 re-centered) kernel.
517 Notes
518 -----
519 - We optionally re-center the kernel if necessary and return the possibly
520 re-centered kernel
521 """
522 kernelImg = afwImage.ImageD(kernel.shape[1], kernel.shape[0])
523 kernelImg.getArray()[:, :] = kernel
524 kern = afwMath.FixedKernel(kernelImg)
525 if recenterKernel:
526 maxloc = np.unravel_index(np.argmax(kernel), kernel.shape)
527 kern.setCtrX(maxloc[0])
528 kern.setCtrY(maxloc[1])
529 outExp = exposure.clone() # Do this to keep WCS, PSF, masks, etc.
530 convCntrl = afwMath.ConvolutionControl(doNormalize=False, doCopyEdge=False,
531 maxInterpolationDistance=0)
532 try:
533 afwMath.convolve(outExp.getMaskedImage(), exposure.getMaskedImage(), kern, convCntrl)
534 except AttributeError:
535 # Allow exposure to actually be an image/maskedImage
536 # (getMaskedImage will throw AttributeError in that case)
537 afwMath.convolve(outExp, exposure, kern, convCntrl)
539 return outExp, kern
541 def computeDiffimImageSpace(self, padSize=None, debug=False, **kwargs):
542 """Compute ZOGY diffim `D` using image-space convlutions
544 This method is still being debugged as it results in artifacts
545 when the PSFs are noisy (see module-level docstring). Thus
546 there are several options still enabled by the `debug` flag,
547 which are disabled by defult.
549 Parameters
550 ----------
551 padSize : `int`
552 The amount to pad the PSFs by
553 debug : `bool`
554 Flag to enable debugging tests and options
556 Returns
557 -------
558 D : `lsst.afw.Exposure`
559 the proper image difference, including correct variance,
560 masks, and PSF
561 """
562 preqs = self.computePrereqs(padSize=padSize)
564 delta = 0.
565 if debug:
566 delta = 1. # Regularize the ratio, a possible option to remove artifacts
567 Kr_hat = (preqs.Pr_hat + delta) / (preqs.denom + delta)
568 Kn_hat = (preqs.Pn_hat + delta) / (preqs.denom + delta)
569 Kr = np.fft.ifft2(Kr_hat).real
570 Kr = np.roll(np.roll(Kr, -1, 0), -1, 1)
571 Kn = np.fft.ifft2(Kn_hat).real
572 Kn = np.roll(np.roll(Kn, -1, 0), -1, 1)
574 def _trimKernel(self, K, trim_amount):
575 # Trim out the wings of Kn, Kr (see notebook #15)
576 # only necessary if it's from a measured psf and PsfEx seems to always make PSFs of size 41x41
577 ps = trim_amount
578 K = K[ps:-ps, ps:-ps]
579 return K
581 padSize = self.padSize if padSize is None else padSize
582 # Enabling this block (debug=True) makes it slightly faster, but ~25% worse artifacts:
583 if debug and self.config.doTrimKernels: # default False
584 # Filtering also makes it slightly faster (zeros are ignored in convolution)
585 # but a bit worse. Filter the wings of Kn, Kr (see notebook #15)
586 Kn = _trimKernel(Kn, padSize)
587 Kr = _trimKernel(Kr, padSize)
589 # Note these are reverse-labelled, this is CORRECT!
590 exp1, _ = self._doConvolve(self.template, Kn)
591 exp2, _ = self._doConvolve(self.science, Kr)
592 D = exp2
593 tmp = D.getMaskedImage()
594 tmp -= exp1.getMaskedImage()
595 tmp /= preqs.Fd
596 return pipeBase.Struct(D=D, R=exp1)
598 def _setNewPsf(self, exposure, psfArr):
599 """Utility method to set an exposure's PSF when provided as a 2-d numpy.array
600 """
601 bbox = exposure.getBBox()
602 center = ((bbox.getBeginX() + bbox.getEndX()) // 2., (bbox.getBeginY() + bbox.getEndY()) // 2.)
603 center = geom.Point2D(center[0], center[1])
604 psfI = afwImage.ImageD(psfArr.shape[1], psfArr.shape[0])
605 psfI.getArray()[:, :] = psfArr
606 psfK = afwMath.FixedKernel(psfI)
607 psfNew = measAlg.KernelPsf(psfK, center)
608 exposure.setPsf(psfNew)
609 return exposure
611 def computeDiffim(self, inImageSpace=None, padSize=None,
612 returnMatchedTemplate=False, **kwargs):
613 """Wrapper method to compute ZOGY proper diffim
615 This method should be used as the public interface for
616 computing the ZOGY diffim.
618 Parameters
619 ----------
620 inImageSpace : `bool`
621 Override config `inImageSpace` parameter
622 padSize : `int`
623 Override config `padSize` parameter
624 returnMatchedTemplate : `bool`
625 Include the PSF-matched template in the results Struct
626 **kwargs
627 additional keyword arguments to be passed to
628 `computeDiffimFourierSpace` or `computeDiffimImageSpace`.
630 Returns
631 -------
632 An lsst.pipe.base.Struct containing:
633 - D : `lsst.afw.Exposure`
634 the proper image difference, including correct variance,
635 masks, and PSF
636 - R : `lsst.afw.Exposure`
637 If `returnMatchedTemplate` is True, the PSF-matched template
638 exposure
639 """
640 R = None
641 inImageSpace = self.config.inImageSpace if inImageSpace is None else inImageSpace
642 if inImageSpace:
643 padSize = self.padSize if padSize is None else padSize
644 res = self.computeDiffimImageSpace(padSize=padSize, **kwargs)
645 D = res.D
646 if returnMatchedTemplate:
647 R = res.R
648 else:
649 res = self.computeDiffimFourierSpace(**kwargs)
650 D = self.science.clone()
651 D.getMaskedImage().getImage().getArray()[:, :] = res.D
652 D.getMaskedImage().getVariance().getArray()[:, :] = res.D_var
653 if returnMatchedTemplate:
654 R = self.science.clone()
655 R.getMaskedImage().getImage().getArray()[:, :] = res.R
656 R.getMaskedImage().getVariance().getArray()[:, :] = res.R_var
658 psf = self.computeDiffimPsf()
659 D = self._setNewPsf(D, psf)
660 return pipeBase.Struct(D=D, R=R)
662 def computeDiffimPsf(self, padSize=0, keepFourier=False, psf1=None, psf2=None):
663 """Compute the ZOGY diffim PSF (ZOGY manuscript eq. 14)
665 Parameters
666 ----------
667 padSize : `int`
668 Override config `padSize` parameter
669 keepFourier : `bool`
670 Return the FFT of the diffim PSF (do not inverse-FFT it)
671 psf1 : 2D `numpy.array`
672 (Optional) Input psf of template, override if already padded
673 psf2 : 2D `numpy.array`
674 (Optional) Input psf of science image, override if already padded
676 Returns
677 -------
678 Pd : 2D `numpy.array`
679 The diffim PSF (or FFT of PSF if `keepFourier=True`)
680 """
681 preqs = self.computePrereqs(psf1=psf1, psf2=psf2, padSize=padSize)
683 Pd_hat_numerator = (self.Fr * self.Fn * preqs.Pr_hat * preqs.Pn_hat)
684 Pd_hat = Pd_hat_numerator / (preqs.Fd * preqs.denom)
686 if keepFourier:
687 return Pd_hat
689 Pd = np.fft.ifft2(Pd_hat)
690 Pd = np.fft.ifftshift(Pd).real
692 return Pd
694 def _computeVarAstGradients(self, xVarAst=0., yVarAst=0., inImageSpace=False,
695 R_hat=None, Kr_hat=None, Kr=None,
696 N_hat=None, Kn_hat=None, Kn=None):
697 """Compute the astrometric noise correction terms
699 Compute the correction for estimated astrometric noise as
700 proscribed in ZOGY (2016), section 3.3. All convolutions
701 performed either in real (image) or Fourier space.
703 Parameters
704 ----------
705 xVarAst, yVarAst : `float`
706 estimated astrometric noise (variance of astrometric registration errors)
707 inImageSpace : `bool`
708 Perform all convolutions in real (image) space rather than Fourier space
709 R_hat : 2-D `numpy.array`
710 (Optional) FFT of template image, only required if `inImageSpace=False`
711 Kr_hat : 2-D `numpy.array`
712 FFT of Kr kernel (eq. 28 of ZOGY (2016)), only required if `inImageSpace=False`
713 Kr : 2-D `numpy.array`
714 Kr kernel (eq. 28 of ZOGY (2016)), only required if `inImageSpace=True`.
715 Kr is associated with the template (reference).
716 N_hat : 2-D `numpy.array`
717 FFT of science image, only required if `inImageSpace=False`
718 Kn_hat : 2-D `numpy.array`
719 FFT of Kn kernel (eq. 29 of ZOGY (2016)), only required if `inImageSpace=False`
720 Kn : 2-D `numpy.array`
721 Kn kernel (eq. 29 of ZOGY (2016)), only required if `inImageSpace=True`.
722 Kn is associated with the science (new) image.
724 Returns
725 -------
726 VastSR, VastSN : 2-D `numpy.array`
727 Arrays containing the values in eqs. 30 and 32 of ZOGY (2016).
728 """
729 VastSR = VastSN = 0.
730 if xVarAst + yVarAst > 0: # Do the astrometric variance correction
731 if inImageSpace:
732 S_R, _ = self._doConvolve(self.template, Kr)
733 S_R = S_R.getMaskedImage().getImage().getArray()
734 else:
735 S_R = np.fft.ifft2(R_hat * Kr_hat)
736 gradRx, gradRy = np.gradient(S_R)
737 VastSR = xVarAst * gradRx**2. + yVarAst * gradRy**2.
739 if inImageSpace:
740 S_N, _ = self._doConvolve(self.science, Kn)
741 S_N = S_N.getMaskedImage().getImage().getArray()
742 else:
743 S_N = np.fft.ifft2(N_hat * Kn_hat)
744 gradNx, gradNy = np.gradient(S_N)
745 VastSN = xVarAst * gradNx**2. + yVarAst * gradNy**2.
747 return VastSR, VastSN
749 def computeScorrFourierSpace(self, xVarAst=0., yVarAst=0., **kwargs):
750 """Compute corrected likelihood image, optimal for source detection
752 Compute ZOGY S_corr image. This image can be thresholded for
753 detection without optimal filtering, and the variance image is
754 corrected to account for astrometric noise (errors in
755 astrometric registration whether systematic or due to effects
756 such as DCR). The calculations here are all performed in
757 Fourier space, as proscribed in ZOGY (2016).
759 Parameters
760 ----------
761 xVarAst, yVarAst : `float`
762 estimated astrometric noise (variance of astrometric registration errors)
764 Returns
765 -------
766 result : `lsst.pipe.base.Struct`
767 Result struct with components:
769 - ``S`` : `numpy.array`, the likelihood image S (eq. 12 of ZOGY (2016))
770 - ``S_var`` : the corrected variance image (denominator of eq. 25 of ZOGY (2016))
771 - ``Dpsf`` : the PSF of the diffim D, likely never to be used.
772 """
773 # Some masked regions are NaN or infinite!, and FFTs no likey.
774 def fix_nans(im):
775 """Replace any NaNs or Infs with the mean of the image."""
776 isbad = ~np.isfinite(im)
777 if np.any(isbad):
778 im[isbad] = np.nan
779 im[isbad] = np.nanmean(im)
780 return im
782 self.im1 = fix_nans(self.im1)
783 self.im2 = fix_nans(self.im2)
784 self.im1_var = fix_nans(self.im1_var)
785 self.im2_var = fix_nans(self.im2_var)
787 # Do all in fourier space (needs image-sized PSFs)
788 psf1 = ZogyTask._padPsfToSize(self.im1_psf, self.im1.shape)
789 psf2 = ZogyTask._padPsfToSize(self.im2_psf, self.im2.shape)
791 preqs = self.computePrereqs(psf1, psf2, padSize=0) # already padded the PSFs
793 # Compute D_hat here (don't need D then, for speed)
794 R_hat = np.fft.fft2(self.im1)
795 N_hat = np.fft.fft2(self.im2)
796 D_hat = self.Fr * preqs.Pr_hat * N_hat - self.Fn * preqs.Pn_hat * R_hat
797 D_hat /= preqs.denom
799 Pd_hat = self.computeDiffimPsf(padSize=0, keepFourier=True, psf1=psf1, psf2=psf2)
800 Pd_bar = np.conj(Pd_hat)
801 S = np.fft.ifft2(D_hat * Pd_bar)
803 # Adjust the variance planes of the two images to contribute to the final detection
804 # (eq's 26-29).
805 Pn_hat2 = np.conj(preqs.Pn_hat) * preqs.Pn_hat
806 Kr_hat = self.Fr * self.Fn**2. * np.conj(preqs.Pr_hat) * Pn_hat2 / preqs.denom**2.
807 Pr_hat2 = np.conj(preqs.Pr_hat) * preqs.Pr_hat
808 Kn_hat = self.Fn * self.Fr**2. * np.conj(preqs.Pn_hat) * Pr_hat2 / preqs.denom**2.
810 Kr_hat2 = np.fft.fft2(np.fft.ifft2(Kr_hat)**2.)
811 Kn_hat2 = np.fft.fft2(np.fft.ifft2(Kn_hat)**2.)
812 var1c_hat = Kr_hat2 * np.fft.fft2(self.im1_var)
813 var2c_hat = Kn_hat2 * np.fft.fft2(self.im2_var)
815 # Do the astrometric variance correction
816 fGradR, fGradN = self._computeVarAstGradients(xVarAst, yVarAst, inImageSpace=False,
817 R_hat=R_hat, Kr_hat=Kr_hat,
818 N_hat=N_hat, Kn_hat=Kn_hat)
820 S_var = np.sqrt(np.fft.ifftshift(np.fft.ifft2(var1c_hat + var2c_hat)) + fGradR + fGradN)
821 S_var *= preqs.Fd
823 S = np.fft.ifftshift(np.fft.ifft2(Kn_hat * N_hat - Kr_hat * R_hat))
824 S *= preqs.Fd
826 Pd = self.computeDiffimPsf(padSize=0)
827 return pipeBase.Struct(S=S.real, S_var=S_var.real, Dpsf=Pd)
829 def computeScorrImageSpace(self, xVarAst=0., yVarAst=0., padSize=None, **kwargs):
830 """Compute corrected likelihood image, optimal for source detection
832 Compute ZOGY S_corr image. This image can be thresholded for
833 detection without optimal filtering, and the variance image is
834 corrected to account for astrometric noise (errors in
835 astrometric registration whether systematic or due to effects
836 such as DCR). The calculations here are all performed in
837 Real (image) space.
839 Parameters
840 ----------
841 xVarAst, yVarAst : `float`
842 estimated astrometric noise (variance of astrometric registration errors)
844 Returns
845 -------
846 A `lsst.pipe.base.Struct` containing:
847 - S : `lsst.afw.image.Exposure`, the likelihood exposure S (eq. 12 of ZOGY (2016)),
848 including corrected variance, masks, and PSF
849 - D : `lsst.afw.image.Exposure`, the proper image difference, including correct
850 variance, masks, and PSF
851 """
852 # Do convolutions in image space
853 preqs = self.computePrereqs(padSize=0)
855 padSize = self.padSize if padSize is None else padSize
856 D = self.computeDiffimImageSpace(padSize=padSize).D
857 Pd = self.computeDiffimPsf()
858 D = self._setNewPsf(D, Pd)
859 Pd_bar = np.fliplr(np.flipud(Pd))
860 S, _ = self._doConvolve(D, Pd_bar)
861 tmp = S.getMaskedImage()
862 tmp *= preqs.Fd
864 # Adjust the variance planes of the two images to contribute to the final detection
865 # (eq's 26-29).
866 Pn_hat2 = np.conj(preqs.Pn_hat) * preqs.Pn_hat
867 Kr_hat = self.Fr * self.Fn**2. * np.conj(preqs.Pr_hat) * Pn_hat2 / preqs.denom**2.
868 Pr_hat2 = np.conj(preqs.Pr_hat) * preqs.Pr_hat
869 Kn_hat = self.Fn * self.Fr**2. * np.conj(preqs.Pn_hat) * Pr_hat2 / preqs.denom**2.
871 Kr = np.fft.ifft2(Kr_hat).real
872 Kr = np.roll(np.roll(Kr, -1, 0), -1, 1)
873 Kn = np.fft.ifft2(Kn_hat).real
874 Kn = np.roll(np.roll(Kn, -1, 0), -1, 1)
875 var1c, _ = self._doConvolve(self.template.getMaskedImage().getVariance(), Kr**2.)
876 var2c, _ = self._doConvolve(self.science.getMaskedImage().getVariance(), Kn**2.)
878 # Do the astrometric variance correction
879 fGradR, fGradN = self._computeVarAstGradients(xVarAst, yVarAst, inImageSpace=True,
880 Kr=Kr, Kn=Kn)
882 Smi = S.getMaskedImage()
883 Smi *= preqs.Fd
884 S_var = np.sqrt(var1c.getArray() + var2c.getArray() + fGradR + fGradN)
885 S.getMaskedImage().getVariance().getArray()[:, :] = S_var
886 S = self._setNewPsf(S, Pd)
888 # also return diffim since it was calculated and might be desired
889 return pipeBase.Struct(S=S, D=D)
891 def computeScorr(self, xVarAst=0., yVarAst=0., inImageSpace=None, padSize=0, **kwargs):
892 """Wrapper method to compute ZOGY corrected likelihood image, optimal for
893 source detection
895 This method should be used as the public interface for
896 computing the ZOGY S_corr.
898 Parameters
899 ----------
900 xVarAst, yVarAst : `float`
901 estimated astrometric noise (variance of astrometric registration errors)
902 inImageSpace : `bool`
903 Override config `inImageSpace` parameter
904 padSize : `int`
905 Override config `padSize` parameter
907 Returns
908 -------
909 S : `lsst.afw.image.Exposure`
910 The likelihood exposure S (eq. 12 of ZOGY (2016)),
911 including corrected variance, masks, and PSF
912 """
913 inImageSpace = self.config.inImageSpace if inImageSpace is None else inImageSpace
914 if inImageSpace:
915 res = self.computeScorrImageSpace(xVarAst=xVarAst, yVarAst=yVarAst, padSize=padSize)
916 S = res.S
917 else:
918 res = self.computeScorrFourierSpace(xVarAst=xVarAst, yVarAst=yVarAst)
920 S = self.science.clone()
921 S.getMaskedImage().getImage().getArray()[:, :] = res.S
922 S.getMaskedImage().getVariance().getArray()[:, :] = res.S_var
923 S = self._setNewPsf(S, res.Dpsf)
925 return pipeBase.Struct(S=S)
928class ZogyMapper(ZogyTask, ImageMapper):
929 """Task to be used as an ImageMapper for performing
930 ZOGY image subtraction on a grid of subimages.
931 """
932 ConfigClass = ZogyConfig
933 _DefaultName = 'ip_diffim_ZogyMapper'
935 def __init__(self, *args, **kwargs):
936 ImageMapper.__init__(self, *args, **kwargs)
938 def run(self, subExposure, expandedSubExposure, fullBBox, template,
939 **kwargs):
940 """Perform ZOGY proper image subtraction on sub-images
942 This method performs ZOGY proper image subtraction on
943 `subExposure` using local measures for image variances and
944 PSF. `subExposure` is a sub-exposure of the science image. It
945 also requires the corresponding sub-exposures of the template
946 (`template`). The operations are actually performed on
947 `expandedSubExposure` to allow for invalid edge pixels arising
948 from convolutions, which are then removed.
950 Parameters
951 ----------
952 subExposure : `lsst.afw.image.Exposure`
953 the sub-exposure of the diffim
954 expandedSubExposure : `lsst.afw.image.Exposure`
955 the expanded sub-exposure upon which to operate
956 fullBBox : `lsst.geom.Box2I`
957 the bounding box of the original exposure
958 template : `lsst.afw.image.Exposure`
959 the template exposure, from which a corresponding sub-exposure
960 is extracted
961 **kwargs
962 additional keyword arguments propagated from
963 `ImageMapReduceTask.run`. These include:
965 ``doScorr`` : `bool`
966 Compute and return the corrected likelihood image S_corr
967 rather than the proper image difference
968 ``inImageSpace`` : `bool`
969 Perform all convolutions in real (image) space rather than
970 in Fourier space. This option currently leads to artifacts
971 when using real (measured and noisy) PSFs, thus it is set
972 to `False` by default.
973 These kwargs may also include arguments to be propagated to
974 `ZogyTask.computeDiffim` and `ZogyTask.computeScorr`.
976 Returns
977 -------
978 result : `lsst.pipe.base.Struct`
979 Result struct with components:
981 ``subExposure``: Either the subExposure of the proper image difference ``D``,
982 or (if `doScorr==True`) the corrected likelihood exposure ``S``.
984 Notes
985 -----
986 This `run` method accepts parameters identical to those of
987 `ImageMapper.run`, since it is called from the
988 `ImageMapperTask`. See that class for more information.
989 """
990 bbox = subExposure.getBBox()
991 center = ((bbox.getBeginX() + bbox.getEndX()) // 2., (bbox.getBeginY() + bbox.getEndY()) // 2.)
992 center = geom.Point2D(center[0], center[1])
994 imageSpace = kwargs.pop('inImageSpace', False)
995 doScorr = kwargs.pop('doScorr', False)
996 sigmas = kwargs.pop('sigmas', None)
997 padSize = kwargs.pop('padSize', 7)
999 # Psf and image for science img (index 2)
1000 subExp2 = expandedSubExposure
1002 # Psf and image for template img (index 1)
1003 subExp1 = template.Factory(template, expandedSubExposure.getBBox())
1005 if sigmas is None:
1006 sig1 = sig2 = None
1007 else:
1008 # for testing, can use the input sigma (e.g., global value for entire exposure)
1009 sig1, sig2 = sigmas[0], sigmas[1]
1011 def _makePsfSquare(psf):
1012 # Sometimes CoaddPsf does this. Make it square.
1013 if psf.shape[0] < psf.shape[1]:
1014 psf = np.pad(psf, ((0, psf.shape[1] - psf.shape[0]), (0, 0)), mode='constant',
1015 constant_values=0.)
1016 elif psf.shape[0] > psf.shape[1]:
1017 psf = np.pad(psf, ((0, 0), (0, psf.shape[0] - psf.shape[1])), mode='constant',
1018 constant_values=0.)
1019 return psf
1021 psf2 = subExp2.getPsf().computeKernelImage(center).getArray()
1022 psf2 = _makePsfSquare(psf2)
1024 psf1 = template.getPsf().computeKernelImage(center).getArray()
1025 psf1 = _makePsfSquare(psf1)
1027 # from diffimTests.diffimTests ...
1028 if subExp1.getDimensions()[0] < psf1.shape[0] or subExp1.getDimensions()[1] < psf1.shape[1]:
1029 return pipeBase.Struct(subExposure=subExposure)
1031 def _filterPsf(psf):
1032 """Filter a noisy Psf to remove artifacts. Subject of future research."""
1033 # only necessary if it's from a measured psf and PsfEx seems to always make PSFs of size 41x41
1034 if psf.shape[0] == 41: # its from a measured psf
1035 psf = psf.copy()
1036 psf[psf < 0] = 0
1037 psf[0:10, :] = psf[:, 0:10] = psf[31:41, :] = psf[:, 31:41] = 0
1038 psf /= psf.sum()
1040 return psf
1042 psf1b = psf2b = None
1043 if self.config.doFilterPsfs: # default True
1044 # Note this *really* helps for measured psfs.
1045 psf1b = _filterPsf(psf1)
1046 psf2b = _filterPsf(psf2)
1048 config = ZogyConfig()
1049 if imageSpace is True:
1050 config.inImageSpace = imageSpace
1051 config.padSize = padSize # Don't need padding if doing all in fourier space
1052 task = ZogyTask(templateExposure=subExp1, scienceExposure=subExp2,
1053 sig1=sig1, sig2=sig2, psf1=psf1b, psf2=psf2b, config=config)
1055 if not doScorr:
1056 res = task.computeDiffim(**kwargs)
1057 D = res.D
1058 else:
1059 res = task.computeScorr(**kwargs)
1060 D = res.S
1062 outExp = D.Factory(D, subExposure.getBBox())
1063 out = pipeBase.Struct(subExposure=outExp)
1064 return out
1067class ZogyMapReduceConfig(ImageMapReduceConfig):
1068 """Config to be passed to ImageMapReduceTask
1070 This config targets the imageMapper to use the ZogyMapper.
1071 """
1072 mapper = pexConfig.ConfigurableField(
1073 doc='Zogy task to run on each sub-image',
1074 target=ZogyMapper
1075 )
1078class ZogyImagePsfMatchConfig(ImagePsfMatchConfig):
1079 """Config for the ZogyImagePsfMatchTask"""
1081 zogyConfig = pexConfig.ConfigField(
1082 dtype=ZogyConfig,
1083 doc='ZogyTask config to use when running on complete exposure (non spatially-varying)',
1084 )
1086 zogyMapReduceConfig = pexConfig.ConfigField(
1087 dtype=ZogyMapReduceConfig,
1088 doc='ZogyMapReduce config to use when running Zogy on each sub-image (spatially-varying)',
1089 )
1091 def setDefaults(self):
1092 self.zogyMapReduceConfig.gridStepX = self.zogyMapReduceConfig.gridStepY = 40
1093 self.zogyMapReduceConfig.cellSizeX = self.zogyMapReduceConfig.cellSizeY = 41
1094 self.zogyMapReduceConfig.borderSizeX = self.zogyMapReduceConfig.borderSizeY = 8
1095 self.zogyMapReduceConfig.reducer.reduceOperation = 'average'
1096 self.zogyConfig.inImageSpace = False
1099class ZogyImagePsfMatchTask(ImagePsfMatchTask):
1100 """Task to perform Zogy PSF matching and image subtraction.
1102 This class inherits from ImagePsfMatchTask to contain the _warper
1103 subtask and related methods.
1104 """
1106 ConfigClass = ZogyImagePsfMatchConfig
1108 def __init__(self, *args, **kwargs):
1109 ImagePsfMatchTask.__init__(self, *args, **kwargs)
1111 def _computeImageMean(self, exposure):
1112 """Compute the sigma-clipped mean of the pixels image of `exposure`.
1113 """
1114 statsControl = afwMath.StatisticsControl()
1115 statsControl.setNumSigmaClip(3.)
1116 statsControl.setNumIter(3)
1117 ignoreMaskPlanes = ("INTRP", "EDGE", "DETECTED", "SAT", "CR", "BAD", "NO_DATA", "DETECTED_NEGATIVE")
1118 statsControl.setAndMask(afwImage.Mask.getPlaneBitMask(ignoreMaskPlanes))
1119 statObj = afwMath.makeStatistics(exposure.getMaskedImage().getImage(),
1120 exposure.getMaskedImage().getMask(),
1121 afwMath.MEANCLIP | afwMath.MEDIAN, statsControl)
1122 mn = statObj.getValue(afwMath.MEANCLIP)
1123 med = statObj.getValue(afwMath.MEDIAN)
1124 return mn, med
1126 def subtractExposures(self, templateExposure, scienceExposure,
1127 doWarping=True, spatiallyVarying=True, inImageSpace=False,
1128 doPreConvolve=False):
1129 """Register, PSF-match, and subtract two Exposures using the ZOGY algorithm.
1131 Do the following, in order:
1132 - Warp templateExposure to match scienceExposure, if their WCSs do not already match
1133 - Compute subtracted exposure ZOGY image subtraction algorithm on the two exposures
1135 Parameters
1136 ----------
1137 templateExposure : `lsst.afw.image.Exposure`
1138 exposure to PSF-match to scienceExposure. The exposure's mean value is subtracted
1139 in-place.
1140 scienceExposure : `lsst.afw.image.Exposure`
1141 reference Exposure. The exposure's mean value is subtracted in-place.
1142 doWarping : `bool`
1143 what to do if templateExposure's and scienceExposure's WCSs do not match:
1144 - if True then warp templateExposure to match scienceExposure
1145 - if False then raise an Exception
1146 spatiallyVarying : `bool`
1147 If True, perform the operation over a grid of patches across the two exposures
1148 inImageSpace : `bool`
1149 If True, perform the Zogy convolutions in image space rather than in frequency space.
1150 doPreConvolve : `bool`
1151 ***Currently not implemented.*** If True assume we are to compute the match filter-convolved
1152 exposure which can be thresholded for detection. In the case of Zogy this would mean
1153 we compute the Scorr image.
1155 Returns
1156 -------
1157 A `lsst.pipe.base.Struct` containing these fields:
1158 - subtractedExposure: subtracted Exposure
1159 - warpedExposure: templateExposure after warping to match scienceExposure (if doWarping true)
1160 """
1162 mn1 = self._computeImageMean(templateExposure)
1163 mn2 = self._computeImageMean(scienceExposure)
1164 self.log.info("Exposure means=%f, %f; median=%f, %f:" % (mn1[0], mn2[0], mn1[1], mn2[1]))
1165 if not np.isnan(mn1[0]) and np.abs(mn1[0]) > 1:
1166 mi = templateExposure.getMaskedImage()
1167 mi -= mn1[0]
1168 if not np.isnan(mn2[0]) and np.abs(mn2[0]) > 1:
1169 mi = scienceExposure.getMaskedImage()
1170 mi -= mn2[0]
1172 self.log.info('Running Zogy algorithm: spatiallyVarying=%r' % spatiallyVarying)
1174 if not self._validateWcs(templateExposure, scienceExposure):
1175 if doWarping:
1176 self.log.info("Astrometrically registering template to science image")
1177 # Also warp the PSF
1178 xyTransform = afwGeom.makeWcsPairTransform(templateExposure.getWcs(),
1179 scienceExposure.getWcs())
1180 psfWarped = measAlg.WarpedPsf(templateExposure.getPsf(), xyTransform)
1181 templateExposure = self._warper.warpExposure(scienceExposure.getWcs(),
1182 templateExposure,
1183 destBBox=scienceExposure.getBBox())
1185 templateExposure.setPsf(psfWarped)
1186 else:
1187 self.log.error("ERROR: Input images not registered")
1188 raise RuntimeError("Input images not registered")
1190 def gm(exp):
1191 return exp.getMaskedImage().getMask()
1193 def ga(exp):
1194 return exp.getMaskedImage().getImage().getArray()
1196 if self.config.zogyConfig.inImageSpace:
1197 inImageSpace = True # Override
1198 self.log.info('Running Zogy algorithm: inImageSpace=%r' % inImageSpace)
1199 if spatiallyVarying:
1200 config = self.config.zogyMapReduceConfig
1201 task = ImageMapReduceTask(config=config)
1202 results = task.run(scienceExposure, template=templateExposure, inImageSpace=inImageSpace,
1203 doScorr=doPreConvolve, forceEvenSized=False)
1204 results.D = results.exposure
1205 # The CoaddPsf, when used for detection does not utilize its spatially-varying
1206 # properties; it simply computes the PSF at its getAveragePosition().
1207 # TODO: we need to get it to return the matchedExposure (convolved template)
1208 # too, for dipole fitting; but the imageMapReduce task might need to be engineered
1209 # for this purpose.
1210 else:
1211 config = self.config.zogyConfig
1212 task = ZogyTask(scienceExposure=scienceExposure, templateExposure=templateExposure,
1213 config=config)
1214 if not doPreConvolve:
1215 results = task.computeDiffim(inImageSpace=inImageSpace)
1216 results.matchedExposure = results.R
1217 else:
1218 results = task.computeScorr(inImageSpace=inImageSpace)
1219 results.D = results.S
1221 # Make sure masks of input images are propagated to diffim
1222 mask = results.D.getMaskedImage().getMask()
1223 mask |= scienceExposure.getMaskedImage().getMask()
1224 mask |= templateExposure.getMaskedImage().getMask()
1225 results.D.getMaskedImage().getMask()[:, :] = mask
1226 badBitsNan = mask.addMaskPlane('UNMASKEDNAN')
1227 resultsArr = results.D.getMaskedImage().getMask().getArray()
1228 resultsArr[np.isnan(resultsArr)] |= badBitsNan
1229 resultsArr[np.isnan(scienceExposure.getMaskedImage().getImage().getArray())] |= badBitsNan
1230 resultsArr[np.isnan(templateExposure.getMaskedImage().getImage().getArray())] |= badBitsNan
1232 results.subtractedExposure = results.D
1233 results.warpedExposure = templateExposure
1234 return results
1236 def subtractMaskedImages(self, templateExposure, scienceExposure,
1237 doWarping=True, spatiallyVarying=True, inImageSpace=False,
1238 doPreConvolve=False):
1239 raise NotImplementedError
1242subtractAlgorithmRegistry.register('zogy', ZogyImagePsfMatchTask)