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

126 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-17 11:21 +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/>. 

22 

23from __future__ import annotations 

24 

25__all__ = ("GaussianizePsfTask", "GaussianizePsfConfig") 

26 

27import numpy as np 

28import scipy.signal 

29 

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 

40 

41 

42class GaussianizePsfConfig(ModelPsfMatchConfig): 

43 """Configuration for model-to-model Psf matching.""" 

44 

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 ) 

56 

57 

58class GaussianizePsfTask(ModelPsfMatchTask): 

59 """Task to make the PSF at a source Gaussian. 

60 

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. 

69 

70 See also 

71 -------- 

72 ModelPsfMatchTask 

73 """ 

74 ConfigClass = GaussianizePsfConfig 

75 

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) 

80 

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. 

85 

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`. 

103 

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() 

117 

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) 

124 

125 basisList = makeKernelBasisList(self.kConfig, fwhmScience, fwhmModel, 

126 basisSigmaGauss=basisSigmaGauss, 

127 metadata=self.metadata) 

128 spatialSolution, psfMatchingKernel, backgroundModel = self._solve(kernelCellSet, basisList) 

129 

130 kParameters = np.array(psfMatchingKernel.getKernelParameters()) 

131 kParameters[0] = kernelSum 

132 psfMatchingKernel.setKernelParameters(kParameters) 

133 

134 bbox = exposure.getBBox() 

135 psfMatchedExposure = afwImage.ExposureD(bbox, exposure.getWcs()) 

136 psfMatchedExposure.setPsf(targetPsfModel) 

137 

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) 

142 

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) 

157 

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") 

162 

163 psfMatchedImage = afwImage.ImageD(psfMatchedImageArray) 

164 psfMatchedExposure.setImage(psfMatchedImage) 

165 

166 return pipeBase.Struct(psfMatchedExposure=psfMatchedExposure, 

167 psfMatchingKernel=psfMatchingKernel, 

168 kernelCellSet=kernelCellSet, 

169 metadata=self.metadata, 

170 ) 

171 

172 def _buildCellSet(self, exposure, center, targetPsfModel) -> pipeBase.Struct: 

173 """Build a SpatialCellSet with one cell for use with the solve method. 

174 

175 This builds a SpatialCellSet containing a single cell and a single 

176 candidate, centered at the location of the source. 

177 

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. 

186 

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 

193 

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 

205 

206 scienceBBox = exposure.getBBox() 

207 scienceBBox.grow(geom.Extent2I(sizeCellX, sizeCellY)) 

208 sciencePsfModel = exposure.getPsf() 

209 dimenR = targetPsfModel.getDimensions() 

210 

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) 

215 

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) 

221 

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 

230 

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 

235 

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) 

250 

251 dimenS = geom.Extent2I(psfSize, psfSize) 

252 

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 

261 

262 # Make the target kernel image, at location of science subimage. 

263 targetMI = self._makePsfMaskedImage(targetPsfModel, center, dimensions=dimenR) 

264 

265 # Make the kernel image we are going to convolve. 

266 scienceMI = self._makePsfMaskedImage(sciencePsfModel, center, dimensions=dimenR) 

267 

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) 

271 

272 return pipeBase.Struct(kernelCellSet=kernelCellSet, 

273 targetPsfModel=targetPsfModel, 

274 ) 

275 

276 def _solve(self, kernelCellSet, basisList): 

277 """Solve for the PSF matching kernel 

278 

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). 

287 

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. 

296 

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) 

307 

308 # Visitor for the kernel sum rejection. 

309 ksv = diffimLib.KernelSumVisitorF(self.ps) 

310 

311 try: 

312 # Make sure there are no uninitialized candidates as 

313 # active occupants of Cell. 

314 kernelCellSet.visitCandidates(singlekv, 1) 

315 

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) 

323 

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 

334 

335 self._diagnostic(kernelCellSet, spatialSolution, spatialKernel, spatialBackground) 

336 

337 return spatialSolution, spatialKernel, spatialBackground 

338 

339 def _makePsfMaskedImage(self, psfModel, center, dimensions=None) -> afwImage.MaskedImage: 

340 """Make a MaskedImage of a PSF model of specified dimensions. 

341 

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. 

350 

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) 

368 

369 kernelMask = afwImage.Mask(dimensions, 0x0) 

370 kernelVar = afwImage.ImageF(dimensions, 1.0) 

371 return afwImage.MaskedImageF(kernelIm, kernelMask, kernelVar)