Coverage for python/lsst/ip/diffim/modelPsfMatch.py: 16%

153 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-01-11 03:35 -0800

1# This file is part of ip_diffim. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://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 GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22import numpy as np 

23 

24from . import diffimLib 

25import lsst.afw.display as afwDisplay 

26import lsst.afw.image as afwImage 

27import lsst.afw.math as afwMath 

28import lsst.geom as geom 

29import lsst.pex.config as pexConfig 

30import lsst.pipe.base as pipeBase 

31from lsst.utils.logging import getTraceLogger 

32from lsst.utils.timer import timeMethod 

33from .makeKernelBasisList import makeKernelBasisList 

34from .psfMatch import PsfMatchTask, PsfMatchConfigAL 

35from . import utils as dituils 

36 

37__all__ = ("ModelPsfMatchTask", "ModelPsfMatchConfig") 

38 

39sigma2fwhm = 2.*np.sqrt(2.*np.log(2.)) 

40 

41 

42def nextOddInteger(x): 

43 nextInt = int(np.ceil(x)) 

44 return nextInt + 1 if nextInt%2 == 0 else nextInt 

45 

46 

47class ModelPsfMatchConfig(pexConfig.Config): 

48 """Configuration for model-to-model Psf matching""" 

49 

50 kernel = pexConfig.ConfigChoiceField( 

51 doc="kernel type", 

52 typemap=dict( 

53 AL=PsfMatchConfigAL, 

54 ), 

55 default="AL", 

56 ) 

57 doAutoPadPsf = pexConfig.Field( 

58 dtype=bool, 

59 doc=("If too small, automatically pad the science Psf? " 

60 "Pad to smallest dimensions appropriate for the matching kernel dimensions, " 

61 "as specified by autoPadPsfTo. If false, pad by the padPsfBy config."), 

62 default=True, 

63 ) 

64 autoPadPsfTo = pexConfig.RangeField( 

65 dtype=float, 

66 doc=("Minimum Science Psf dimensions as a fraction of matching kernel dimensions. " 

67 "If the dimensions of the Psf to be matched are less than the " 

68 "matching kernel dimensions * autoPadPsfTo, pad Science Psf to this size. " 

69 "Ignored if doAutoPadPsf=False."), 

70 default=1.4, 

71 min=1.0, 

72 max=2.0 

73 ) 

74 padPsfBy = pexConfig.Field( 

75 dtype=int, 

76 doc="Pixels (even) to pad Science Psf by before matching. Ignored if doAutoPadPsf=True", 

77 default=0, 

78 ) 

79 

80 def setDefaults(self): 

81 # No sigma clipping 

82 self.kernel.active.singleKernelClipping = False 

83 self.kernel.active.kernelSumClipping = False 

84 self.kernel.active.spatialKernelClipping = False 

85 self.kernel.active.checkConditionNumber = False 

86 

87 # Variance is ill defined 

88 self.kernel.active.constantVarianceWeighting = True 

89 

90 # Do not change specified kernel size 

91 self.kernel.active.scaleByFwhm = False 

92 

93 

94class ModelPsfMatchTask(PsfMatchTask): 

