32 from .imagePsfMatch
import (ImagePsfMatchTask, ImagePsfMatchConfig,
33 subtractAlgorithmRegistry)
35 __all__ = [
"ZogyTask",
"ZogyConfig",
36 "ZogyImagePsfMatchConfig",
"ZogyImagePsfMatchTask"]
39 """Tasks for performing the "Proper image subtraction" algorithm of
40 Zackay, et al. (2016), hereafter simply referred to as 'ZOGY (2016)'.
42 `ZogyTask` contains methods to perform the basic estimation of the
43 ZOGY diffim ``D``, its updated PSF, and the variance-normalized
44 likelihood image ``S_corr``. We have implemented ZOGY using the
45 proscribed methodology, computing all convolutions in Fourier space,
46 and also variants in which the convolutions are performed in real
47 (image) space. The former is faster and results in fewer artifacts
48 when the PSFs are noisy (i.e., measured, for example, via
49 `PsfEx`). The latter is presumed to be preferred as it can account for
50 masks correctly with fewer "ringing" artifacts from edge effects or
51 saturated stars, but noisy PSFs result in their own smaller
52 artifacts. Removal of these artifacts is a subject of continuing
53 research. Currently, we "pad" the PSFs when performing the
54 subtractions in real space, which reduces, but does not entirely
55 eliminate these artifacts.
57 All methods in `ZogyTask` assume template and science images are
58 already accurately photometrically and astrometrically registered.
60 `ZogyMapper` is a wrapper which runs `ZogyTask` in the
61 `ImageMapReduce` framework, computing of ZOGY diffim's on small,
62 overlapping sub-images, thereby enabling complete ZOGY diffim's which
63 account for spatially-varying noise and PSFs across the two input
64 exposures. An example of the use of this task is in the `testZogy.py`
70 """Configuration parameters for the ZogyTask
73 templateFluxScaling = pexConfig.Field(
76 doc=
"Template flux scaling factor (Fr in ZOGY paper)"
79 scienceFluxScaling = pexConfig.Field(
82 doc=
"Science flux scaling factor (Fn in ZOGY paper)"
85 scaleByCalibration = pexConfig.Field(
88 doc=
"Compute the flux normalization scaling based on the image calibration."
89 "This overrides 'templateFluxScaling' and 'scienceFluxScaling'."
92 correctBackground = pexConfig.Field(
95 doc=
"Subtract exposure background mean to have zero expectation value."
98 ignoreMaskPlanes = pexConfig.ListField(
100 default=(
"INTRP",
"EDGE",
"DETECTED",
"SAT",
"CR",
"BAD",
"NO_DATA",
"DETECTED_NEGATIVE"),
101 doc=
"Mask planes to ignore for statistics"
103 maxPsfCentroidDist = pexConfig.Field(
106 doc=
"Maximum centroid difference allowed between the two exposure PSFs (pixels)."
111 """Task to perform ZOGY proper image subtraction. See module-level documentation for
115 ConfigClass = ZogyConfig
116 _DefaultName =
"imageDifferenceZogy"
118 def _computeVarianceMean(self, exposure):
119 """Compute the sigma-clipped mean of the variance image of ``exposure``.
122 exposure.getMaskedImage().getMask(),
124 var = statObj.getValue(afwMath.MEANCLIP)
129 """Zero pad an image where the origin is at the center and replace the
130 origin to the corner as required by the periodic input of FFT.
132 Implement also the inverse operation, crop the padding and re-center data.
137 An array to copy from.
138 newShape : `tuple` of `int`
139 The dimensions of the resulting array. For padding, the resulting array
140 must be larger than A in each dimension. For the inverse operation this
141 must be the original, before padding size of the array.
142 useInverse : bool, optional
143 Selector of forward, add padding, operation (False)
144 or its inverse, crop padding, operation (True).
145 dtype: `numpy.dtype`, optional
146 Dtype of output array. Values must be implicitly castable to this type.
147 Use to get expected result type, e.g. single float (nympy.float32).
148 If not specified, dtype is inherited from ``A``.
153 The padded or unpadded array with shape of `newShape` and dtype of ``dtype``.
157 For odd dimensions, the splitting is rounded to
158 put the center pixel into the new corner origin (0,0). This is to be consistent
159 e.g. for a dirac delta kernel that is originally located at the center pixel.
164 ValueError : ``newShape`` dimensions must be greater than or equal to the
165 dimensions of ``A`` for the forward operation and less than or equal to
166 for the inverse operation.
173 firstHalves = [x//2
for x
in A.shape]
174 secondHalves = [x-y
for x, y
in zip(A.shape, firstHalves)]
175 for d1, d2
in zip(newShape, A.shape):
177 raise ValueError(
"Newshape dimensions must be greater or equal")
180 secondHalves = [x//2
for x
in newShape]
181 firstHalves = [x-y
for x, y
in zip(newShape, secondHalves)]
182 for d1, d2
in zip(newShape, A.shape):
184 raise ValueError(
"Newshape dimensions must be smaller or equal")
189 R = np.zeros(newShape, dtype=dtype)
190 R[-firstHalves[0]:, -firstHalves[1]:] = A[:firstHalves[0], :firstHalves[1]]
191 R[:secondHalves[0], -firstHalves[1]:] = A[-secondHalves[0]:, :firstHalves[1]]
192 R[:secondHalves[0], :secondHalves[1]] = A[-secondHalves[0]:, -secondHalves[1]:]
193 R[-firstHalves[0]:, :secondHalves[1]] = A[:firstHalves[0], -secondHalves[1]:]
197 """Calculate the common shape for FFT operations.
199 Set ``self.freqSpaceShape`` internally.
203 shapes : one or more `tuple` of `int`
204 Shapes of the arrays. All must have the same dimensionality.
205 At least one shape must be provided.
213 For each dimension, gets the smallest even number greater than or equal to
214 `N1+N2-1` where `N1` and `N2` are the two largest values.
215 In case of only one shape given, rounds up to even each dimension value.
217 S = np.array(shapes, dtype=int)
222 commonShape = np.sum(S, axis=0) - 1
225 commonShape[commonShape % 2 != 0] += 1
227 self.log.
info(f
"Common frequency space shape {self.freqSpaceShape}")
230 """Prepare and forward FFT an image array.
234 imgArr : `numpy.ndarray` of `float`
235 Original array. In-place modified as `numpy.nan` and `numpy.inf` are replaced by
240 result : `lsst.pipe.base.Struct`
241 - ``imFft`` : `numpy.ndarray` of `numpy.complex`.
243 - ``filtInf``, ``filtNaN`` : `numpy.ndarray` of `bool`
247 Save location of non-finite values for restoration, and replace them
248 with image mean values. Re-center and zero pad array by `padCenterOriginArray`.
250 filtInf = np.isinf(imgArr)
251 filtNaN = np.isnan(imgArr)
252 imgArr[filtInf] = np.nan
253 imgArr[filtInf | filtNaN] = np.nanmean(imgArr)
254 self.log.
debug(
"Replacing {} Inf and {} NaN values.".
format(
255 np.sum(filtInf), np.sum(filtNaN)))
257 imgArr = np.fft.fft2(imgArr)
258 return pipeBase.Struct(imFft=imgArr, filtInf=filtInf, filtNaN=filtNaN)
261 """Inverse FFT and crop padding from image array.
265 imgArr : `numpy.ndarray` of `numpy.complex`
266 Fourier space array representing a real image.
268 origSize : `tuple` of `int`
269 Original unpadded shape tuple of the image to be cropped to.
271 filtInf, filtNan : `numpy.ndarray` of bool or int, optional
272 If specified, they are used as index arrays for ``result`` to set values to
273 `numpy.inf` and `numpy.nan` respectively at these positions.
275 dtype : `numpy.dtype`, optional
276 Dtype of result array to cast return values to implicitly. This is to
277 spare one array copy operation at reducing double precision to single.
278 If `None` result inherits dtype of `imgArr`.
282 result : `numpy.ndarray` of `dtype`
284 imgNew = np.fft.ifft2(imgArr)
287 if filtInf
is not None:
288 imgNew[filtInf] = np.inf
289 if filtNaN
is not None:
290 imgNew[filtNaN] = np.nan
295 """Computes the PSF image at the bbox center point.
297 This may be at a fractional pixel position.
301 exposure : `lsst.afw.image.Exposure`
306 psfImg : `lsst.afw.image.Image`
307 Calculated psf image.
309 bbox = exposure.getBBox()
310 cen = bbox.getCenter()
311 psf = exposure.getPsf()
312 psfImg = psf.computeKernelImage(cen)
317 """In-place subtraction of sigma-clipped mean of the image.
321 image : `lsst.afw.image.Image`
322 Image to manipulate. Its sigma clipped mean is in-place subtracted.
324 mask : `lsst.afw.image.Mask`
325 Mask to use for ignoring pixels.
327 statsControl : `lsst.afw.math.StatisticsControl`
328 Config of sigma clipped mean statistics calculation.
336 ValueError : If image mean is nan.
339 afwMath.MEANCLIP, statsControl)
340 mean = statObj.getValue(afwMath.MEANCLIP)
341 if not np.isnan(mean):
344 raise ValueError(
"Image mean is NaN.")
347 """Performs calculations that apply to the full exposures once only in the psf matching.
352 correctBackground : `bool`, optional
353 If True, subtracts sigma-clipped mean of exposures. The algorithm
354 assumes zero expectation value at background pixels.
362 Set a number of instance fields with pre-calculated values. ``psfShape``,
363 ``imgShape`` fields follow the numpy ndarray shape convention i.e. height,
368 ValueError : If photometric calibrations are not available while
369 ``config.scaleByCalibration`` equals True.
375 self.
statsControl.setAndMask(afwImage.Mask.getPlaneBitMask(
376 self.config.ignoreMaskPlanes))
378 exposure1 = exposure1.clone()
379 exposure2 = exposure2.clone()
381 if self.config.scaleByCalibration:
382 calibObj1 = exposure1.getPhotoCalib()
383 calibObj2 = exposure2.getPhotoCalib()
384 if calibObj1
is None or calibObj2
is None:
385 raise ValueError(
"Photometric calibrations are not available for both exposures.")
386 mImg1 = calibObj1.calibrateImage(exposure1.maskedImage)
387 mImg2 = calibObj2.calibrateImage(exposure2.maskedImage)
391 self.
F1 = self.config.templateFluxScaling
392 self.
F2 = self.config.scienceFluxScaling
393 mImg1 = exposure1.maskedImage
394 mImg2 = exposure2.maskedImage
397 if correctBackground:
401 psfBBox1 = exposure1.getPsf().computeBBox()
402 psfBBox2 = exposure2.getPsf().computeBBox()
404 self.
psfShape1 = (psfBBox1.getHeight(), psfBBox1.getWidth())
405 self.
psfShape2 = (psfBBox2.getHeight(), psfBBox2.getWidth())
406 self.
imgShape = (mImg1.getHeight(), mImg1.getWidth())
409 exposure1.maskedImage = mImg1
410 exposure2.maskedImage = mImg2
425 """Perform per-sub exposure preparations.
429 sig1, sig2 : `float`, optional
430 For debug purposes only, copnsider that the image
431 may already be rescaled by the photometric calibration.
432 bbox1, bbox2 : `lsst.geom.Box2I`, optional
433 If specified, the region of the full exposure to use.
435 psf1, psf2 : `lsst.afw.detection.Psf`, optional
436 If specified, use given psf as the sub exposure psf. For debug purposes.
438 sig1, sig2 : `float`, optional
439 If specified, use value as the sub-exposures' background noise sigma value.
447 TODO DM-23855: Performing ZOGY on a grid is not yet implemented.
448 Set (replace) a number of instance fields with pre-calculated values
449 about the current sub exposure including the FFT of the psfs.
453 ValueError: If sub-exposure dimensions do not match.
464 if subExposure1.getDimensions() != subExposure2.getDimensions():
465 raise ValueError(
"Subexposure dimensions do not match.")
494 """Square the argument in pixel space.
498 D : 2D `numpy.ndarray` of `numpy.complex`
499 Fourier transform of a real valued array.
503 R : `numpy.ndarray` of `numpy.complex`
507 ``D`` is to be inverse Fourier transformed, squared and then
508 forward Fourier transformed again, i.e. an autoconvolution in Fourier space.
509 This operation is not distributive over multiplication.
510 ``pixelSpaceSquare(A*B) != pixelSpaceSquare(A)*pixelSpaceSquare(B)``
512 R = np.real(np.fft.ifft2(D))
519 """Calculate the centroid coordinates of a 2D array.
523 A : 2D `numpy.ndarray` of `float`
524 The input array. Must not be all exact zero.
528 Calculates the centroid as if the array represented a 2D geometrical shape with
529 weights per cell, allowing for "negative" weights. If sum equals to exact (float) zero,
530 calculates centroid of absolute value array.
532 The geometrical center is defined as (0,0), independently of the array shape.
533 For an odd dimension, this is the center of the center pixel,
534 for an even dimension, this is between the two center pixels.
538 ycen, xcen : `tuple` of `float`
545 w = np.arange(A.shape[0], dtype=float) - (A.shape[0] - 1.)/2
546 ycen = np.sum(w[:, np.newaxis]*A)/s
547 w = np.arange(A.shape[1], dtype=float) - (A.shape[1] - 1.)/2
548 xcen = np.sum(w[np.newaxis, :]*A)/s
553 """Check whether two PSF array centroids' distance is within tolerance.
557 psfArr1, psfArr2 : `numpy.ndarray` of `float`
558 Input PSF arrays to check.
567 Centroid distance exceeds `config.maxPsfCentroidDist` pixels.
573 if dy*dy + dx*dx > self.config.maxPsfCentroidDist*self.config.maxPsfCentroidDist:
575 f
"PSF centroids are offset by more than {self.config.maxPsfCentroidDist:.2f} pixels.")
578 psf2, im2, varPlane2, F2, varMean2, calculateScore=True):
579 """Convolve and subtract two images in Fourier space.
581 Calculate the ZOGY proper difference image, score image and their PSFs.
582 All input and output arrays are in Fourier space.
586 psf1, psf2, im1, im2, varPlane1, varPlane2 : `numpy.ndarray` of `numpy.complex`,
587 shape ``self.freqSpaceShape``
588 Psf, image and variance plane arrays respectively.
589 All arrays must be already in Fourier space.
591 varMean1, varMean2: `numpy.float` > 0.
592 Average per-pixel noise variance in im1, im2 respectively. Used as weighing
593 of input images. Must be greater than zero.
595 F1, F2 : `numpy.float` > 0.
596 Photometric scaling of the images. See eqs. (5)--(9)
598 calculateScore : `bool`, optional
599 If True (default), calculate and return the detection significance (score) image.
600 Otherwise, these return fields are `None`.
604 result : `pipe.base.Struct`
605 All arrays are in Fourier space and have shape ``self.freqSpaceShape``.
607 Photometric level of ``D``.
608 - ``D`` : `numpy.ndarray` of `numpy.complex`
609 The difference image.
610 - ``varplaneD`` : `numpy.ndarray` of `numpy.complex`
611 Variance plane of ``D``.
612 - ``Pd`` : `numpy.ndarray` of `numpy.complex`
614 - ``S`` : `numpy.ndarray` of `numpy.complex` or `None`
615 Significance (score) image.
616 - ``varplaneS`` : `numpy.ndarray` of `numpy.complex` or `None`
617 Variance plane of ``S``.
618 - ``Ps`` : `numpy.ndarray` of `numpy.complex`
623 All array inputs and outputs are Fourier-space images with size of
624 `self.freqSpaceShape` in this method.
626 ``varMean1``, ``varMean2`` quantities are part of the noise model and not to be confused
627 with the variance of image frequency components or with ``varPlane1``, ``varPlane2`` that
628 are the Fourier transform of the variance planes.
630 var1F2Sq = varMean1*F2*F2
631 var2F1Sq = varMean2*F1*F1
633 psfAbsSq1 = np.real(np.conj(psf1)*psf1)
634 psfAbsSq2 = np.real(np.conj(psf2)*psf2)
635 FdDenom = np.sqrt(var1F2Sq + var2F1Sq)
638 tiny = np.finfo(psf1.dtype).tiny * 100
639 sDenom = var1F2Sq*psfAbsSq2 + var2F1Sq*psfAbsSq1
649 fltZero = sDenom < tiny
650 nZero = np.sum(fltZero)
651 self.log.
debug(f
"There are {nZero} frequencies where both FFTd PSFs are close to zero.")
654 fltZero = np.nonzero(fltZero)
655 sDenom[fltZero] = tiny
656 denom = np.sqrt(sDenom)
661 c1[fltZero] = F2/FdDenom
662 c2[fltZero] = F1/FdDenom
666 Pd = FdDenom*psf1*psf2/denom
672 c1 = F1*F2*F2*np.conj(psf1)*psfAbsSq2/sDenom
673 c2 = F2*F1*F1*np.conj(psf2)*psfAbsSq1/sDenom
684 return pipeBase.Struct(D=D, Pd=Pd, varPlaneD=varPlaneD, Fd=Fd,
685 S=S, Ps=Ps, varPlaneS=varPlaneS)
689 """Calculate the mask plane of the difference image.
693 mask1, maks2 : `lsst.afw.image.Mask`
694 Mask planes of the two exposures.
699 diffmask : `lsst.afw.image.Mask`
700 Mask plane for the subtraction result.
704 TODO DM-25174 : Specification of effPsf1, effPsf2 are not yet supported.
708 if effPsf1
is not None or effPsf2
is not None:
712 raise NotImplementedError(
"Mask plane only 'convolution' operation is not yet supported")
719 """Create a non spatially varying PSF from a `numpy.ndarray`.
724 2D array to use as the new psf image. The pixels are copied.
728 psfNew : `lsst.meas.algorithms.KernelPsf`
731 psfImg = afwImage.ImageD(A.astype(np.float64, copy=
True), deep=
False)
736 """Wrap array results into Exposure objects.
740 ftDiff : `lsst.pipe.base.Struct`
741 Result struct by `calculateFourierDiffim`.
745 resultName : `lsst.pipe.base.Struct`
746 - ``diffSubExp`` : `lsst.afw.image.Exposure`
747 The difference (sub)exposure. The exposure is calibrated
748 in its pixel values, and has a constant `PhotoCalib` object of 1.
749 - ``scoreSubExp`` : `lsst.afw.image.Exposure` or `None`
750 The score (sub)exposure if it was calculated.
764 self.log.
info(f
"Pd sum before normalization: {sumPd:.3f}")
772 diffSubExposure.image = imgD[bbox]
775 diffSubExposure.variance = imgVarPlaneD[bbox]
779 diffSubExposure.maskedImage /= ftDiff.Fd
782 diffSubExposure.setPhotoCalib(calibOne)
786 if ftDiff.S
is not None:
799 tiny = np.finfo(varPlaneS.dtype).tiny * 100
800 flt = np.logical_or(flt, varPlaneS < tiny)
810 imgVarPlaneS = imgVarPlaneS[bbox]
815 self.log.
info(f
"Ps sum before normalization: {sumPs:.3f}")
821 scoreSubExposure.image = imgS
822 scoreSubExposure.variance = imgVarPlaneS
823 scoreSubExposure.mask = diffSubExposure.mask
824 scoreSubExposure.setPhotoCalib(
None)
827 scoreSubExposure =
None
829 return pipeBase.Struct(diffSubExp=diffSubExposure, scoreSubExp=scoreSubExposure)
831 def run(self, exposure1, exposure2, calculateScore=True):
832 """Task entry point to perform the zogy subtraction
833 of ``exposure1-exposure2``.
837 exposure1, exposure2 : `lsst.afw.image.Exposure`
838 Two exposures warped and matched into matching pixel dimensions.
839 calculateScore : `bool`, optional
840 If True (default), calculate the score image and return in ``scoreExp``.
845 resultName : `lsst.pipe.base.Struct`
846 - ``diffExp`` : `lsst.afw.image.Exposure`
847 The Zogy difference exposure (``exposure1-exposure2``).
848 - ``scoreExp`` : `lsst.afw.image.Exposure` or `None`
849 The Zogy significance or score (S) exposure if ``calculateScore==True``.
850 - ``ftDiff`` : `lsst.pipe.base.Struct`
851 Lower level return struct by `calculateFourierDiffim` with added
852 fields from the task instance. For debug purposes.
857 The score image (``S``) is defined in the ZOGY paper as the detection
858 statistic value at each pixel. In the ZOGY image model, the input images
859 have uniform variance noises and thus ``S`` has uniform per pixel
860 variance (though it is not scaled to 1). In Section 3.3 of the paper,
861 there are "corrections" defined to the score image to correct the
862 significance values for some deviations from the image model. The first
863 of these corrections is the calculation of the _variance plane_ of ``S``
864 allowing for different per pixel variance values by following the
865 overall convolution operation on the pixels of the input images. ``S``
866 scaled (divided) by its corrected per pixel noise is referred as
867 ``Scorr`` in the paper.
869 In the current implementation, ``scoreExp`` contains ``S`` in its image
870 plane and the calculated (non-uniform) variance plane of ``S`` in its
871 variance plane. ``scoreExp`` can be used directly for source detection
872 as a likelihood image by respecting its variance plane or can be divided
873 by the square root of the variance plane to scale detection significance
874 values into units of sigma.
876 TODO DM-23855 : Implement further correction tags to the variance of
877 ``scoreExp``. As of DM-25174 it is not determined how important these
878 further correction tags are.
880 TODO DM-23855 : spatially varying solution on a grid is not yet implemented
883 if exposure1.getDimensions() != exposure2.getDimensions():
884 raise ValueError(
"Exposure dimensions do not match ({} != {} )".
format(
885 exposure1.getDimensions(), exposure2.getDimensions()))
887 self.
prepareFullExposure(exposure1, exposure2, correctBackground=self.config.correctBackground)
896 calculateScore=calculateScore)
903 return pipeBase.Struct(diffExp=diffExp.diffSubExp,
904 scoreExp=diffExp.scoreSubExp,
909 """Config for the ZogyImagePsfMatchTask"""
911 zogyConfig = pexConfig.ConfigField(
913 doc=
'ZogyTask config to use when running on complete exposure (non spatially-varying)',
918 """Task to perform Zogy PSF matching and image subtraction.
920 This class inherits from ImagePsfMatchTask to contain the _warper
921 subtask and related methods.
924 ConfigClass = ZogyImagePsfMatchConfig
927 ImagePsfMatchTask.__init__(self, *args, **kwargs)
929 def run(self, scienceExposure, templateExposure, doWarping=True, spatiallyVarying=False):
930 """Register, PSF-match, and subtract two Exposures, ``scienceExposure - templateExposure``
931 using the ZOGY algorithm.
935 templateExposure : `lsst.afw.image.Exposure`
936 exposure to be warped to scienceExposure.
937 scienceExposure : `lsst.afw.image.Exposure`
940 what to do if templateExposure's and scienceExposure's WCSs do not match:
941 - if True then warp templateExposure to match scienceExposure
942 - if False then raise an Exception
943 spatiallyVarying : `bool`
944 If True, perform the operation over a grid of patches across the two exposures
948 Do the following, in order:
949 - Warp templateExposure to match scienceExposure, if their WCSs do not already match
950 - Compute subtracted exposure ZOGY image subtraction algorithm on the two exposures
952 This is the new entry point of the task as of DM-25115.
957 results : `lsst.pipe.base.Struct` containing these fields:
958 - subtractedExposure: `lsst.afw.image.Exposure`
959 The subtraction result.
960 - warpedExposure: `lsst.afw.image.Exposure` or `None`
961 templateExposure after warping to match scienceExposure
965 raise NotImplementedError(
966 "DM-25115 Spatially varying zogy subtraction is not implemented.")
968 if not self.
_validateWcs(scienceExposure, templateExposure):
970 self.log.
info(
"Warping templateExposure to scienceExposure")
972 scienceExposure.getWcs())
973 psfWarped = measAlg.WarpedPsf(templateExposure.getPsf(), xyTransform)
975 scienceExposure.getWcs(), templateExposure, destBBox=scienceExposure.getBBox())
976 templateExposure.setPsf(psfWarped)
978 raise RuntimeError(
"Input images are not registered. Consider setting doWarping=True.")
980 config = self.config.zogyConfig
982 results = task.run(scienceExposure, templateExposure)
983 results.warpedExposure = templateExposure
987 doWarping=True, spatiallyVarying=True, inImageSpace=False,
988 doPreConvolve=False):
989 raise NotImplementedError
992 doWarping=True, spatiallyVarying=True, inImageSpace=False,
993 doPreConvolve=False):
994 raise NotImplementedError
997 subtractAlgorithmRegistry.register(
'zogy', ZogyImagePsfMatchTask)