Coverage for python/lsst/ip/diffim/imagePsfMatch.py: 13%

282 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-01-25 03:38 -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 

24import lsst.daf.base as dafBase 

25import lsst.pex.config as pexConfig 

26import lsst.afw.detection as afwDetect 

27import lsst.afw.image as afwImage 

28import lsst.afw.math as afwMath 

29import lsst.afw.geom as afwGeom 

30import lsst.afw.table as afwTable 

31import lsst.geom as geom 

32import lsst.pipe.base as pipeBase 

33from lsst.meas.algorithms import SourceDetectionTask, SubtractBackgroundTask, WarpedPsf 

34from lsst.meas.base import SingleFrameMeasurementTask 

35from .makeKernelBasisList import makeKernelBasisList 

36from .psfMatch import PsfMatchTask, PsfMatchConfigDF, PsfMatchConfigAL 

37from . import utils as diffimUtils 

38from . import diffimLib 

39from . import diffimTools 

40import lsst.afw.display as afwDisplay 

41from lsst.utils.timer import timeMethod 

42 

43__all__ = ["ImagePsfMatchConfig", "ImagePsfMatchTask", "subtractAlgorithmRegistry"] 

44 

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

46 

47 

48class ImagePsfMatchConfig(pexConfig.Config): 

49 """Configuration for image-to-image Psf matching. 

50 """ 

51 kernel = pexConfig.ConfigChoiceField( 

52 doc="kernel type", 

53 typemap=dict( 

54 AL=PsfMatchConfigAL, 

55 DF=PsfMatchConfigDF 

56 ), 

57 default="AL", 

58 ) 

59 selectDetection = pexConfig.ConfigurableField( 

60 target=SourceDetectionTask, 

61 doc="Initial detections used to feed stars to kernel fitting", 

62 ) 

63 selectMeasurement = pexConfig.ConfigurableField( 

64 target=SingleFrameMeasurementTask, 

65 doc="Initial measurements used to feed stars to kernel fitting", 

66 ) 

67 

68 def setDefaults(self): 

69 # High sigma detections only 

70 self.selectDetection.reEstimateBackground = False 

71 self.selectDetection.thresholdValue = 10.0 

72 

73 # Minimal set of measurments for star selection 

74 self.selectMeasurement.algorithms.names.clear() 

75 self.selectMeasurement.algorithms.names = ('base_SdssCentroid', 'base_PsfFlux', 'base_PixelFlags', 

76 'base_SdssShape', 'base_GaussianFlux', 'base_SkyCoord') 

77 self.selectMeasurement.slots.modelFlux = None 

78 self.selectMeasurement.slots.apFlux = None 

79 self.selectMeasurement.slots.calibFlux = None 

80 

81 

82class ImagePsfMatchTask(PsfMatchTask): 