95 """Matching of two model Psfs, and application of the Psf-matching kernel to an input Exposure 

96 

97 Notes 

98 ----- 

99 

100 This Task differs from ImagePsfMatchTask in that it matches two Psf _models_, by realizing 

101 them in an Exposure-sized SpatialCellSet and then inserting each Psf-image pair into KernelCandidates. 

102 Because none of the pairs of sources that are to be matched should be invalid, all sigma clipping is 

103 turned off in ModelPsfMatchConfig. And because there is no tracked _variance_ in the Psf images, the 

104 debugging and logging QA info should be interpreted with caution. 

105 

106 One item of note is that the sizes of Psf models are fixed (e.g. its defined as a 21x21 matrix). When the 

107 Psf-matching kernel is being solved for, the Psf "image" is convolved with each kernel basis function, 

108 leading to a loss of information around the borders. 

109 This pixel loss will be problematic for the numerical 

110 stability of the kernel solution if the size of the convolution kernel 

111 (set by ModelPsfMatchConfig.kernelSize) is much bigger than: psfSize//2. 

112 Thus the sizes of Psf-model matching kernels are typically smaller 

113 than their image-matching counterparts. If the size of the kernel is too small, the convolved stars will 

114 look "boxy"; if the kernel is too large, the kernel solution will be "noisy". This is a trade-off that 

115 needs careful attention for a given dataset. 

116 

117 The primary use case for this Task is in matching an Exposure to a 

118 constant-across-the-sky Psf model for the purposes of image coaddition. 

119 It is important to note that in the code, the "template" Psf is the Psf 

120 that the science image gets matched to. In this sense the order of template and science image are 

121 reversed, compared to ImagePsfMatchTask, which operates on the template image. 

122 

123 Debug variables 

124 

125 The ``pipetask`` command line interface supports a 

126 flag --debug to import @b debug.py from your PYTHONPATH. The relevant contents of debug.py 

127 for this Task include: 

128 

129 .. code-block:: py 

130 

131 import sys 

132 import lsstDebug 

133 def DebugInfo(name): 

134 di = lsstDebug.getInfo(name) 

135 if name == "lsst.ip.diffim.psfMatch": 

136 di.display = True # global 

137 di.maskTransparency = 80 # mask transparency 

138 di.displayCandidates = True # show all the candidates and residuals 

139 di.displayKernelBasis = False # show kernel basis functions 

140 di.displayKernelMosaic = True # show kernel realized across the image 

141 di.plotKernelSpatialModel = False # show coefficients of spatial model 

142 di.showBadCandidates = True # show the bad candidates (red) along with good (green) 

143 elif name == "lsst.ip.diffim.modelPsfMatch": 

144 di.display = True # global 

145 di.maskTransparency = 30 # mask transparency 

146 di.displaySpatialCells = True # show spatial cells before the fit 

147 return di 

148 lsstDebug.Info = DebugInfo 

149 lsstDebug.frame = 1 

150 

151 Note that if you want addional logging info, you may add to your scripts: 

152 

153 .. code-block:: py 

154 

155 import lsst.utils.logging as logUtils 

156 logUtils.trace_set_at("lsst.ip.diffim", 4) 

157 

158 Examples 

159 -------- 

160 A complete example of using ModelPsfMatchTask 

161 

162 Create a subclass of ModelPsfMatchTask that accepts two exposures. 

163 Note that the "template" exposure contains the Psf that will get matched to, 

164 and the "science" exposure is the one that will be convolved: 

165 

166 .. code-block :: none 

167 

168 class MyModelPsfMatchTask(ModelPsfMatchTask): 

169 def __init__(self, *args, **kwargs): 

170 ModelPsfMatchTask.__init__(self, *args, **kwargs) 

171 def run(self, templateExp, scienceExp): 

172 return ModelPsfMatchTask.run(self, scienceExp, templateExp.getPsf()) 

173 

174 And allow the user the freedom to either run the script in default mode, 

175 or point to their own images on disk. Note that these 

176 images must be readable as an lsst.afw.image.Exposure: 

177 

178 .. code-block :: none 

179 

180 if __name__ == "__main__": 

181 import argparse 

182 parser = argparse.ArgumentParser(description="Demonstrate the use of ModelPsfMatchTask") 

183 parser.add_argument("--debug", "-d", action="store_true", help="Load debug.py?", default=False) 

184 parser.add_argument("--template", "-t", help="Template Exposure to use", default=None) 

185 parser.add_argument("--science", "-s", help="Science Exposure to use", default=None) 

186 args = parser.parse_args() 

187 

188 We have enabled some minor display debugging in this script via the –debug option. 

189 However, if you have an lsstDebug debug.py in your PYTHONPATH you will get additional 

190 debugging displays. The following block checks for this script: 

191 

192 .. code-block :: none 

193 

194 if args.debug: 

195 try: 

196 import debug 

197 # Since I am displaying 2 images here, set the starting frame number for the LSST debug LSST 

198 debug.lsstDebug.frame = 3 

199 except ImportError as e: 

200 print(e, file=sys.stderr) 

201 

202 Finally, we call a run method that we define below. 

203 First set up a Config and modify some of the parameters. 

204 In particular we don't want to "grow" the sizes of the kernel or KernelCandidates, 

205 since we are operating with fixed–size images (i.e. the size of the input Psf models). 

206 

207 .. code-block :: none 

208 

209 def run(args): 

210 # 

211 # Create the Config and use sum of gaussian basis 

212 # 

213 config = ModelPsfMatchTask.ConfigClass() 

214 config.kernel.active.scaleByFwhm = False 

215 

216 Make sure the images (if any) that were sent to the script exist on disk and are readable. 

217 If no images are sent, make some fake data up for the sake of this example script 

218 (have a look at the code if you want more details on generateFakeData): 

219 

220 .. code-block :: none 

221 

222 # Run the requested method of the Task 

223 if args.template is not None and args.science is not None: 

224 if not os.path.isfile(args.template): 

225 raise FileNotFoundError("Template image %s does not exist" % (args.template)) 

226 if not os.path.isfile(args.science): 

227 raise FileNotFoundError("Science image %s does not exist" % (args.science)) 

228 try: 

229 templateExp = afwImage.ExposureF(args.template) 

230 except Exception as e: 

231 raise RuntimeError("Cannot read template image %s" % (args.template)) 

232 try: 

233 scienceExp = afwImage.ExposureF(args.science) 

234 except Exception as e: 

235 raise RuntimeError("Cannot read science image %s" % (args.science)) 

236 else: 

237 templateExp, scienceExp = generateFakeData() 

238 config.kernel.active.sizeCellX = 128 

239 config.kernel.active.sizeCellY = 128 

240 

241 .. code-block :: none 

242 

243 if args.debug: 

244 afwDisplay.Display(frame=1).mtv(templateExp, title="Example script: Input Template") 

245 afwDisplay.Display(frame=2).mtv(scienceExp, title="Example script: Input Science Image") 

246 

247 Create and run the Task: 

248 

249 .. code-block :: none 

250 

251 # Create the Task 

252 psfMatchTask = MyModelPsfMatchTask(config=config) 

253 # Run the Task 

254 result = psfMatchTask.run(templateExp, scienceExp) 

255 

256 And finally provide optional debugging display of the Psf-matched (via the Psf models) science image: 

257 

258 .. code-block :: none 

259 

260 if args.debug: 

261 # See if the LSST debug has incremented the frame number; if not start with frame 3 

262 try: 

263 frame = debug.lsstDebug.frame + 1 

264 except Exception: 

265 frame = 3 

266 afwDisplay.Display(frame=frame).mtv(result.psfMatchedExposure, 

267 title="Example script: Matched Science Image") 

268 

269 """ 

