Hide keyboard shortcuts

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 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 .makeKernelBasisList import makeKernelBasisList 

33from .psfMatch import PsfMatchTask, PsfMatchConfigAL 

34from . import utils as dituils 

35 

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

37 

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

39 

40 

41def nextOddInteger(x): 

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

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

44 

45 

46class ModelPsfMatchConfig(pexConfig.Config): 

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

48 

49 kernel = pexConfig.ConfigChoiceField( 

50 doc="kernel type", 

51 typemap=dict( 

52 AL=PsfMatchConfigAL, 

53 ), 

54 default="AL", 

55 ) 

56 doAutoPadPsf = pexConfig.Field( 

57 dtype=bool, 

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

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

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

61 default=True, 

62 ) 

63 autoPadPsfTo = pexConfig.RangeField( 

64 dtype=float, 

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

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

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

68 "Ignored if doAutoPadPsf=False."), 

69 default=1.4, 

70 min=1.0, 

71 max=2.0 

72 ) 

73 padPsfBy = pexConfig.Field( 

74 dtype=int, 

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

76 default=0, 

77 ) 

78 

79 def setDefaults(self): 

80 # No sigma clipping 

81 self.kernel.active.singleKernelClipping = False 

82 self.kernel.active.kernelSumClipping = False 

83 self.kernel.active.spatialKernelClipping = False 

84 self.kernel.active.checkConditionNumber = False 

85 

86 # Variance is ill defined 

87 self.kernel.active.constantVarianceWeighting = True 

88 

89 # Do not change specified kernel size 

90 self.kernel.active.scaleByFwhm = False 

91 

92 

93class ModelPsfMatchTask(PsfMatchTask): 

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

95 

96 Notes 

97 ----- 

98 

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

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

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

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

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

104 

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

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

107 leading to a loss of information around the borders. 

108 This pixel loss will be problematic for the numerical 

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

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

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

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

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

114 needs careful attention for a given dataset. 

115 

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

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

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

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

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

121 

122 Debug variables 

123 

124 The `lsst.pipe.base.cmdLineTask.CmdLineTask` command line task interface supports a 

125 flag -d/--debug to import debug.py from your PYTHONPATH. The relevant contents of debug.py 

126 for this Task include: 

127 

128 .. code-block:: py 

129 

130 import sys 

131 import lsstDebug 

132 def DebugInfo(name): 

133 di = lsstDebug.getInfo(name) 

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

135 di.display = True # global 

136 di.maskTransparency = 80 # mask transparency 

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

138 di.displayKernelBasis = False # show kernel basis functions 

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

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

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

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

143 di.display = True # global 

144 di.maskTransparency = 30 # mask transparency 

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

146 return di 

147 lsstDebug.Info = DebugInfo 

148 lsstDebug.frame = 1 

149 

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

151 

152 .. code-block:: py 

153 

154 import lsst.log.utils as logUtils 

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

156 

157 Examples 

158 -------- 

159 A complete example of using ModelPsfMatchTask 

160 

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

162 

163 .. code-block :: none 

164 

165 examples/modelPsfMatchTask.py 

166 examples/modelPsfMatchTask.py --debug 

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

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

169 

170 Create a subclass of ModelPsfMatchTask that accepts two exposures. 

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

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

173 

174 .. code-block :: none 

175 

176 class MyModelPsfMatchTask(ModelPsfMatchTask): 

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

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

179 def run(self, templateExp, scienceExp): 

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

181 

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

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

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

185 

186 .. code-block :: none 

187 

188 if __name__ == "__main__": 

189 import argparse 

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

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

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

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

194 args = parser.parse_args() 

195 

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

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

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

199 

200 .. code-block :: none 

201 

202 if args.debug: 

203 try: 

204 import debug 

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

206 debug.lsstDebug.frame = 3 

207 except ImportError as e: 

208 print(e, file=sys.stderr) 

209 

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

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

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

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

214 

215 .. code-block :: none 

216 

217 def run(args): 

218 # 

219 # Create the Config and use sum of gaussian basis 