83 """Psf-match two MaskedImages or Exposures using the sources in the images. 

84 

85 Parameters 

86 ---------- 

87 args : 

88 Arguments to be passed to lsst.ip.diffim.PsfMatchTask.__init__ 

89 kwargs : 

90 Keyword arguments to be passed to lsst.ip.diffim.PsfMatchTask.__init__ 

91 

92 Notes 

93 ----- 

94 Upon initialization, the kernel configuration is defined by self.config.kernel.active. 

95 The task creates an lsst.afw.math.Warper from the subConfig self.config.kernel.active.warpingConfig. 

96 A schema for the selection and measurement of candidate lsst.ip.diffim.KernelCandidates is 

97 defined, and used to initize subTasks selectDetection (for candidate detection) and selectMeasurement 

98 (for candidate measurement). 

99 

100 Description 

101 

102 Build a Psf-matching kernel using two input images, either as MaskedImages (in which case they need 

103 to be astrometrically aligned) or Exposures (in which case astrometric alignment will happen by 

104 default but may be turned off). This requires a list of input Sources which may be provided 

105 by the calling Task; if not, the Task will perform a coarse source detection 

106 and selection for this purpose. Sources are vetted for signal-to-noise and masked pixels 

107 (in both the template and science image), and substamps around each acceptable 

108 source are extracted and used to create an instance of KernelCandidate. 

109 Each KernelCandidate is then placed within a lsst.afw.math.SpatialCellSet, which is used by an ensemble of 

110 lsst.afw.math.CandidateVisitor instances to build the Psf-matching kernel. These visitors include, in 

111 the order that they are called: BuildSingleKernelVisitor, KernelSumVisitor, BuildSpatialKernelVisitor, 

112 and AssessSpatialKernelVisitor. 

113 

114 Sigma clipping of KernelCandidates is performed as follows: 

115 

116 - BuildSingleKernelVisitor, using the substamp diffim residuals from the per-source kernel fit, 

117 if PsfMatchConfig.singleKernelClipping is True 

118 - KernelSumVisitor, using the mean and standard deviation of the kernel sum from all candidates, 

119 if PsfMatchConfig.kernelSumClipping is True 

120 - AssessSpatialKernelVisitor, using the substamp diffim ressiduals from the spatial kernel fit, 

121 if PsfMatchConfig.spatialKernelClipping is True 

122 

123 The actual solving for the kernel (and differential background model) happens in 

124 lsst.ip.diffim.PsfMatchTask._solve. This involves a loop over the SpatialCellSet that first builds the 

125 per-candidate matching kernel for the requested number of KernelCandidates per cell 

126 (PsfMatchConfig.nStarPerCell). The quality of this initial per-candidate difference image is examined, 

127 using moments of the pixel residuals in the difference image normalized by the square root of the variance 

128 (i.e. sigma); ideally this should follow a normal (0, 1) distribution, 

129 but the rejection thresholds are set 

130 by the config (PsfMatchConfig.candidateResidualMeanMax and PsfMatchConfig.candidateResidualStdMax). 

131 All candidates that pass this initial build are then examined en masse to find the 

132 mean/stdev of the kernel sums across all candidates. 

133 Objects that are significantly above or below the mean, 

134 typically due to variability or sources that are saturated in one image but not the other, 

135 are also rejected.This threshold is defined by PsfMatchConfig.maxKsumSigma. 

136 Finally, a spatial model is built using all currently-acceptable candidates, 

137 and the spatial model used to derive a second set of (spatial) residuals 

138 which are again used to reject bad candidates, using the same thresholds as above. 

139 

140 Invoking the Task 

141 

142 There is no run() method for this Task. Instead there are 4 methods that 

143 may be used to invoke the Psf-matching. These are 

144 `~lsst.ip.diffim.imagePsfMatch.ImagePsfMatchTask.matchMaskedImages`, 

145 `~lsst.ip.diffim.imagePsfMatch.ImagePsfMatchTask.subtractMaskedImages`, 

146 `~lsst.ip.diffim.imagePsfMatch.ImagePsfMatchTask.matchExposures`, and 

147 `~lsst.ip.diffim.imagePsfMatch.ImagePsfMatchTask.subtractExposures`. 

148 

149 The methods that operate on lsst.afw.image.MaskedImage require that the images already be astrometrically 

150 aligned, and are the same shape. The methods that operate on lsst.afw.image.Exposure allow for the 

151 input images to be misregistered and potentially be different sizes; by default a 

152 lsst.afw.math.LanczosWarpingKernel is used to perform the astrometric alignment. The methods 

153 that "match" images return a Psf-matched image, while the methods that "subtract" images 

154 return a Psf-matched and template subtracted image. 

155 

156 See each method's returned lsst.pipe.base.Struct for more details. 

157 

158 Debug variables 

159 

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

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

162 for this Task include: 

163 

164 .. code-block:: py 

165 

166 import sys 

167 import lsstDebug 

168 def DebugInfo(name): 

169 di = lsstDebug.getInfo(name) 

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

171 di.display = True # enable debug output 

172 di.maskTransparency = 80 # display mask transparency 

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

174 di.displayKernelBasis = False # show kernel basis functions 

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

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

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

178 elif name == "lsst.ip.diffim.imagePsfMatch": 

179 di.display = True # enable debug output 

180 di.maskTransparency = 30 # display mask transparency 

181 di.displayTemplate = True # show full (remapped) template 

182 di.displaySciIm = True # show science image to match to 

183 di.displaySpatialCells = True # show spatial cells 

184 di.displayDiffIm = True # show difference image 

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

186 elif name == "lsst.ip.diffim.diaCatalogSourceSelector": 

187 di.display = False # enable debug output 

188 di.maskTransparency = 30 # display mask transparency 

189 di.displayExposure = True # show exposure with candidates indicated 

190 di.pauseAtEnd = False # pause when done 

191 return di 

192 lsstDebug.Info = DebugInfo 

193 lsstDebug.frame = 1 

194 

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

196 

197 .. code-block:: py 

198 

199 import lsst.utils.logging as logUtils 

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

201 

202 Examples 

203 -------- 

204 A complete example of using ImagePsfMatchTask 

205 

206 Create a subclass of ImagePsfMatchTask that allows us to either match exposures, or subtract exposures: 

207 

208 .. code-block:: none 

209 

210 class MyImagePsfMatchTask(ImagePsfMatchTask): 

211 

212 def __init__(self, args, kwargs): 

213 ImagePsfMatchTask.__init__(self, args, kwargs) 

214 

215 def run(self, templateExp, scienceExp, mode): 

216 if mode == "matchExposures": 

217 return self.matchExposures(templateExp, scienceExp) 

218 elif mode == "subtractExposures": 

219 return self.subtractExposures(templateExp, scienceExp) 

220 

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

222 or point to their own images on disk. 

223 Note that these images must be readable as an lsst.afw.image.Exposure. 

224 

225 We have enabled some minor display debugging in this script via the --debug option. However, if you 

226 have an lsstDebug debug.py in your PYTHONPATH you will get additional debugging displays. The following 

227 block checks for this script: 

228 

229 .. code-block:: py 

230 

231 if args.debug: 

232 try: 

233 import debug 

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

235 debug.lsstDebug.frame = 3 

236 except ImportError as e: 

237 print(e, file=sys.stderr) 

238 

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

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

241 E.g. use an "Alard-Lupton" sum-of-Gaussian basis, 

242 fit for a differential background, and use low order spatial 

243 variation in the kernel and background: 

244 

245 .. code-block:: py 

246 

247 def run(args): 

248 # 

249 # Create the Config and use sum of gaussian basis 

250 # 

251 config = ImagePsfMatchTask.ConfigClass() 

252 config.kernel.name = "AL" 

253 config.kernel.active.fitForBackground = True 

254 config.kernel.active.spatialKernelOrder = 1 

255 config.kernel.active.spatialBgOrder = 0 

256 

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

258 are sent, make some fake data up for the sake of this example script (have a look at the code if you want 

259 more details on generateFakeImages): 

260 

261 .. code-block:: py 

262 

263 # Run the requested method of the Task 

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

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

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

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

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

269 try: 

270 templateExp = afwImage.ExposureF(args.template) 

271 except Exception as e: 

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

273 try: 

274 scienceExp = afwImage.ExposureF(args.science) 

275 except Exception as e: 

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

277 else: 

278 templateExp, scienceExp = generateFakeImages() 

279 config.kernel.active.sizeCellX = 128 

280 config.kernel.active.sizeCellY = 128 

281 

282 Create and run the Task: 

283 

284 .. code-block:: py 

285 

286 # Create the Task 

287 psfMatchTask = MyImagePsfMatchTask(config=config) 

288 # Run the Task 

289 result = psfMatchTask.run(templateExp, scienceExp, args.mode) 

290 

291 And finally provide some optional debugging displays: 

292 

293 .. code-block:: py 

294 

295 if args.debug: 

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

297 try: 

298 frame = debug.lsstDebug.frame + 1 

299 except Exception: 

300 frame = 3 

301 afwDisplay.Display(frame=frame).mtv(result.matchedExposure, 

302 title="Example script: Matched Template Image") 

303 if "subtractedExposure" in result.getDict(): 

304 afwDisplay.Display(frame=frame + 1).mtv(result.subtractedExposure, 

305 title="Example script: Subtracted Image") 

306 """ 