270 ConfigClass = ModelPsfMatchConfig 

271 

272 def __init__(self, *args, **kwargs): 

273 """Create a ModelPsfMatchTask 

274 

275 Parameters 

276 ---------- 

277 *args 

278 arguments to be passed to lsst.ip.diffim.PsfMatchTask.__init__ 

279 **kwargs 

280 keyword arguments to be passed to lsst.ip.diffim.PsfMatchTask.__init__ 

281 

282 Notes 

283 ----- 

284 Upon initialization, the kernel configuration is defined by self.config.kernel.active. This Task 

285 does have a run() method, which is the default way to call the Task. 

286 """ 

287 PsfMatchTask.__init__(self, *args, **kwargs) 

288 self.kConfig = self.config.kernel.active 

289 

290 @timeMethod 

291 def run(self, exposure, referencePsfModel, kernelSum=1.0): 

292 """Psf-match an exposure to a model Psf 

293 

294 Parameters 

295 ---------- 

296 exposure : `lsst.afw.image.Exposure` 

297 Exposure to Psf-match to the reference Psf model; 

298 it must return a valid PSF model via exposure.getPsf() 

299 referencePsfModel : `lsst.afw.detection.Psf` 

300 The Psf model to match to 

301 kernelSum : `float`, optional 

302 A multipicative factor to apply to the kernel sum (default=1.0) 

303 

304 Returns 

305 ------- 

306 result : `struct` 

307 - ``psfMatchedExposure`` : the Psf-matched Exposure. 

308 This has the same parent bbox, Wcs, PhotoCalib and 

309 Filter as the input Exposure but no Psf. 

310 In theory the Psf should equal referencePsfModel but 

311 the match is likely not exact. 

312 - ``psfMatchingKernel`` : the spatially varying Psf-matching kernel 

313 - ``kernelCellSet`` : SpatialCellSet used to solve for the Psf-matching kernel 

314 - ``referencePsfModel`` : Validated and/or modified reference model used 

315 

316 Raises 

317 ------ 

318 RuntimeError 

319 if the Exposure does not contain a Psf model 

320 """ 

