Coverage for python/lsst/meas/extensions/gaap/_gaussianizePsf.py : 16%

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# 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.log as log
38import lsst.pex.config as pexConfig
39import lsst.pipe.base as pipeBase
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().getDeterminantRadius()*sigma2fwhm
122 fwhmModel = targetPsfModel.computeShape().getDeterminantRadius()*sigma2fwhm
124 basisList = makeKernelBasisList(self.kConfig, fwhmScience, fwhmModel,
125 basisSigmaGauss=basisSigmaGauss,
126 metadata=self.metadata)
127 spatialSolution, psfMatchingKernel, backgroundModel = self._solve(kernelCellSet, basisList)
129 kParameters = np.array(psfMatchingKernel.getKernelParameters())
130 kParameters[0] = kernelSum
131 psfMatchingKernel.setKernelParameters(kParameters)
133 bbox = exposure.getBBox()
134 psfMatchedExposure = afwImage.ExposureD(bbox, exposure.getWcs())
135 psfMatchedExposure.setPsf(targetPsfModel)
137 # Normalize the psf-matching kernel while convolving since its
138 # magnitude is meaningless when PSF-matching one model to another.
139 kernelImage = afwImage.ImageD(psfMatchingKernel.getDimensions())
140 psfMatchingKernel.computeImage(kernelImage, False)
142 if self.config.convolutionMethod == "overlap-add":
143 # The order of image arrays is important if mode="same", since the
144 # returned image array has the same dimensions as the first one.
145 psfMatchedImageArray = scipy.signal.oaconvolve(maskedImage.image.array, kernelImage.array,
146 mode="same")
147 else:
148 convolutionMethod = self.config.convolutionMethod
149 if convolutionMethod == "auto":
150 # Decide if the convolution is faster in real-space or in
151 # Fourier space? scipy.signal.convolve uses this under the
152 # hood, but we call here for logging purposes.
153 convolutionMethod = scipy.signal.choose_conv_method(maskedImage.image.array,
154 kernelImage.array)
155 self.log.debug("Using %s method for convolution.", convolutionMethod)
157 # The order of image arrays is important if mode="same", since the
158 # returned array has the same dimensions as the first argument.
159 psfMatchedImageArray = scipy.signal.convolve(maskedImage.image.array, kernelImage.array,
160 method=convolutionMethod, mode="same")
162 psfMatchedImage = afwImage.ImageD(psfMatchedImageArray)
163 psfMatchedExposure.setImage(psfMatchedImage)
165 return pipeBase.Struct(psfMatchedExposure=psfMatchedExposure,
166 psfMatchingKernel=psfMatchingKernel,
167 kernelCellSet=kernelCellSet,
168 metadata=self.metadata,
169 )
171 def _buildCellSet(self, exposure, center, targetPsfModel) -> pipeBase.Struct:
172 """Build a SpatialCellSet with one cell for use with the solve method.
174 This builds a SpatialCellSet containing a single cell and a single
175 candidate, centered at the location of the source.
177 Parameters
178 ----------
179 exposure : `lsst.afw.image.Exposure`
180 The science exposure that will be convolved; must contain a Psf.
181 center : `lsst.geom.Point2D`
182 The centroid of the source being measured.
183 targetPsfModel : `~lsst.afw.detection.GaussianPsf`
184 Psf model to match to.
186 Returns
187 -------
188 result : `struct`
189 - ``kernelCellSet`` : a SpatialCellSet to be used by self._solve
190 - ``targetPsfModel`` : Validated and/or modified
191 target model used to populate the SpatialCellSet
193 Notes
194 -----
195 If the target Psf model and science Psf model have different
196 dimensions, adjust the targetPsfModel (the model to which the
197 exposure PSF will be matched) to match that of the science Psf.
198 If the science Psf dimensions vary across the image,
199 as is common with a WarpedPsf, either pad or clip
200 (depending on config.padPsf) the dimensions to be constant.
201 """
202 sizeCellX = self.kConfig.sizeCellX
203 sizeCellY = self.kConfig.sizeCellY
205 scienceBBox = exposure.getBBox()
206 scienceBBox.grow(geom.Extent2I(sizeCellX, sizeCellY))
207 sciencePsfModel = exposure.getPsf()
208 dimenR = targetPsfModel.getDimensions()
210 # Have the size of the region much larger than the bbox, so that
211 # the ``kernelCellSet`` has only one instance of `SpatialCell`.
212 regionSize = 10*max(scienceBBox.getWidth(), scienceBBox.getHeight())
213 kernelCellSet = afwMath.SpatialCellSet(geom.Box2I(scienceBBox), regionSize)
215 # Survey the PSF dimensions of the Spatial Cell Set
216 # to identify the minimum enclosed or maximum bounding square BBox.
217 scienceMI = self._makePsfMaskedImage(sciencePsfModel, center)
218 psfWidth, psfHeight = scienceMI.getBBox().getDimensions()
219 psfSize = max(psfWidth, psfHeight)
221 if self.config.doAutoPadPsf:
222 minPsfSize = nextOddInteger(self.kConfig.kernelSize*self.config.autoPadPsfTo)
223 paddingPix = max(0, minPsfSize - psfSize)
224 else:
225 if self.config.padPsfBy % 2 != 0:
226 raise ValueError("Config padPsfBy (%i pixels) must be even number." %
227 self.config.padPsfBy)
228 paddingPix = self.config.padPsfBy
230 if paddingPix > 0:
231 self.log.debug("Padding Science PSF from (%d, %d) to (%d, %d) pixels",
232 psfSize, psfSize, paddingPix + psfSize, paddingPix + psfSize)
233 psfSize += paddingPix
235 # Check that PSF is larger than the matching kernel.
236 maxKernelSize = psfSize - 1
237 if maxKernelSize % 2 == 0:
238 maxKernelSize -= 1
239 if self.kConfig.kernelSize > maxKernelSize:
240 message = """
241 Kernel size (%d) too big to match Psfs of size %d.
242 Please reconfigure by setting one of the following:
243 1) kernel size to <= %d
244 2) doAutoPadPsf=True
245 3) padPsfBy to >= %s
246 """ % (self.kConfig.kernelSize, psfSize,
247 maxKernelSize, self.kConfig.kernelSize - maxKernelSize)
248 raise ValueError(message)
250 dimenS = geom.Extent2I(psfSize, psfSize)
252 if (dimenR != dimenS):
253 try:
254 targetPsfModel = targetPsfModel.resized(psfSize, psfSize)
255 except Exception as e:
256 self.log.warning("Zero padding or clipping the target PSF model of type %s and dimensions %s"
257 " to the science Psf dimensions %s because: %s",
258 targetPsfModel.__class__.__name__, dimenR, dimenS, e)
259 dimenR = dimenS
261 # Make the target kernel image, at location of science subimage.
262 targetMI = self._makePsfMaskedImage(targetPsfModel, center, dimensions=dimenR)
264 # Make the kernel image we are going to convolve.
265 scienceMI = self._makePsfMaskedImage(sciencePsfModel, center, dimensions=dimenR)
267 # The image to convolve is the science image, to the target Psf.
268 kc = diffimLib.makeKernelCandidate(center.getX(), center.getY(), scienceMI, targetMI, self.ps)
269 kernelCellSet.insertCandidate(kc)
271 return pipeBase.Struct(kernelCellSet=kernelCellSet,
272 targetPsfModel=targetPsfModel,
273 )
275 def _solve(self, kernelCellSet, basisList):
276 """Solve for the PSF matching kernel
278 Parameters
279 ----------
280 kernelCellSet : `~lsst.afw.math.SpatialCellSet`
281 A SpatialCellSet to use in determining the matching kernel
282 (typically as provided by _buildCellSet).
283 basisList : `list` [`~lsst.afw.math.kernel.FixedKernel`]
284 A sequence of Kernels to be used in the decomposition of the kernel
285 (typically as provided by makeKernelBasisList).
287 Returns
288 -------
289 spatialSolution : `~lsst.ip.diffim.KernelSolution`
290 Solution of convolution kernels.
291 psfMatchingKernel : `~lsst.afw.math.LinearCombinationKernel`
292 Spatially varying Psf-matching kernel.
293 backgroundModel : `~lsst.afw.math.Function2D`
294 Spatially varying background-matching function.
296 Raises
297 ------
298 RuntimeError
299 Raised if unable to determine PSF matching kernel.
300 """
301 # Visitor for the single kernel fit.
302 if self.useRegularization:
303 singlekv = diffimLib.BuildSingleKernelVisitorF(basisList, self.ps, self.hMat)
304 else:
305 singlekv = diffimLib.BuildSingleKernelVisitorF(basisList, self.ps)
307 # Visitor for the kernel sum rejection.
308 ksv = diffimLib.KernelSumVisitorF(self.ps)
310 try:
311 # Make sure there are no uninitialized candidates as
312 # active occupants of Cell.
313 kernelCellSet.visitCandidates(singlekv, 1)
315 # Reject outliers in kernel sum.
316 ksv.resetKernelSum()
317 ksv.setMode(diffimLib.KernelSumVisitorF.AGGREGATE)
318 kernelCellSet.visitCandidates(ksv, 1)
319 ksv.processKsumDistribution()
320 ksv.setMode(diffimLib.KernelSumVisitorF.REJECT)
321 kernelCellSet.visitCandidates(ksv, 1)
323 regionBBox = kernelCellSet.getBBox()
324 spatialkv = diffimLib.BuildSpatialKernelVisitorF(basisList, regionBBox, self.ps)
325 kernelCellSet.visitCandidates(spatialkv, 1)
326 spatialkv.solveLinearEquation()
327 spatialKernel, spatialBackground = spatialkv.getSolutionPair()
328 spatialSolution = spatialkv.getKernelSolution()
329 except Exception as e:
330 self.log.error("ERROR: Unable to calculate psf matching kernel")
331 log.getLogger(f"TRACE1.{self.log.name}._solve").debug("%s", e)
332 raise e
334 self._diagnostic(kernelCellSet, spatialSolution, spatialKernel, spatialBackground)
336 return spatialSolution, spatialKernel, spatialBackground
338 def _makePsfMaskedImage(self, psfModel, center, dimensions=None) -> afwImage.MaskedImage:
339 """Make a MaskedImage of a PSF model of specified dimensions.
341 Parameters
342 ----------
343 psfModel : `~lsst.afw.detection.Psf`
344 The PSF model whose image is requested.
345 center : `~lsst.geom.Point2D`
346 The location at which the PSF image is requested.
347 dimensions : `~lsst.geom.Box2I`, optional
348 The bounding box of the PSF image.
350 Returns
351 -------
352 kernelIm : `~lsst.afw.image.MaskedImage`
353 Image of the PSF.
354 """
355 rawKernel = psfModel.computeKernelImage(center).convertF()
356 if dimensions is None:
357 dimensions = rawKernel.getDimensions()
358 if rawKernel.getDimensions() == dimensions:
359 kernelIm = rawKernel
360 else:
361 # Make an image of proper size.
362 kernelIm = afwImage.ImageF(dimensions)
363 bboxToPlace = geom.Box2I(geom.Point2I((dimensions.getX() - rawKernel.getWidth())//2,
364 (dimensions.getY() - rawKernel.getHeight())//2),
365 rawKernel.getDimensions())
366 kernelIm.assign(rawKernel, bboxToPlace)
368 kernelMask = afwImage.Mask(dimensions, 0x0)
369 kernelVar = afwImage.ImageF(dimensions, 1.0)
370 return afwImage.MaskedImageF(kernelIm, kernelMask, kernelVar)