307 

308 ConfigClass = ImagePsfMatchConfig 

309 

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

311 """Create the ImagePsfMatchTask. 

312 """ 

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

314 self.kConfig = self.config.kernel.active 

315 self._warper = afwMath.Warper.fromConfig(self.kConfig.warpingConfig) 

316 # the background subtraction task uses a config from an unusual location, 

317 # so cannot easily be constructed with makeSubtask 

318 self.background = SubtractBackgroundTask(config=self.kConfig.afwBackgroundConfig, name="background", 

319 parentTask=self) 

320 self.selectSchema = afwTable.SourceTable.makeMinimalSchema() 

321 self.selectAlgMetadata = dafBase.PropertyList() 

322 self.makeSubtask("selectDetection", schema=self.selectSchema) 

323 self.makeSubtask("selectMeasurement", schema=self.selectSchema, algMetadata=self.selectAlgMetadata) 

324 

325 def getFwhmPix(self, psf, position=None): 

326 """Return the FWHM in pixels of a Psf. 

327 """ 

328 if position is None: 

329 position = psf.getAveragePosition() 

330 sigPix = psf.computeShape(position).getDeterminantRadius() 

331 return sigPix*sigma2fwhm 

332 

333 @timeMethod 

334 def matchExposures(self, templateExposure, scienceExposure, 

335 templateFwhmPix=None, scienceFwhmPix=None, 

336 candidateList=None, doWarping=True, convolveTemplate=True): 

337 """Warp and PSF-match an exposure to the reference. 

338 

339 Do the following, in order: 

340 

341 - Warp templateExposure to match scienceExposure, 

342 if doWarping True and their WCSs do not already match 

343 - Determine a PSF matching kernel and differential background model 

344 that matches templateExposure to scienceExposure 

345 - Convolve templateExposure by PSF matching kernel 

346 

347 Parameters 

348 ---------- 

349 templateExposure : `lsst.afw.image.Exposure` 

350 Exposure to warp and PSF-match to the reference masked image 

351 scienceExposure : `lsst.afw.image.Exposure` 

352 Exposure whose WCS and PSF are to be matched to 

353 templateFwhmPix :`float` 

354 FWHM (in pixels) of the Psf in the template image (image to convolve) 

355 scienceFwhmPix : `float` 

356 FWHM (in pixels) of the Psf in the science image 

357 candidateList : `list`, optional 

358 a list of footprints/maskedImages for kernel candidates; 

359 if `None` then source detection is run. 

360 

361 - Currently supported: list of Footprints or measAlg.PsfCandidateF 

362 

363 doWarping : `bool` 

364 what to do if ``templateExposure`` and ``scienceExposure`` WCSs do not match: 

365 

366 - if `True` then warp ``templateExposure`` to match ``scienceExposure`` 

367 - if `False` then raise an Exception 

368 

369 convolveTemplate : `bool` 

370 Whether to convolve the template image or the science image: 

371 

372 - if `True`, ``templateExposure`` is warped if doWarping, 

373 ``templateExposure`` is convolved 

374 - if `False`, ``templateExposure`` is warped if doWarping, 

375 ``scienceExposure`` is convolved 

376 

377 Returns 

378 ------- 

379 results : `lsst.pipe.base.Struct` 

380 An `lsst.pipe.base.Struct` containing these fields: 

381 

382 - ``matchedImage`` : the PSF-matched exposure = 

383 Warped ``templateExposure`` convolved by psfMatchingKernel. This has: 

384 

385 - the same parent bbox, Wcs and PhotoCalib as scienceExposure 

386 - the same filter as templateExposure 

387 - no Psf (because the PSF-matching process does not compute one) 

388 

389 - ``psfMatchingKernel`` : the PSF matching kernel 

390 - ``backgroundModel`` : differential background model 

391 - ``kernelCellSet`` : SpatialCellSet used to solve for the PSF matching kernel 

392 

393 Raises 

394 ------ 

395 RuntimeError 

396 Raised if doWarping is False and ``templateExposure`` and 

397 ``scienceExposure`` WCSs do not match 

398 """ 

399 if not self._validateWcs(templateExposure, scienceExposure): 

400 if doWarping: 

401 self.log.info("Astrometrically registering template to science image") 

402 templatePsf = templateExposure.getPsf() 

403 # Warp PSF before overwriting exposure 

404 xyTransform = afwGeom.makeWcsPairTransform(templateExposure.getWcs(), 

405 scienceExposure.getWcs()) 

406 psfWarped = WarpedPsf(templatePsf, xyTransform) 

407 templateExposure = self._warper.warpExposure(scienceExposure.getWcs(), 

408 templateExposure, 

409 destBBox=scienceExposure.getBBox()) 

