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

Shortcuts 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

152 statements  

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.log as log 

30import lsst.pex.config as pexConfig 

31import lsst.pipe.base as pipeBase 

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 `lsst.pipe.base.cmdLineTask.CmdLineTask` command line task interface supports a 

126 flag -d/--debug to import 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.log.utils as logUtils 

156 logUtils.traceSetAt("ip.diffim", 4) 

157 

158 Examples 

159 -------- 

160 A complete example of using ModelPsfMatchTask 

161 

162 This code is modelPsfMatchTask.py in the examples directory, and can be run as e.g. 

163 

164 .. code-block :: none 

165 

166 examples/modelPsfMatchTask.py 

167 examples/modelPsfMatchTask.py --debug 

168 examples/modelPsfMatchTask.py --debug --template /path/to/templateExp.fits 

169 --science /path/to/scienceExp.fits 

170 

171 Create a subclass of ModelPsfMatchTask that accepts two exposures. 

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

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

174 

175 .. code-block :: none 

176 

177 class MyModelPsfMatchTask(ModelPsfMatchTask): 

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

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

180 def run(self, templateExp, scienceExp): 

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

182 

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

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

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

186 

187 .. code-block :: none 

188 

189 if __name__ == "__main__": 

190 import argparse 

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

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

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

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

195 args = parser.parse_args() 

196 

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

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

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

200 

201 .. code-block :: none 

202 

203 if args.debug: 

204 try: 

205 import debug 

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

207 debug.lsstDebug.frame = 3 

208 except ImportError as e: 

209 print(e, file=sys.stderr) 

210 

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

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

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

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

215 

216 .. code-block :: none 

217 

218 def run(args): 

219 # 

220 # Create the Config and use sum of gaussian basis 

221 # 

222 config = ModelPsfMatchTask.ConfigClass() 

223 config.kernel.active.scaleByFwhm = False 

224 

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

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

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

228 

229 .. code-block :: none 

230 

231 # Run the requested method of the Task 

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

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

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

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

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

237 try: 

238 templateExp = afwImage.ExposureF(args.template) 

239 except Exception as e: 

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

241 try: 

242 scienceExp = afwImage.ExposureF(args.science) 

243 except Exception as e: 

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

245 else: 

246 templateExp, scienceExp = generateFakeData() 

247 config.kernel.active.sizeCellX = 128 

248 config.kernel.active.sizeCellY = 128 

249 

250 .. code-block :: none 

251 

252 if args.debug: 

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

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

255 

256 Create and run the Task: 

257 

258 .. code-block :: none 

259 

260 # Create the Task 

261 psfMatchTask = MyModelPsfMatchTask(config=config) 

262 # Run the Task 

263 result = psfMatchTask.run(templateExp, scienceExp) 

264 

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

266 

267 .. code-block :: none 

268 

269 if args.debug: 

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

271 try: 

272 frame = debug.lsstDebug.frame + 1 

273 except Exception: 

274 frame = 3 

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

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

277 

278 """ 

279 ConfigClass = ModelPsfMatchConfig 

280 

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

282 """Create a ModelPsfMatchTask 

283 

284 Parameters 

285 ---------- 

286 *args 

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

288 **kwargs 

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

290 

291 Notes 

292 ----- 

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

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

295 """ 

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

297 self.kConfig = self.config.kernel.active 

298 

299 @timeMethod 

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

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

302 

303 Parameters 

304 ---------- 

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

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

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

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

309 The Psf model to match to 

310 kernelSum : `float`, optional 

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

312 

313 Returns 

314 ------- 

315 result : `struct` 

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

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

318 Filter as the input Exposure but no Psf. 

319 In theory the Psf should equal referencePsfModel but 

320 the match is likely not exact. 

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

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

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

324 

325 Raises 

326 ------ 

327 RuntimeError 

328 if the Exposure does not contain a Psf model 

329 """ 

330 if not exposure.hasPsf(): 

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

332 

333 maskedImage = exposure.getMaskedImage() 

334 

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

336 result = self._buildCellSet(exposure, referencePsfModel) 

337 kernelCellSet = result.kernelCellSet 

338 referencePsfModel = result.referencePsfModel 

339 fwhmScience = exposure.getPsf().computeShape().getDeterminantRadius()*sigma2fwhm 

340 fwhmModel = referencePsfModel.computeShape().getDeterminantRadius()*sigma2fwhm 

341 

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

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

344 

345 if psfMatchingKernel.isSpatiallyVarying(): 

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

347 sParameters[0][0] = kernelSum 

348 psfMatchingKernel.setSpatialParameters(sParameters) 

349 else: 

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

351 kParameters[0] = kernelSum 

352 psfMatchingKernel.setKernelParameters(kParameters) 

353 

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

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

356 psfMatchedExposure.info.id = exposure.info.id 

357 psfMatchedExposure.setFilterLabel(exposure.getFilterLabel()) 

358 psfMatchedExposure.setPhotoCalib(exposure.getPhotoCalib()) 

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

360 psfMatchedExposure.setPsf(referencePsfModel) 