321 if not exposure.hasPsf(): 

322 raise RuntimeError("exposure does not contain a Psf model") 

323 

324 maskedImage = exposure.getMaskedImage() 

325 

326 self.log.info("compute Psf-matching kernel") 

327 result = self._buildCellSet(exposure, referencePsfModel) 

328 kernelCellSet = result.kernelCellSet 

329 referencePsfModel = result.referencePsfModel 

330 # TODO: This should be evaluated at (or close to) the center of the 

331 # exposure's bounding box in DM-32756. 

332 sciAvgPos = exposure.getPsf().getAveragePosition() 

333 modelAvgPos = referencePsfModel.getAveragePosition() 

334 fwhmScience = exposure.getPsf().computeShape(sciAvgPos).getDeterminantRadius()*sigma2fwhm 

335 fwhmModel = referencePsfModel.computeShape(modelAvgPos).getDeterminantRadius()*sigma2fwhm 

336 

337 basisList = makeKernelBasisList(self.kConfig, fwhmScience, fwhmModel, metadata=self.metadata) 

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

339 

340 if psfMatchingKernel.isSpatiallyVarying(): 

341 sParameters = np.array(psfMatchingKernel.getSpatialParameters()) 

342 sParameters[0][0] = kernelSum 

343 psfMatchingKernel.setSpatialParameters(sParameters) 

344 else: 

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

346 kParameters[0] = kernelSum 

347 psfMatchingKernel.setKernelParameters(kParameters) 

348 

349 self.log.info("Psf-match science exposure to reference") 

350 psfMatchedExposure = afwImage.ExposureF(exposure.getBBox(), exposure.getWcs()) 

351 psfMatchedExposure.info.id = exposure.info.id 

352 psfMatchedExposure.setFilter(exposure.getFilter()) 

353 psfMatchedExposure.setPhotoCalib(exposure.getPhotoCalib()) 

354 psfMatchedExposure.getInfo().setVisitInfo(exposure.getInfo().getVisitInfo()) 

355 psfMatchedExposure.setPsf(referencePsfModel) 

356 psfMatchedMaskedImage = psfMatchedExposure.getMaskedImage() 

357 

358 # Normalize the psf-matching kernel while convolving since its magnitude is meaningless 

359 # when PSF-matching one model to another. 

360 convolutionControl = afwMath.ConvolutionControl() 

361 convolutionControl.setDoNormalize(True) 

362 afwMath.convolve(psfMatchedMaskedImage, maskedImage, psfMatchingKernel, convolutionControl) 

363 

364 self.log.info("done") 

365 return pipeBase.Struct(psfMatchedExposure=psfMatchedExposure, 

366 psfMatchingKernel=psfMatchingKernel, 

367 kernelCellSet=kernelCellSet, 

368 metadata=self.metadata, 

369 ) 

370 

371 def _diagnostic(self, kernelCellSet, spatialSolution, spatialKernel, spatialBg): 

372 """Print diagnostic information on spatial kernel and background fit 

373 

374 The debugging diagnostics are not really useful here, since the images we are matching have 

375 no variance. Thus override the _diagnostic method to generate no logging information""" 