410 templateExposure.setPsf(psfWarped) 

411 else: 

412 self.log.error("ERROR: Input images not registered") 

413 raise RuntimeError("Input images not registered") 

414 

415 if templateFwhmPix is None: 

416 if not templateExposure.hasPsf(): 

417 self.log.warning("No estimate of Psf FWHM for template image") 

418 else: 

419 templateFwhmPix = self.getFwhmPix(templateExposure.getPsf()) 

420 self.log.info("templateFwhmPix: %s", templateFwhmPix) 

421 

422 if scienceFwhmPix is None: 

423 if not scienceExposure.hasPsf(): 

424 self.log.warning("No estimate of Psf FWHM for science image") 

425 else: 

426 scienceFwhmPix = self.getFwhmPix(scienceExposure.getPsf()) 

427 self.log.info("scienceFwhmPix: %s", scienceFwhmPix) 

428 

429 if convolveTemplate: 

430 kernelSize = self.makeKernelBasisList(templateFwhmPix, scienceFwhmPix)[0].getWidth() 

431 candidateList = self.makeCandidateList( 

432 templateExposure, scienceExposure, kernelSize, candidateList) 

433 results = self.matchMaskedImages( 

434 templateExposure.getMaskedImage(), scienceExposure.getMaskedImage(), candidateList, 

435 templateFwhmPix=templateFwhmPix, scienceFwhmPix=scienceFwhmPix) 

436 else: 

437 kernelSize = self.makeKernelBasisList(scienceFwhmPix, templateFwhmPix)[0].getWidth() 

438 candidateList = self.makeCandidateList( 

439 templateExposure, scienceExposure, kernelSize, candidateList) 

440 results = self.matchMaskedImages( 

441 scienceExposure.getMaskedImage(), templateExposure.getMaskedImage(), candidateList, 

442 templateFwhmPix=scienceFwhmPix, scienceFwhmPix=templateFwhmPix) 

443 

444 psfMatchedExposure = afwImage.makeExposure(results.matchedImage, scienceExposure.getWcs()) 

445 psfMatchedExposure.setFilter(templateExposure.getFilter()) 

446 psfMatchedExposure.setPhotoCalib(scienceExposure.getPhotoCalib()) 

447 results.warpedExposure = templateExposure 

448 results.matchedExposure = psfMatchedExposure 

449 return results 

450 

451 @timeMethod 

452 def matchMaskedImages(self, templateMaskedImage, scienceMaskedImage, candidateList, 

453 templateFwhmPix=None, scienceFwhmPix=None): 

454 """PSF-match a MaskedImage (templateMaskedImage) to a reference MaskedImage (scienceMaskedImage). 

455 

456 Do the following, in order: 

457 

458 - Determine a PSF matching kernel and differential background model 

459 that matches templateMaskedImage to scienceMaskedImage 

460 - Convolve templateMaskedImage by the PSF matching kernel 

461 

462 Parameters 

463 ---------- 

464 templateMaskedImage : `lsst.afw.image.MaskedImage` 

465 masked image to PSF-match to the reference masked image; 

466 must be warped to match the reference masked image 

467 scienceMaskedImage : `lsst.afw.image.MaskedImage` 

468 maskedImage whose PSF is to be matched to 

469 templateFwhmPix : `float` 

470 FWHM (in pixels) of the Psf in the template image (image to convolve) 

471 scienceFwhmPix : `float` 

472 FWHM (in pixels) of the Psf in the science image 

473 candidateList : `list`, optional 

474 A list of footprints/maskedImages for kernel candidates; 

475 if `None` then source detection is run. 

476 

477 - Currently supported: list of Footprints or measAlg.PsfCandidateF 

478 

479 Returns 

480 ------- 

481 result : `callable` 

482 An `lsst.pipe.base.Struct` containing these fields: 

483 

484 - psfMatchedMaskedImage: the PSF-matched masked image = 

485 ``templateMaskedImage`` convolved with psfMatchingKernel. 

486 This has the same xy0, dimensions and wcs as ``scienceMaskedImage``. 

487 - psfMatchingKernel: the PSF matching kernel 

488 - backgroundModel: differential background model 

489 - kernelCellSet: SpatialCellSet used to solve for the PSF matching kernel 

490 

491 Raises 

492 ------ 

493 RuntimeError 

494 Raised if input images have different dimensions 

495 """ 

496 import lsstDebug 

497 display = lsstDebug.Info(__name__).display 

498 displayTemplate = lsstDebug.Info(__name__).displayTemplate 

499 displaySciIm = lsstDebug.Info(__name__).displaySciIm 

500 displaySpatialCells = lsstDebug.Info(__name__).displaySpatialCells 

501 maskTransparency = lsstDebug.Info(__name__).maskTransparency 

502 if not maskTransparency: 

503 maskTransparency = 0 

504 if display: 

505 afwDisplay.setDefaultMaskTransparency(maskTransparency) 

506 

507 if not candidateList: 

508 raise RuntimeError("Candidate list must be populated by makeCandidateList") 

509 

510 if not self._validateSize(templateMaskedImage, scienceMaskedImage): 

511 self.log.error("ERROR: Input images different size") 

512 raise RuntimeError("Input images different size") 

513 

514 if display and displayTemplate: 

515 disp = afwDisplay.Display(frame=lsstDebug.frame) 

516 disp.mtv(templateMaskedImage, title="Image to convolve") 

517 lsstDebug.frame += 1 

518 

519 if display and displaySciIm: 

520 disp = afwDisplay.Display(frame=lsstDebug.frame) 