220 # 

221 config = ModelPsfMatchTask.ConfigClass() 

222 config.kernel.active.scaleByFwhm = False 

223 

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

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

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

227 

228 .. code-block :: none 

229 

230 # Run the requested method of the Task 

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

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

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

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

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

236 try: 

237 templateExp = afwImage.ExposureF(args.template) 

238 except Exception as e: 

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

240 try: 

241 scienceExp = afwImage.ExposureF(args.science) 

242 except Exception as e: 

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

244 else: 

245 templateExp, scienceExp = generateFakeData() 

246 config.kernel.active.sizeCellX = 128 

247 config.kernel.active.sizeCellY = 128 

248 

249 .. code-block :: none 

250 

251 if args.debug: 

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

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

254 

255 Create and run the Task: 

256 

257 .. code-block :: none 

258 

259 # Create the Task 

260 psfMatchTask = MyModelPsfMatchTask(config=config) 

261 # Run the Task 

262 result = psfMatchTask.run(templateExp, scienceExp) 

263 

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

265 

266 .. code-block :: none 

267 

268 if args.debug: 

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

270 try: 

271 frame = debug.lsstDebug.frame + 1 

272 except Exception: 

273 frame = 3 

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

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

276 

277 """ 

278 ConfigClass = ModelPsfMatchConfig 

279 

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

281 """Create a ModelPsfMatchTask 

282 

283 Parameters 

284 ---------- 

285 *args 

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

287 **kwargs 

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

289 

290 Notes 

291 ----- 

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

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

294 """ 

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

296 self.kConfig = self.config.kernel.active 

297 

298 @pipeBase.timeMethod 

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

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

301 

302 Parameters 

303 ---------- 

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

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

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

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

308 The Psf model to match to 

309 kernelSum : `float`, optional 

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

311 

312 Returns 

313 ------- 

314 result : `struct` 

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

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

317 Filter as the input Exposure but no Psf. 

318 In theory the Psf should equal referencePsfModel but 

319 the match is likely not exact. 

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

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

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

323 

324 Raises 

325 ------ 

326 RuntimeError 

327 if the Exposure does not contain a Psf model 

328 """ 

329 if not exposure.hasPsf(): 

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

331 

332 maskedImage = exposure.getMaskedImage() 

333 

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

335 result = self._buildCellSet(exposure, referencePsfModel) 

336 kernelCellSet = result.kernelCellSet 

337 referencePsfModel = result.referencePsfModel 

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

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

340 

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

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

343 

344 if psfMatchingKernel.isSpatiallyVarying(): 

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

346 sParameters[0][0] = kernelSum 

347 psfMatchingKernel.setSpatialParameters(sParameters) 

348 else: 

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

350 kParameters[0] = kernelSum 

351 psfMatchingKernel.setKernelParameters(kParameters) 

352 

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

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

355 psfMatchedExposure.setFilterLabel(exposure.getFilterLabel()) 

356 psfMatchedExposure.setPhotoCalib(exposure.getPhotoCalib()) 

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

358 psfMatchedExposure.setPsf(referencePsfModel) 

359 psfMatchedMaskedImage = psfMatchedExposure.getMaskedImage() 

360 

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

362 # when PSF-matching one model to another. 

363 convolutionControl = afwMath.ConvolutionControl() 

364 convolutionControl.setDoNormalize(True) 

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

366 

367 self.log.info("done") 

368 return pipeBase.Struct(psfMatchedExposure=psfMatchedExposure, 

369 psfMatchingKernel=psfMatchingKernel, 

370 kernelCellSet=kernelCellSet, 

371 metadata=self.metadata, 

372 ) 

373 

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

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

376 

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

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

379 return 

380 

381 def _buildCellSet(self, exposure, referencePsfModel): 

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

383 

384 Parameters 

385 ---------- 

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

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

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

389 Psf model to match to 

390 

391 Returns 

392 ------- 

393 result : `struct` 

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

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

396 reference model used to populate the SpatialCellSet 

397 

398 Notes 

399 ----- 

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

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

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

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