376 return 

377 

378 def _buildCellSet(self, exposure, referencePsfModel): 

379 """Build a SpatialCellSet for use with the solve method 

380 

381 Parameters 

382 ---------- 

383 exposure : `lsst.afw.image.Exposure` 

384 The science exposure that will be convolved; must contain a Psf 

385 referencePsfModel : `lsst.afw.detection.Psf` 

386 Psf model to match to 

387 

388 Returns 

389 ------- 

390 result : `struct` 

391 - ``kernelCellSet`` : a SpatialCellSet to be used by self._solve 

392 - ``referencePsfModel`` : Validated and/or modified 

393 reference model used to populate the SpatialCellSet 

394 

395 Notes 

396 ----- 

397 If the reference Psf model and science Psf model have different dimensions, 

398 adjust the referencePsfModel (the model to which the exposure PSF will be matched) 

399 to match that of the science Psf. If the science Psf dimensions vary across the image, 

400 as is common with a WarpedPsf, either pad or clip (depending on config.padPsf) 

401 the dimensions to be constant. 

402 """ 

403 sizeCellX = self.kConfig.sizeCellX 

404 sizeCellY = self.kConfig.sizeCellY 

405 

406 scienceBBox = exposure.getBBox() 

407 # Extend for proper spatial matching kernel all the way to edge, especially for narrow strips 

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

409 

410 sciencePsfModel = exposure.getPsf() 

411 

412 dimenR = referencePsfModel.getLocalKernel(scienceBBox.getCenter()).getDimensions() 

413 

414 regionSizeX, regionSizeY = scienceBBox.getDimensions() 

415 scienceX0, scienceY0 = scienceBBox.getMin() 

416 

417 kernelCellSet = afwMath.SpatialCellSet(geom.Box2I(scienceBBox), sizeCellX, sizeCellY) 

418 

419 nCellX = regionSizeX//sizeCellX 

420 nCellY = regionSizeY//sizeCellY 

421 

422 if nCellX == 0 or nCellY == 0: 

423 raise ValueError("Exposure dimensions=%s and sizeCell=(%s, %s). Insufficient area to match" % 

424 (scienceBBox.getDimensions(), sizeCellX, sizeCellY)) 

425 

426 # Survey the PSF dimensions of the Spatial Cell Set 

427 # to identify the minimum enclosed or maximum bounding square BBox. 

428 widthList = [] 

429 heightList = [] 

430 for row in range(nCellY): 

431 posY = sizeCellY*row + sizeCellY//2 + scienceY0 

432 for col in range(nCellX): 

433 posX = sizeCellX*col + sizeCellX//2 + scienceX0 

434 widthS, heightS = sciencePsfModel.computeBBox(geom.Point2D(posX, posY)).getDimensions() 

435 widthList.append(widthS) 

436 heightList.append(heightS) 

437 

438 psfSize = max(max(heightList), max(widthList)) 

439 

440 if self.config.doAutoPadPsf: 

441 minPsfSize = nextOddInteger(self.kConfig.kernelSize*self.config.autoPadPsfTo) 

442 paddingPix = max(0, minPsfSize - psfSize) 

443 else: 

444 if self.config.padPsfBy % 2 != 0: 

445 raise ValueError("Config padPsfBy (%i pixels) must be even number." % 

446 self.config.padPsfBy) 

447 paddingPix = self.config.padPsfBy 

448 

449 if paddingPix > 0: 

450 self.log.debug("Padding Science PSF from (%d, %d) to (%d, %d) pixels", 

451 psfSize, psfSize, paddingPix + psfSize, paddingPix + psfSize) 

452 psfSize += paddingPix 

453 

454 # Check that PSF is larger than the matching kernel 

455 maxKernelSize = psfSize - 1 

456 if maxKernelSize % 2 == 0: 

457 maxKernelSize -= 1 

458 if self.kConfig.kernelSize > maxKernelSize: 