521 disp.mtv(scienceMaskedImage, title="Image to not convolve") 

522 lsstDebug.frame += 1 

523 

524 kernelCellSet = self._buildCellSet(templateMaskedImage, 

525 scienceMaskedImage, 

526 candidateList) 

527 

528 if display and displaySpatialCells: 

529 diffimUtils.showKernelSpatialCells(scienceMaskedImage, kernelCellSet, 

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

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

532 title="Image to not convolve") 

533 lsstDebug.frame += 1 

534 

535 if templateFwhmPix and scienceFwhmPix: 

536 self.log.info("Matching Psf FWHM %.2f -> %.2f pix", templateFwhmPix, scienceFwhmPix) 

537 

538 if self.kConfig.useBicForKernelBasis: 

539 tmpKernelCellSet = self._buildCellSet(templateMaskedImage, 

540 scienceMaskedImage, 

541 candidateList) 

542 nbe = diffimTools.NbasisEvaluator(self.kConfig, templateFwhmPix, scienceFwhmPix) 

543 bicDegrees = nbe(tmpKernelCellSet, self.log) 

544 basisList = self.makeKernelBasisList(templateFwhmPix, scienceFwhmPix, 

545 basisDegGauss=bicDegrees[0], metadata=self.metadata) 

546 del tmpKernelCellSet 

547 else: 

548 basisList = self.makeKernelBasisList(templateFwhmPix, scienceFwhmPix, 

549 metadata=self.metadata) 

550 

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

552 

553 psfMatchedMaskedImage = afwImage.MaskedImageF(templateMaskedImage.getBBox()) 

554 convolutionControl = afwMath.ConvolutionControl() 

555 convolutionControl.setDoNormalize(False) 

556 afwMath.convolve(psfMatchedMaskedImage, templateMaskedImage, psfMatchingKernel, convolutionControl) 

557 return pipeBase.Struct( 

558 matchedImage=psfMatchedMaskedImage, 

559 psfMatchingKernel=psfMatchingKernel, 

560 backgroundModel=backgroundModel, 

561 kernelCellSet=kernelCellSet, 

562 ) 

563 

564 @timeMethod 

565 def subtractExposures(self, templateExposure, scienceExposure, 

566 templateFwhmPix=None, scienceFwhmPix=None, 

567 candidateList=None, doWarping=True, convolveTemplate=True): 

568 """Register, Psf-match and subtract two Exposures. 

569 

570 Do the following, in order: 

571 

572 - Warp templateExposure to match scienceExposure, if their WCSs do not already match 

573 - Determine a PSF matching kernel and differential background model 

574 that matches templateExposure to scienceExposure 

575 - PSF-match templateExposure to scienceExposure 

576 - Compute subtracted exposure (see return values for equation). 

577 

578 Parameters 

579 ---------- 

580 templateExposure : `lsst.afw.image.ExposureF` 

581 Exposure to PSF-match to scienceExposure 

582 scienceExposure : `lsst.afw.image.ExposureF` 

583 Reference Exposure 

584 templateFwhmPix : `float` 

585 FWHM (in pixels) of the Psf in the template image (image to convolve) 

586 scienceFwhmPix : `float` 

587 FWHM (in pixels) of the Psf in the science image 

588 candidateList : `list`, optional 

589 A list of footprints/maskedImages for kernel candidates; 

590 if `None` then source detection is run. 

591 

592 - Currently supported: list of Footprints or measAlg.PsfCandidateF 

593 

594 doWarping : `bool` 

595 What to do if ``templateExposure``` and ``scienceExposure`` WCSs do 

596 not match: 

597 

598 - if `True` then warp ``templateExposure`` to match ``scienceExposure`` 

599 - if `False` then raise an Exception 

600 

601 convolveTemplate : `bool` 

602 Convolve the template image or the science image 

603 

604 - if `True`, ``templateExposure`` is warped if doWarping, 

605 ``templateExposure`` is convolved 

606 - if `False`, ``templateExposure`` is warped if doWarping, 

607 ``scienceExposure is`` convolved 

608 

609 Returns 

610 ------- 

611 result : `lsst.pipe.base.Struct` 

612 An `lsst.pipe.base.Struct` containing these fields: 

613 

614 - ``subtractedExposure`` : subtracted Exposure 

615 scienceExposure - (matchedImage + backgroundModel) 

616 - ``matchedImage`` : ``templateExposure`` after warping to match 

617 ``templateExposure`` (if doWarping true), 

618 and convolving with psfMatchingKernel 

619 - ``psfMatchingKernel`` : PSF matching kernel 

620 - ``backgroundModel`` : differential background model 

621 - ``kernelCellSet`` : SpatialCellSet used to determine PSF matching kernel 

622 """ 

623 results = self.matchExposures( 

624 templateExposure=templateExposure, 

625 scienceExposure=scienceExposure, 

626 templateFwhmPix=templateFwhmPix, 

627 scienceFwhmPix=scienceFwhmPix, 

628 candidateList=candidateList, 

629 doWarping=doWarping, 

630 convolveTemplate=convolveTemplate 

631 ) 

632 # Always inherit WCS and photocalib from science exposure 

633 subtractedExposure = afwImage.ExposureF(scienceExposure, deep=True) 

634 # Note, the decorrelation afterburner re-calculates the variance plane 

635 # from the variance planes of the original exposures. 

636 # That recalculation code must be in accordance with the 

637 # photometric level set here in ``subtractedMaskedImage``. 

638 if convolveTemplate: 

639 subtractedMaskedImage = subtractedExposure.maskedImage 

640 subtractedMaskedImage -= results.matchedExposure.maskedImage 