361 psfMatchedMaskedImage = psfMatchedExposure.getMaskedImage() 

362 

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

364 # when PSF-matching one model to another. 

365 convolutionControl = afwMath.ConvolutionControl() 

366 convolutionControl.setDoNormalize(True) 

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

368 

369 self.log.info("done") 

370 return pipeBase.Struct(psfMatchedExposure=psfMatchedExposure, 

371 psfMatchingKernel=psfMatchingKernel, 

372 kernelCellSet=kernelCellSet, 

373 metadata=self.metadata, 

374 ) 

375 

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

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

378 

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

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

381 return 

382 

383 def _buildCellSet(self, exposure, referencePsfModel): 

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

385 

386 Parameters 

387 ---------- 

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

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

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

391 Psf model to match to 

392 

393 Returns 

394 ------- 

395 result : `struct` 

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

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

398 reference model used to populate the SpatialCellSet 

399 

400 Notes 

401 ----- 

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

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

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

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

406 the dimensions to be constant. 

407 """ 

408 sizeCellX = self.kConfig.sizeCellX 

409 sizeCellY = self.kConfig.sizeCellY 

410 

411 scienceBBox = exposure.getBBox() 

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

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

414 

415 sciencePsfModel = exposure.getPsf() 

416 

417 dimenR = referencePsfModel.getLocalKernel().getDimensions() 

418 psfWidth, psfHeight = dimenR 

419 

420 regionSizeX, regionSizeY = scienceBBox.getDimensions() 

421 scienceX0, scienceY0 = scienceBBox.getMin() 

422 

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

424 

425 nCellX = regionSizeX//sizeCellX 

426 nCellY = regionSizeY//sizeCellY 

427 

428 if nCellX == 0 or nCellY == 0: 

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

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

431 

432 # Survey the PSF dimensions of the Spatial Cell Set 

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

434 widthList = [] 

435 heightList = [] 

436 for row in range(nCellY): 

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

438 for col in range(nCellX): 

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

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

441 widthList.append(widthS) 

442 heightList.append(heightS) 

443 

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

445 

446 if self.config.doAutoPadPsf: 

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

448 paddingPix = max(0, minPsfSize - psfSize) 

449 else: 

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

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

452 self.config.padPsfBy) 

453 paddingPix = self.config.padPsfBy 

454 

455 if paddingPix > 0: 

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

457 psfSize, psfSize, paddingPix + psfSize, paddingPix + psfSize) 

458 psfSize += paddingPix 

459 

460 # Check that PSF is larger than the matching kernel 

461 maxKernelSize = psfSize - 1 

462 if maxKernelSize % 2 == 0: 

463 maxKernelSize -= 1 

464 if self.kConfig.kernelSize > maxKernelSize: 

465 message = """ 

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

467 Please reconfigure by setting one of the following: 

468 1) kernel size to <= %d 

469 2) doAutoPadPsf=True 

470 3) padPsfBy to >= %s 

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

472 maxKernelSize, self.kConfig.kernelSize - maxKernelSize) 

473 raise ValueError(message) 

474 

475 dimenS = geom.Extent2I(psfSize, psfSize) 

476 

477 if (dimenR != dimenS): 

478 try: 

479 referencePsfModel = referencePsfModel.resized(psfSize, psfSize) 

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

481 except Exception as e: 

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

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

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

485 dimenR = dimenS 

486 

487 ps = pexConfig.makePropertySet(self.kConfig) 

488 for row in range(nCellY): 

489 # place at center of cell 

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

491 

492 for col in range(nCellX): 

493 # place at center of cell 

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

495 

496 log.log("TRACE4." + self.log.name, log.DEBUG, 

497 "Creating Psf candidate at %.1f %.1f", posX, posY) 

498 

499 # reference kernel image, at location of science subimage 

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

501 

502 # kernel image we are going to convolve 

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

504 

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

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

507 kernelCellSet.insertCandidate(kc) 

508 

509 import lsstDebug 

510 display = lsstDebug.Info(__name__).display 

511 displaySpatialCells = lsstDebug.Info(__name__).displaySpatialCells 

512 maskTransparency = lsstDebug.Info(__name__).maskTransparency 

513 if not maskTransparency: 

514 maskTransparency = 0 

515 if display: 

516 afwDisplay.setDefaultMaskTransparency(maskTransparency) 

517 if display and displaySpatialCells: 

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

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

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

521 title="Image to be convolved") 

522 lsstDebug.frame += 1 

523 return pipeBase.Struct(kernelCellSet=kernelCellSet, 

524 referencePsfModel=referencePsfModel, 

525 ) 

526 

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

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

529 """ 

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

531 if dimensions is None: 

532 dimensions = rawKernel.getDimensions() 

533 if rawKernel.getDimensions() == dimensions: 

534 kernelIm = rawKernel 

535 else: 

536 # make image of proper size 

537 kernelIm = afwImage.ImageF(dimensions) 

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

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

540 rawKernel.getDimensions()) 

541 kernelIm.assign(rawKernel, bboxToPlace) 

542 

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

544 kernelVar = afwImage.ImageF(dimensions, 1.0) 

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