459 message = """ 

460 Kernel size (%d) too big to match Psfs of size %d. 

461 Please reconfigure by setting one of the following: 

462 1) kernel size to <= %d 

463 2) doAutoPadPsf=True 

464 3) padPsfBy to >= %s 

465 """ % (self.kConfig.kernelSize, psfSize, 

466 maxKernelSize, self.kConfig.kernelSize - maxKernelSize) 

467 raise ValueError(message) 

468 

469 dimenS = geom.Extent2I(psfSize, psfSize) 

470 

471 if (dimenR != dimenS): 

472 try: 

473 referencePsfModel = referencePsfModel.resized(psfSize, psfSize) 

474 self.log.info("Adjusted dimensions of reference PSF model from %s to %s", dimenR, dimenS) 

475 except Exception as e: 

476 self.log.warning("Zero padding or clipping the reference PSF model of type %s and dimensions" 

477 " %s to the science Psf dimensions %s because: %s", 

478 referencePsfModel.__class__.__name__, dimenR, dimenS, e) 

479 dimenR = dimenS 

480 

481 ps = pexConfig.makePropertySet(self.kConfig) 

482 for row in range(nCellY): 

483 # place at center of cell 

484 posY = sizeCellY*row + sizeCellY//2 + scienceY0 

485 

486 for col in range(nCellX): 

487 # place at center of cell 

488 posX = sizeCellX*col + sizeCellX//2 + scienceX0 

489 

490 getTraceLogger(self.log, 4).debug("Creating Psf candidate at %.1f %.1f", posX, posY) 

491 

492 # reference kernel image, at location of science subimage 

493 referenceMI = self._makePsfMaskedImage(referencePsfModel, posX, posY, dimensions=dimenR) 

494 

495 # kernel image we are going to convolve 

496 scienceMI = self._makePsfMaskedImage(sciencePsfModel, posX, posY, dimensions=dimenR) 

497 

498 # The image to convolve is the science image, to the reference Psf. 

499 kc = diffimLib.makeKernelCandidate(posX, posY, scienceMI, referenceMI, ps) 

500 kernelCellSet.insertCandidate(kc) 

501 

502 import lsstDebug 

503 display = lsstDebug.Info(__name__).display 

504 displaySpatialCells = lsstDebug.Info(__name__).displaySpatialCells 

505 maskTransparency = lsstDebug.Info(__name__).maskTransparency 

506 if not maskTransparency: 

507 maskTransparency = 0 

508 if display: 

509 afwDisplay.setDefaultMaskTransparency(maskTransparency) 

510 if display and displaySpatialCells: 

511 dituils.showKernelSpatialCells(exposure.getMaskedImage(), kernelCellSet, 

512 symb="o", ctype=afwDisplay.CYAN, ctypeUnused=afwDisplay.YELLOW, 

513 ctypeBad=afwDisplay.RED, size=4, frame=lsstDebug.frame, 

514 title="Image to be convolved") 

515 lsstDebug.frame += 1 

516 return pipeBase.Struct(kernelCellSet=kernelCellSet, 

517 referencePsfModel=referencePsfModel, 

518 ) 

519 

520 def _makePsfMaskedImage(self, psfModel, posX, posY, dimensions=None): 

521 """Return a MaskedImage of the a PSF Model of specified dimensions 

522 """ 

523 rawKernel = psfModel.computeKernelImage(geom.Point2D(posX, posY)).convertF() 

524 if dimensions is None: 

525 dimensions = rawKernel.getDimensions() 

526 if rawKernel.getDimensions() == dimensions: 

527 kernelIm = rawKernel 

528 else: 

529 # make image of proper size 

530 kernelIm = afwImage.ImageF(dimensions) 

531 bboxToPlace = geom.Box2I(geom.Point2I((dimensions.getX() - rawKernel.getWidth())//2, 

532 (dimensions.getY() - rawKernel.getHeight())//2), 

533 rawKernel.getDimensions()) 

534 kernelIm.assign(rawKernel, bboxToPlace) 

535 

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

537 kernelVar = afwImage.ImageF(dimensions, 1.0) 

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