641 subtractedMaskedImage -= results.backgroundModel 

642 else: 

643 subtractedMaskedImage = subtractedExposure.maskedImage 

644 subtractedMaskedImage[:, :] = results.warpedExposure.maskedImage 

645 subtractedMaskedImage -= results.matchedExposure.maskedImage 

646 subtractedMaskedImage -= results.backgroundModel 

647 

648 # Preserve polarity of differences 

649 subtractedMaskedImage *= -1 

650 

651 # Place back on native photometric scale 

652 subtractedMaskedImage /= results.psfMatchingKernel.computeImage( 

653 afwImage.ImageD(results.psfMatchingKernel.getDimensions()), False) 

654 # We matched to the warped template 

655 subtractedExposure.setPsf(results.warpedExposure.getPsf()) 

656 

657 import lsstDebug 

658 display = lsstDebug.Info(__name__).display 

659 displayDiffIm = lsstDebug.Info(__name__).displayDiffIm 

660 maskTransparency = lsstDebug.Info(__name__).maskTransparency 

661 if not maskTransparency: 

662 maskTransparency = 0 

663 if display: 

664 afwDisplay.setDefaultMaskTransparency(maskTransparency) 

665 if display and displayDiffIm: 

666 disp = afwDisplay.Display(frame=lsstDebug.frame) 

667 disp.mtv(templateExposure, title="Template") 

668 lsstDebug.frame += 1 

669 disp = afwDisplay.Display(frame=lsstDebug.frame) 

670 disp.mtv(results.matchedExposure, title="Matched template") 

671 lsstDebug.frame += 1 

672 disp = afwDisplay.Display(frame=lsstDebug.frame) 

673 disp.mtv(scienceExposure, title="Science Image") 

674 lsstDebug.frame += 1 

675 disp = afwDisplay.Display(frame=lsstDebug.frame) 

676 disp.mtv(subtractedExposure, title="Difference Image") 

677 lsstDebug.frame += 1 

678 

679 results.subtractedExposure = subtractedExposure 

680 return results 

681 

682 @timeMethod 

683 def subtractMaskedImages(self, templateMaskedImage, scienceMaskedImage, candidateList, 

684 templateFwhmPix=None, scienceFwhmPix=None): 

685 """Psf-match and subtract two MaskedImages. 

686 

687 Do the following, in order: 

688 

689 - PSF-match templateMaskedImage to scienceMaskedImage 

690 - Determine the differential background 

691 - Return the difference: scienceMaskedImage 

692 ((warped templateMaskedImage convolved with psfMatchingKernel) + backgroundModel) 

693 

694 Parameters 

695 ---------- 

696 templateMaskedImage : `lsst.afw.image.MaskedImage` 

697 MaskedImage to PSF-match to ``scienceMaskedImage`` 

698 scienceMaskedImage : `lsst.afw.image.MaskedImage` 

699 Reference MaskedImage 

700 templateFwhmPix : `float` 

701 FWHM (in pixels) of the Psf in the template image (image to convolve) 

702 scienceFwhmPix : `float` 

703 FWHM (in pixels) of the Psf in the science image 

704 candidateList : `list`, optional 

705 A list of footprints/maskedImages for kernel candidates; 

706 if `None` then source detection is run. 

707 

708 - Currently supported: list of Footprints or measAlg.PsfCandidateF 

709 

710 Returns 

711 ------- 

712 results : `lsst.pipe.base.Struct` 

713 An `lsst.pipe.base.Struct` containing these fields: 

714 

715 - ``subtractedMaskedImage`` : ``scienceMaskedImage`` - (matchedImage + backgroundModel) 

716 - ``matchedImage`` : templateMaskedImage convolved with psfMatchingKernel 

717 - `psfMatchingKernel`` : PSF matching kernel 

718 - ``backgroundModel`` : differential background model 

719 - ``kernelCellSet`` : SpatialCellSet used to determine PSF matching kernel 

720 

721 """ 

722 if not candidateList: 

723 raise RuntimeError("Candidate list must be populated by makeCandidateList") 

724 

725 results = self.matchMaskedImages( 

726 templateMaskedImage=templateMaskedImage, 

727 scienceMaskedImage=scienceMaskedImage, 

728 candidateList=candidateList, 

729 templateFwhmPix=templateFwhmPix, 

730 scienceFwhmPix=scienceFwhmPix, 

731 ) 

732 

733 subtractedMaskedImage = afwImage.MaskedImageF(scienceMaskedImage, True) 

734 subtractedMaskedImage -= results.matchedImage 

735 subtractedMaskedImage -= results.backgroundModel 

736 results.subtractedMaskedImage = subtractedMaskedImage 

737 

738 import lsstDebug 

739 display = lsstDebug.Info(__name__).display 

740 displayDiffIm = lsstDebug.Info(__name__).displayDiffIm 

741 maskTransparency = lsstDebug.Info(__name__).maskTransparency 

742 if not maskTransparency: 

743 maskTransparency = 0 

744 if display: 

745 afwDisplay.setDefaultMaskTransparency(maskTransparency) 

746 if display and displayDiffIm: 

747 disp = afwDisplay.Display(frame=lsstDebug.frame) 

748 disp.mtv(subtractedMaskedImage, title="Subtracted masked image") 

749 lsstDebug.frame += 1 

750 

751 return results 

752 

753 def getSelectSources(self, exposure, sigma=None, doSmooth=True, idFactory=None): 

