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