404 the dimensions to be constant. 

405 """ 

406 sizeCellX = self.kConfig.sizeCellX 

407 sizeCellY = self.kConfig.sizeCellY 

408 

409 scienceBBox = exposure.getBBox() 

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

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

412 

413 sciencePsfModel = exposure.getPsf() 

414 

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

416 psfWidth, psfHeight = dimenR 

417 

418 regionSizeX, regionSizeY = scienceBBox.getDimensions() 

419 scienceX0, scienceY0 = scienceBBox.getMin() 

420 

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

422 

423 nCellX = regionSizeX//sizeCellX 

424 nCellY = regionSizeY//sizeCellY 

425 

426 if nCellX == 0 or nCellY == 0: 

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

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

429 

430 # Survey the PSF dimensions of the Spatial Cell Set 

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

432 widthList = [] 

433 heightList = [] 

434 for row in range(nCellY): 

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

436 for col in range(nCellX): 

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

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

439 widthList.append(widthS) 

440 heightList.append(heightS) 

441 

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

443 

444 if self.config.doAutoPadPsf: 

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

446 paddingPix = max(0, minPsfSize - psfSize) 

447 else: 

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

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

450 self.config.padPsfBy) 

451 paddingPix = self.config.padPsfBy 

452 

453 if paddingPix > 0: 

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

455 psfSize, psfSize, paddingPix + psfSize, paddingPix + psfSize) 

456 psfSize += paddingPix 

457 

458 # Check that PSF is larger than the matching kernel 

459 maxKernelSize = psfSize - 1 

460 if maxKernelSize % 2 == 0: 

461 maxKernelSize -= 1 

462 if self.kConfig.kernelSize > maxKernelSize: 

463 message = """ 

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

465 Please reconfigure by setting one of the following: 

466 1) kernel size to <= %d 

467 2) doAutoPadPsf=True 

468 3) padPsfBy to >= %s 

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

470 maxKernelSize, self.kConfig.kernelSize - maxKernelSize) 

471 raise ValueError(message) 

472 

473 dimenS = geom.Extent2I(psfSize, psfSize) 

474 

475 if (dimenR != dimenS): 

476 try: 

477 referencePsfModel = referencePsfModel.resized(psfSize, psfSize) 

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

479 except Exception as e: 

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

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

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

483 dimenR = dimenS 

484 

485 ps = pexConfig.makePropertySet(self.kConfig) 

486 for row in range(nCellY): 

487 # place at center of cell 

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

489 

490 for col in range(nCellX): 

491 # place at center of cell 

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

493 

494 log.log("TRACE4." + self.log.getName(), log.DEBUG, 

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

496 

497 # reference kernel image, at location of science subimage 

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

499 

500 # kernel image we are going to convolve 

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

502 

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

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

505 kernelCellSet.insertCandidate(kc) 

506 

507 import lsstDebug 

508 display = lsstDebug.Info(__name__).display 

509 displaySpatialCells = lsstDebug.Info(__name__).displaySpatialCells 

510 maskTransparency = lsstDebug.Info(__name__).maskTransparency 

511 if not maskTransparency: 

512 maskTransparency = 0 

513 if display: 

514 afwDisplay.setDefaultMaskTransparency(maskTransparency) 

515 if display and displaySpatialCells: 

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

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

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

519 title="Image to be convolved") 

520 lsstDebug.frame += 1 

521 return pipeBase.Struct(kernelCellSet=kernelCellSet, 

522 referencePsfModel=referencePsfModel, 

523 ) 

524 

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

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

527 """ 

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

529 if dimensions is None: 

530 dimensions = rawKernel.getDimensions() 

531 if rawKernel.getDimensions() == dimensions: 

532 kernelIm = rawKernel 

533 else: 

534 # make image of proper size 

535 kernelIm = afwImage.ImageF(dimensions) 

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

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

538 rawKernel.getDimensions()) 

539 kernelIm.assign(rawKernel, bboxToPlace) 

540 

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

542 kernelVar = afwImage.ImageF(dimensions, 1.0) 

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