754 """Get sources to use for Psf-matching. 

755 

756 This method runs detection and measurement on an exposure. 

757 The returned set of sources will be used as candidates for 

758 Psf-matching. 

759 

760 Parameters 

761 ---------- 

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

763 Exposure on which to run detection/measurement 

764 sigma : `float` 

765 Detection threshold 

766 doSmooth : `bool` 

767 Whether or not to smooth the Exposure with Psf before detection 

768 idFactory : 

769 Factory for the generation of Source ids 

770 

771 Returns 

772 ------- 

773 selectSources : 

774 source catalog containing candidates for the Psf-matching 

775 """ 

776 if idFactory: 

777 table = afwTable.SourceTable.make(self.selectSchema, idFactory) 

778 else: 

779 table = afwTable.SourceTable.make(self.selectSchema) 

780 mi = exposure.getMaskedImage() 

781 

782 imArr = mi.getImage().getArray() 

783 maskArr = mi.getMask().getArray() 

784 miArr = np.ma.masked_array(imArr, mask=maskArr) 

785 try: 

786 fitBg = self.background.fitBackground(mi) 

787 bkgd = fitBg.getImageF(self.background.config.algorithm, 

788 self.background.config.undersampleStyle) 

789 except Exception: 

790 self.log.warning("Failed to get background model. Falling back to median background estimation") 

791 bkgd = np.ma.median(miArr) 

792 

793 # Take off background for detection 

794 mi -= bkgd 

795 try: 

796 table.setMetadata(self.selectAlgMetadata) 

797 detRet = self.selectDetection.run( 

798 table=table, 

799 exposure=exposure, 

800 sigma=sigma, 

801 doSmooth=doSmooth 

802 ) 

803 selectSources = detRet.sources 

804 self.selectMeasurement.run(measCat=selectSources, exposure=exposure) 

805 finally: 

806 # Put back on the background in case it is needed down stream 

807 mi += bkgd 

808 del bkgd 

809 return selectSources 

810 

811 def makeCandidateList(self, templateExposure, scienceExposure, kernelSize, candidateList=None): 

812 """Make a list of acceptable KernelCandidates. 

813 

814 Accept or generate a list of candidate sources for 

815 Psf-matching, and examine the Mask planes in both of the 

816 images for indications of bad pixels 

817 

818 Parameters 

819 ---------- 

820 templateExposure : `lsst.afw.image.Exposure` 

821 Exposure that will be convolved 

822 scienceExposure : `lsst.afw.image.Exposure` 

823 Exposure that will be matched-to 

824 kernelSize : `float` 

825 Dimensions of the Psf-matching Kernel, used to grow detection footprints 

826 candidateList : `list`, optional 

827 List of Sources to examine. Elements must be of type afw.table.Source 

828 or a type that wraps a Source and has a getSource() method, such as 

829 meas.algorithms.PsfCandidateF. 

830 

831 Returns 

832 ------- 

833 candidateList : `list` of `dict` 

834 A list of dicts having a "source" and "footprint" 

835 field for the Sources deemed to be appropriate for Psf 

836 matching 

837 """ 

838 if candidateList is None: 

839 candidateList = self.getSelectSources(scienceExposure) 

840 

841 if len(candidateList) < 1: 

842 raise RuntimeError("No candidates in candidateList") 

843 

844 listTypes = set(type(x) for x in candidateList) 

845 if len(listTypes) > 1: 

846 raise RuntimeError("Candidate list contains mixed types: %s" % [t for t in listTypes]) 

847 

848 if not isinstance(candidateList[0], afwTable.SourceRecord): 

849 try: 

850 candidateList[0].getSource() 

851 except Exception as e: 

852 raise RuntimeError(f"Candidate List is of type: {type(candidateList[0])} " 

853 "Can only make candidate list from list of afwTable.SourceRecords, " 

854 f"measAlg.PsfCandidateF or other type with a getSource() method: {e}") 

855 candidateList = [c.getSource() for c in candidateList] 

856 

857 candidateList = diffimTools.sourceToFootprintList(candidateList, 

858 templateExposure, scienceExposure, 

859 kernelSize, 

860 self.kConfig.detectionConfig, 

861 self.log) 

862 if len(candidateList) == 0: 

863 raise RuntimeError("Cannot find any objects suitable for KernelCandidacy") 

864 

865 return candidateList 

866 

867 def makeKernelBasisList(self, targetFwhmPix=None, referenceFwhmPix=None, 

868 basisDegGauss=None, basisSigmaGauss=None, metadata=None): 

869 """Wrapper to set log messages for 

870 `lsst.ip.diffim.makeKernelBasisList`. 

871 

872 Parameters 

873 ---------- 

874 targetFwhmPix : `float`, optional 

875 Passed on to `lsst.ip.diffim.generateAlardLuptonBasisList`. 

876 Not used for delta function basis sets. 

877 referenceFwhmPix : `float`, optional 

878 Passed on to `lsst.ip.diffim.generateAlardLuptonBasisList`. 

879 Not used for delta function basis sets. 

880 basisDegGauss : `list` of `int`, optional 

881 Passed on to `lsst.ip.diffim.generateAlardLuptonBasisList`. 

882 Not used for delta function basis sets. 

883 basisSigmaGauss : `list` of `int`, optional 

884 Passed on to `lsst.ip.diffim.generateAlardLuptonBasisList`. 

885 Not used for delta function basis sets. 

886 metadata : `lsst.daf.base.PropertySet`, optional 

887 Passed on to `lsst.ip.diffim.generateAlardLuptonBasisList`. 

888 Not used for delta function basis sets. 

889 

890 Returns 

891 ------- 

892 basisList: `list` of `lsst.afw.math.kernel.FixedKernel` 

893 List of basis kernels. 

894 """ 

