Coverage for python/lsst/meas/extensions/gaap/_gaussianizePsf.py: 16%
126 statements
« prev ^ index » next coverage.py v7.1.0, created at 2023-02-15 23:45 +0000
« prev ^ index » next coverage.py v7.1.0, created at 2023-02-15 23:45 +0000
1# This file is part of meas_extensions_gaap
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org/).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the LSST License Statement and
20# the GNU General Public License along with this program. If not,
21# see <http://www.lsstcorp.org/LegalNotices/>.
23from __future__ import annotations
25__all__ = ("GaussianizePsfTask", "GaussianizePsfConfig")
27import numpy as np
28import scipy.signal
30import lsst.afw.image as afwImage
31import lsst.afw.math as afwMath
32import lsst.geom as geom
33from lsst.ip.diffim import diffimLib
34from lsst.ip.diffim.makeKernelBasisList import makeKernelBasisList
35from lsst.ip.diffim.modelPsfMatch import ModelPsfMatchConfig, ModelPsfMatchTask
36from lsst.ip.diffim.modelPsfMatch import sigma2fwhm, nextOddInteger
37import lsst.pex.config as pexConfig
38import lsst.pipe.base as pipeBase
39from lsst.utils.logging import getTraceLogger
42class GaussianizePsfConfig(ModelPsfMatchConfig):
43 """Configuration for model-to-model Psf matching."""
45 convolutionMethod = pexConfig.ChoiceField(
46 dtype=str,
47 doc="Which type of convolution to use",
48 default="auto",
49 allowed={
50 "direct": "Brute-force real-space convolution",
51 "fft": "Convolve using FFTs (generally faster)",
52 "auto": "Choose the faster method between 'direct' and 'fft'",
53 "overlap-add": "Convolve using the overlap-add method",
54 }
55 )
58class GaussianizePsfTask(ModelPsfMatchTask):
59 """Task to make the PSF at a source Gaussian.
61 This is a specialized version of `lsst.ip.diffim.ModelPsfMatchTask` for
62 use within the Gaussian-Aperture and PSF (GAaP) photometry plugin. The
63 `run` method has a different signature and is optimized for multiple calls.
64 The optimization includes treating PSF as spatially constant within the
65 footprint of a source and substituting `lsst.afw.math.convolution` method
66 with scipy.signal's. The PSF is evaluated at the centroid of the source.
67 Unlike `lsst.ip.diffim.ModelPsfMatchTask`, the assessment of the fit from
68 residuals is not made. This is assessed via `PsfFlux` in the GAaP plugin.
70 See also
71 --------
72 ModelPsfMatchTask
73 """
74 ConfigClass = GaussianizePsfConfig
76 def __init__(self, *args, **kwargs):
77 super().__init__(*args, **kwargs)
78 self.kConfig = self.config.kernel.active
79 self.ps = pexConfig.makePropertySet(self.kConfig)
81 def run(self, exposure: lsst.afw.image.Exposure, center: lsst.geom.Point2D, # noqa: F821
82 targetPsfModel: lsst.afw.detection.GaussianPsf, # noqa: F821
83 kernelSum=1.0, basisSigmaGauss=None) -> pipeBase.Struct:
84 """Make the PSF of an exposure match a model PSF.
86 Parameters
87 ----------
88 exposure : `~lsst.afw.image.Exposure`
89 A (sub-)exposure containing a single (deblended) source being
90 measured; it must return a valid PSF model via exposure.getPsf().
91 center : `~lsst.geom.Point2D`
92 The centroid position of the source being measured.
93 targetPsfModel : `~lsst.afw.detection.GaussianPsf`
94 The model GaussianPsf to which the PSF at the source must be
95 matched to.
96 kernelSum : `float`, optional
97 A multipicative factor to apply to the kernel sum.
98 basisSigmaGauss: `list` [`float`], optional
99 The sigma (in pixels) of the Gaussian in the Alard-Lupton basis set
100 used to express the kernel in. This is used only if ``scaleByFwhm``
101 is set to False. If it is not provided, then it defaults to
102 `config.alardSigGauss`.
104 Returns
105 -------
106 result : `struct`
107 - ``psfMatchedExposure`` : the Psf-matched Exposure.
108 This has the same parent bbox, wcs as ``exposure``
109 and ``targetPsfModel`` as its Psf.
110 - ``psfMatchingKernel`` : Psf-matching kernel.
111 - ``kernelCellSet`` : SpatialCellSet used to solve for the
112 Psf-matching kernel.
113 - ``metadata`` : Metadata generated in making Alard-Lupton basis
114 set.
115 """
116 maskedImage = exposure.getMaskedImage()
118 result = self._buildCellSet(exposure, center, targetPsfModel)
119 kernelCellSet = result.kernelCellSet
120 targetPsfModel = result.targetPsfModel
121 fwhmScience = exposure.getPsf().computeShape(center).getTraceRadius()*sigma2fwhm
122 fwhmModel = targetPsfModel.getSigma()*sigma2fwhm # This works only because it is a `GaussianPsf`.
123 self.log.debug("Ratio of GAaP model to science PSF = %f", fwhmModel/fwhmScience)
125 basisList = makeKernelBasisList(self.kConfig, fwhmScience, fwhmModel,
126 basisSigmaGauss=basisSigmaGauss,
127 metadata=self.metadata)
128 spatialSolution, psfMatchingKernel, backgroundModel = self._solve(kernelCellSet, basisList)
130 kParameters = np.array(psfMatchingKernel.getKernelParameters())
131 kParameters[0] = kernelSum
132 psfMatchingKernel.setKernelParameters(kParameters)
134 bbox = exposure.getBBox()
135 psfMatchedExposure = afwImage.ExposureD(bbox, exposure.getWcs())
136 psfMatchedExposure.setPsf(targetPsfModel)
138 # Normalize the psf-matching kernel while convolving since its
139 # magnitude is meaningless when PSF-matching one model to another.
140 kernelImage = afwImage.ImageD(psfMatchingKernel.getDimensions())
141 psfMatchingKernel.computeImage(kernelImage, False)
143 if self.config.convolutionMethod == "overlap-add":
144 # The order of image arrays is important if mode="same", since the
145 # returned image array has the same dimensions as the first one.
146 psfMatchedImageArray = scipy.signal.oaconvolve(maskedImage.image.array, kernelImage.array,
147 mode="same")
148 else:
149 convolutionMethod = self.config.convolutionMethod
150 if convolutionMethod == "auto":
151 # Decide if the convolution is faster in real-space or in
152 # Fourier space? scipy.signal.convolve uses this under the
153 # hood, but we call here for logging purposes.
154 convolutionMethod = scipy.signal.choose_conv_method(maskedImage.image.array,
155 kernelImage.array)
156 self.log.debug("Using %s method for convolution.", convolutionMethod)
158 # The order of image arrays is important if mode="same", since the
159 # returned array has the same dimensions as the first argument.
160 psfMatchedImageArray = scipy.signal.convolve(maskedImage.image.array, kernelImage.array,
161 method=convolutionMethod, mode="same")
163 psfMatchedImage = afwImage.ImageD(psfMatchedImageArray)
164 psfMatchedExposure.setImage(psfMatchedImage)
166 return pipeBase.Struct(psfMatchedExposure=psfMatchedExposure,
167 psfMatchingKernel=psfMatchingKernel,
168 kernelCellSet=kernelCellSet,
169 metadata=self.metadata,
170 )
172 def _buildCellSet(self, exposure, center, targetPsfModel) -> pipeBase.Struct:
173 """Build a SpatialCellSet with one cell for use with the solve method.
175 This builds a SpatialCellSet containing a single cell and a single
176 candidate, centered at the location of the source.
178 Parameters
179 ----------
180 exposure : `lsst.afw.image.Exposure`
181 The science exposure that will be convolved; must contain a Psf.
182 center : `lsst.geom.Point2D`
183 The centroid of the source being measured.
184 targetPsfModel : `~lsst.afw.detection.GaussianPsf`
185 Psf model to match to.
187 Returns
188 -------
189 result : `struct`
190 - ``kernelCellSet`` : a SpatialCellSet to be used by self._solve
191 - ``targetPsfModel`` : Validated and/or modified
192 target model used to populate the SpatialCellSet
194 Notes
195 -----
196 If the target Psf model and science Psf model have different
197 dimensions, adjust the targetPsfModel (the model to which the
198 exposure PSF will be matched) to match that of the science Psf.
199 If the science Psf dimensions vary across the image,
200 as is common with a WarpedPsf, either pad or clip
201 (depending on config.padPsf) the dimensions to be constant.
202 """
203 sizeCellX = self.kConfig.sizeCellX
204 sizeCellY = self.kConfig.sizeCellY
206 scienceBBox = exposure.getBBox()
207 scienceBBox.grow(geom.Extent2I(sizeCellX, sizeCellY))
208 sciencePsfModel = exposure.getPsf()
209 dimenR = targetPsfModel.getDimensions()
211 # Have the size of the region much larger than the bbox, so that
212 # the ``kernelCellSet`` has only one instance of `SpatialCell`.
213 regionSize = 10*max(scienceBBox.getWidth(), scienceBBox.getHeight())
214 kernelCellSet = afwMath.SpatialCellSet(geom.Box2I(scienceBBox), regionSize)
216 # Survey the PSF dimensions of the Spatial Cell Set
217 # to identify the minimum enclosed or maximum bounding square BBox.
218 scienceMI = self._makePsfMaskedImage(sciencePsfModel, center)
219 psfWidth, psfHeight = scienceMI.getBBox().getDimensions()
220 psfSize = max(psfWidth, psfHeight)
222 if self.config.doAutoPadPsf:
223 minPsfSize = nextOddInteger(self.kConfig.kernelSize*self.config.autoPadPsfTo)
224 paddingPix = max(0, minPsfSize - psfSize)
225 else:
226 if self.config.padPsfBy % 2 != 0:
227 raise ValueError("Config padPsfBy (%i pixels) must be even number." %
228 self.config.padPsfBy)
229 paddingPix = self.config.padPsfBy
231 if paddingPix > 0:
232 self.log.debug("Padding Science PSF from (%d, %d) to (%d, %d) pixels",
233 psfSize, psfSize, paddingPix + psfSize, paddingPix + psfSize)
234 psfSize += paddingPix
236 # Check that PSF is larger than the matching kernel.
237 maxKernelSize = psfSize - 1
238 if maxKernelSize % 2 == 0:
239 maxKernelSize -= 1
240 if self.kConfig.kernelSize > maxKernelSize:
241 message = """
242 Kernel size (%d) too big to match Psfs of size %d.
243 Please reconfigure by setting one of the following:
244 1) kernel size to <= %d
245 2) doAutoPadPsf=True
246 3) padPsfBy to >= %s
247 """ % (self.kConfig.kernelSize, psfSize,
248 maxKernelSize, self.kConfig.kernelSize - maxKernelSize)
249 raise ValueError(message)
251 dimenS = geom.Extent2I(psfSize, psfSize)
253 if (dimenR != dimenS):
254 try:
255 targetPsfModel = targetPsfModel.resized(psfSize, psfSize)
256 except Exception as e:
257 self.log.warning("Zero padding or clipping the target PSF model of type %s and dimensions %s"
258 " to the science Psf dimensions %s because: %s",
259 targetPsfModel.__class__.__name__, dimenR, dimenS, e)
260 dimenR = dimenS
262 # Make the target kernel image, at location of science subimage.
263 targetMI = self._makePsfMaskedImage(targetPsfModel, center, dimensions=dimenR)
265 # Make the kernel image we are going to convolve.
266 scienceMI = self._makePsfMaskedImage(sciencePsfModel, center, dimensions=dimenR)
268 # The image to convolve is the science image, to the target Psf.
269 kc = diffimLib.makeKernelCandidate(center.getX(), center.getY(), scienceMI, targetMI, self.ps)
270 kernelCellSet.insertCandidate(kc)
272 return pipeBase.Struct(kernelCellSet=kernelCellSet,
273 targetPsfModel=targetPsfModel,
274 )
276 def _solve(self, kernelCellSet, basisList):
277 """Solve for the PSF matching kernel
279 Parameters
280 ----------
281 kernelCellSet : `~lsst.afw.math.SpatialCellSet`
282 A SpatialCellSet to use in determining the matching kernel
283 (typically as provided by _buildCellSet).
284 basisList : `list` [`~lsst.afw.math.kernel.FixedKernel`]
285 A sequence of Kernels to be used in the decomposition of the kernel
286 (typically as provided by makeKernelBasisList).
288 Returns
289 -------
290 spatialSolution : `~lsst.ip.diffim.KernelSolution`
291 Solution of convolution kernels.
292 psfMatchingKernel : `~lsst.afw.math.LinearCombinationKernel`
293 Spatially varying Psf-matching kernel.
294 backgroundModel : `~lsst.afw.math.Function2D`
295 Spatially varying background-matching function.
297 Raises
298 ------
299 RuntimeError
300 Raised if unable to determine PSF matching kernel.
301 """
302 # Visitor for the single kernel fit.
303 if self.useRegularization:
304 singlekv = diffimLib.BuildSingleKernelVisitorF(basisList, self.ps, self.hMat)
305 else:
306 singlekv = diffimLib.BuildSingleKernelVisitorF(basisList, self.ps)
308 # Visitor for the kernel sum rejection.
309 ksv = diffimLib.KernelSumVisitorF(self.ps)
311 try:
312 # Make sure there are no uninitialized candidates as
313 # active occupants of Cell.
314 kernelCellSet.visitCandidates(singlekv, 1)
316 # Reject outliers in kernel sum.
317 ksv.resetKernelSum()
318 ksv.setMode(diffimLib.KernelSumVisitorF.AGGREGATE)
319 kernelCellSet.visitCandidates(ksv, 1)
320 ksv.processKsumDistribution()
321 ksv.setMode(diffimLib.KernelSumVisitorF.REJECT)
322 kernelCellSet.visitCandidates(ksv, 1)
324 regionBBox = kernelCellSet.getBBox()
325 spatialkv = diffimLib.BuildSpatialKernelVisitorF(basisList, regionBBox, self.ps)
326 kernelCellSet.visitCandidates(spatialkv, 1)
327 spatialkv.solveLinearEquation()
328 spatialKernel, spatialBackground = spatialkv.getSolutionPair()
329 spatialSolution = spatialkv.getKernelSolution()
330 except Exception as e:
331 self.log.error("ERROR: Unable to calculate psf matching kernel")
332 getTraceLogger(self.log.getChild("_solve"), 1).debug("%s", e)
333 raise e
335 self._diagnostic(kernelCellSet, spatialSolution, spatialKernel, spatialBackground)
337 return spatialSolution, spatialKernel, spatialBackground
339 def _makePsfMaskedImage(self, psfModel, center, dimensions=None) -> afwImage.MaskedImage:
340 """Make a MaskedImage of a PSF model of specified dimensions.
342 Parameters
343 ----------
344 psfModel : `~lsst.afw.detection.Psf`
345 The PSF model whose image is requested.
346 center : `~lsst.geom.Point2D`
347 The location at which the PSF image is requested.
348 dimensions : `~lsst.geom.Box2I`, optional
349 The bounding box of the PSF image.
351 Returns
352 -------
353 kernelIm : `~lsst.afw.image.MaskedImage`
354 Image of the PSF.
355 """
356 rawKernel = psfModel.computeKernelImage(center).convertF()
357 if dimensions is None:
358 dimensions = rawKernel.getDimensions()
359 if rawKernel.getDimensions() == dimensions:
360 kernelIm = rawKernel
361 else:
362 # Make an image of proper size.
363 kernelIm = afwImage.ImageF(dimensions)
364 bboxToPlace = geom.Box2I(geom.Point2I((dimensions.getX() - rawKernel.getWidth())//2,
365 (dimensions.getY() - rawKernel.getHeight())//2),
366 rawKernel.getDimensions())
367 kernelIm.assign(rawKernel, bboxToPlace)
369 kernelMask = afwImage.Mask(dimensions, 0x0)
370 kernelVar = afwImage.ImageF(dimensions, 1.0)
371 return afwImage.MaskedImageF(kernelIm, kernelMask, kernelVar)