895 basisList = makeKernelBasisList(self.kConfig, 

896 targetFwhmPix=targetFwhmPix, 

897 referenceFwhmPix=referenceFwhmPix, 

898 basisDegGauss=basisDegGauss, 

899 basisSigmaGauss=basisSigmaGauss, 

900 metadata=metadata) 

901 if targetFwhmPix == referenceFwhmPix: 

902 self.log.info("Target and reference psf fwhms are equal, falling back to config values") 

903 elif referenceFwhmPix > targetFwhmPix: 

904 self.log.info("Reference psf fwhm is the greater, normal convolution mode") 

905 else: 

906 self.log.info("Target psf fwhm is the greater, deconvolution mode") 

907 

908 return basisList 

909 

910 def _adaptCellSize(self, candidateList): 

911 """NOT IMPLEMENTED YET. 

912 """ 

913 return self.kConfig.sizeCellX, self.kConfig.sizeCellY 

914 

915 def _buildCellSet(self, templateMaskedImage, scienceMaskedImage, candidateList): 

916 """Build a SpatialCellSet for use with the solve method. 

917 

918 Parameters 

919 ---------- 

920 templateMaskedImage : `lsst.afw.image.MaskedImage` 

921 MaskedImage to PSF-matched to scienceMaskedImage 

922 scienceMaskedImage : `lsst.afw.image.MaskedImage` 

923 Reference MaskedImage 

924 candidateList : `list` 

925 A list of footprints/maskedImages for kernel candidates; 

926 

927 - Currently supported: list of Footprints or measAlg.PsfCandidateF 

928 

929 Returns 

930 ------- 

931 kernelCellSet : `lsst.afw.math.SpatialCellSet` 

932 a SpatialCellSet for use with self._solve 

933 """ 

934 if not candidateList: 

935 raise RuntimeError("Candidate list must be populated by makeCandidateList") 

936 

937 sizeCellX, sizeCellY = self._adaptCellSize(candidateList) 

938 

939 # Object to store the KernelCandidates for spatial modeling 

940 kernelCellSet = afwMath.SpatialCellSet(templateMaskedImage.getBBox(), 

941 sizeCellX, sizeCellY) 

942 

943 ps = pexConfig.makePropertySet(self.kConfig) 

944 # Place candidates within the spatial grid 

945 for cand in candidateList: 

946 if isinstance(cand, afwDetect.Footprint): 

947 bbox = cand.getBBox() 

948 else: 

949 bbox = cand['footprint'].getBBox() 

950 tmi = afwImage.MaskedImageF(templateMaskedImage, bbox) 

951 smi = afwImage.MaskedImageF(scienceMaskedImage, bbox) 

952 

953 if not isinstance(cand, afwDetect.Footprint): 

954 if 'source' in cand: 

955 cand = cand['source'] 

956 xPos = cand.getCentroid()[0] 

957 yPos = cand.getCentroid()[1] 

958 cand = diffimLib.makeKernelCandidate(xPos, yPos, tmi, smi, ps) 

959 

960 self.log.debug("Candidate %d at %f, %f", cand.getId(), cand.getXCenter(), cand.getYCenter()) 

961 kernelCellSet.insertCandidate(cand) 

962 

963 return kernelCellSet 

964 

965 def _validateSize(self, templateMaskedImage, scienceMaskedImage): 

966 """Return True if two image-like objects are the same size. 

967 """ 

968 return templateMaskedImage.getDimensions() == scienceMaskedImage.getDimensions() 

969 

970 def _validateWcs(self, templateExposure, scienceExposure): 

971 """Return True if the WCS of the two Exposures have the same origin and extent. 

972 """ 

973 templateWcs = templateExposure.getWcs() 

974 scienceWcs = scienceExposure.getWcs() 

975 templateBBox = templateExposure.getBBox() 

976 scienceBBox = scienceExposure.getBBox() 

977 

978 # LLC 

979 templateOrigin = templateWcs.pixelToSky(geom.Point2D(templateBBox.getBegin())) 

980 scienceOrigin = scienceWcs.pixelToSky(geom.Point2D(scienceBBox.getBegin())) 

981 

982 # URC 

983 templateLimit = templateWcs.pixelToSky(geom.Point2D(templateBBox.getEnd())) 

984 scienceLimit = scienceWcs.pixelToSky(geom.Point2D(scienceBBox.getEnd())) 

985 

986 self.log.info("Template Wcs : %f,%f -> %f,%f", 

987 templateOrigin[0], templateOrigin[1], 

988 templateLimit[0], templateLimit[1]) 

989 self.log.info("Science Wcs : %f,%f -> %f,%f", 

990 scienceOrigin[0], scienceOrigin[1], 

991 scienceLimit[0], scienceLimit[1]) 

992 

993 templateBBox = geom.Box2D(templateOrigin.getPosition(geom.degrees), 

994 templateLimit.getPosition(geom.degrees)) 

995 scienceBBox = geom.Box2D(scienceOrigin.getPosition(geom.degrees), 

996 scienceLimit.getPosition(geom.degrees)) 

997 if not (templateBBox.overlaps(scienceBBox)): 

998 raise RuntimeError("Input images do not overlap at all") 

999 

1000 if ((templateOrigin != scienceOrigin) 

1001 or (templateLimit != scienceLimit) 

1002 or (templateExposure.getDimensions() != scienceExposure.getDimensions())): 

1003 return False 

1004 return True 

1005 

1006 

1007subtractAlgorithmRegistry = pexConfig.makeRegistry( 

1008 doc="A registry of subtraction algorithms for use as a subtask in imageDifference", 

1009) 

1010 

1011subtractAlgorithmRegistry.register('al', ImagePsfMatchTask)