Coverage for python/lsst/ip/diffim/subtractImages.py: 23%

333 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-11 03:14 -0700

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 warnings 

23 

24import numpy as np 

25 

26import lsst.afw.image 

27import lsst.afw.math 

28import lsst.geom 

29from lsst.utils.introspection import find_outside_stacklevel 

30from lsst.ip.diffim.utils import evaluateMeanPsfFwhm, getPsfFwhm 

31from lsst.meas.algorithms import ScaleVarianceTask 

32import lsst.pex.config 

33import lsst.pipe.base 

34import lsst.pex.exceptions 

35from lsst.pipe.base import connectionTypes 

36from . import MakeKernelTask, DecorrelateALKernelTask 

37from lsst.utils.timer import timeMethod 

38 

39__all__ = ["AlardLuptonSubtractConfig", "AlardLuptonSubtractTask", 

40 "AlardLuptonPreconvolveSubtractConfig", "AlardLuptonPreconvolveSubtractTask"] 

41 

42_dimensions = ("instrument", "visit", "detector") 

43_defaultTemplates = {"coaddName": "deep", "fakesType": ""} 

44 

45 

46class SubtractInputConnections(lsst.pipe.base.PipelineTaskConnections, 

47 dimensions=_dimensions, 

48 defaultTemplates=_defaultTemplates): 

49 template = connectionTypes.Input( 

50 doc="Input warped template to subtract.", 

51 dimensions=("instrument", "visit", "detector"), 

52 storageClass="ExposureF", 

53 name="{fakesType}{coaddName}Diff_templateExp" 

54 ) 

55 science = connectionTypes.Input( 

56 doc="Input science exposure to subtract from.", 

57 dimensions=("instrument", "visit", "detector"), 

58 storageClass="ExposureF", 

59 name="{fakesType}calexp" 

60 ) 

61 sources = connectionTypes.Input( 

62 doc="Sources measured on the science exposure; " 

63 "used to select sources for making the matching kernel.", 

64 dimensions=("instrument", "visit", "detector"), 

65 storageClass="SourceCatalog", 

66 name="{fakesType}src" 

67 ) 

68 visitSummary = connectionTypes.Input( 

69 doc=("Per-visit catalog with final calibration objects. " 

70 "These catalogs use the detector id for the catalog id, " 

71 "sorted on id for fast lookup."), 

72 dimensions=("instrument", "visit"), 

73 storageClass="ExposureCatalog", 

74 name="finalVisitSummary", 

75 ) 

76 

77 def __init__(self, *, config=None): 

78 super().__init__(config=config) 

79 if not config.doApplyExternalCalibrations: 

80 del self.visitSummary 

81 

82 

83class SubtractImageOutputConnections(lsst.pipe.base.PipelineTaskConnections, 

84 dimensions=_dimensions, 

85 defaultTemplates=_defaultTemplates): 

86 difference = connectionTypes.Output( 

87 doc="Result of subtracting convolved template from science image.", 

88 dimensions=("instrument", "visit", "detector"), 

89 storageClass="ExposureF", 

90 name="{fakesType}{coaddName}Diff_differenceTempExp", 

91 ) 

92 matchedTemplate = connectionTypes.Output( 

93 doc="Warped and PSF-matched template used to create `subtractedExposure`.", 

94 dimensions=("instrument", "visit", "detector"), 

95 storageClass="ExposureF", 

96 name="{fakesType}{coaddName}Diff_matchedExp", 

97 ) 

98 

99 

100class SubtractScoreOutputConnections(lsst.pipe.base.PipelineTaskConnections, 

101 dimensions=_dimensions, 

102 defaultTemplates=_defaultTemplates): 

103 scoreExposure = connectionTypes.Output( 

104 doc="The maximum likelihood image, used for the detection of diaSources.", 

105 dimensions=("instrument", "visit", "detector"), 

106 storageClass="ExposureF", 

107 name="{fakesType}{coaddName}Diff_scoreExp", 

108 ) 

109 

110 

111class AlardLuptonSubtractConnections(SubtractInputConnections, SubtractImageOutputConnections): 

112 pass 

113 

114 

115class AlardLuptonSubtractBaseConfig(lsst.pex.config.Config): 

116 makeKernel = lsst.pex.config.ConfigurableField( 

117 target=MakeKernelTask, 

118 doc="Task to construct a matching kernel for convolution.", 

119 ) 

120 doDecorrelation = lsst.pex.config.Field( 

121 dtype=bool, 

122 default=True, 

123 doc="Perform diffim decorrelation to undo pixel correlation due to A&L " 

124 "kernel convolution? If True, also update the diffim PSF." 

125 ) 

126 decorrelate = lsst.pex.config.ConfigurableField( 

127 target=DecorrelateALKernelTask, 

128 doc="Task to decorrelate the image difference.", 

129 ) 

130 requiredTemplateFraction = lsst.pex.config.Field( 

131 dtype=float, 

132 default=0.1, 

133 doc="Raise NoWorkFound and do not attempt image subtraction if template covers less than this " 

134 " fraction of pixels. Setting to 0 will always attempt image subtraction." 

135 ) 

136 minTemplateFractionForExpectedSuccess = lsst.pex.config.Field( 

137 dtype=float, 

138 default=0.2, 

139 doc="Raise NoWorkFound if PSF-matching fails and template covers less than this fraction of pixels." 

140 " If the fraction of pixels covered by the template is less than this value (and greater than" 

141 " requiredTemplateFraction) this task is attempted but failure is anticipated and tolerated." 

142 ) 

143 doScaleVariance = lsst.pex.config.Field( 

144 dtype=bool, 

145 default=True, 

146 doc="Scale variance of the image difference?" 

147 ) 

148 scaleVariance = lsst.pex.config.ConfigurableField( 

149 target=ScaleVarianceTask, 

150 doc="Subtask to rescale the variance of the template to the statistically expected level." 

151 ) 

152 doSubtractBackground = lsst.pex.config.Field( 

153 doc="Subtract the background fit when solving the kernel?", 

154 dtype=bool, 

155 default=True, 

156 ) 

157 doApplyExternalCalibrations = lsst.pex.config.Field( 

158 doc=( 

159 "Replace science Exposure's calibration objects with those" 

160 " in visitSummary. Ignored if `doApplyFinalizedPsf is True." 

161 ), 

162 dtype=bool, 

163 default=False, 

164 ) 

165 detectionThreshold = lsst.pex.config.Field( 

166 dtype=float, 

167 default=10, 

168 doc="Minimum signal to noise ratio of detected sources " 

169 "to use for calculating the PSF matching kernel." 

170 ) 

171 detectionThresholdMax = lsst.pex.config.Field( 

172 dtype=float, 

173 default=500, 

174 doc="Maximum signal to noise ratio of detected sources " 

175 "to use for calculating the PSF matching kernel." 

176 ) 

177 maxKernelSources = lsst.pex.config.Field( 

178 dtype=int, 

179 default=1000, 

180 doc="Maximum number of sources to use for calculating the PSF matching kernel." 

181 "Set to -1 to disable." 

182 ) 

183 minKernelSources = lsst.pex.config.Field( 

184 dtype=int, 

185 default=3, 

186 doc="Minimum number of sources needed for calculating the PSF matching kernel." 

187 ) 

188 badSourceFlags = lsst.pex.config.ListField( 

189 dtype=str, 

190 doc="Flags that, if set, the associated source should not " 

191 "be used to determine the PSF matching kernel.", 

192 default=("sky_source", "slot_Centroid_flag", 

193 "slot_ApFlux_flag", "slot_PsfFlux_flag", 

194 "base_PixelFlags_flag_interpolated", 

195 "base_PixelFlags_flag_saturated", 

196 "base_PixelFlags_flag_bad", 

197 ), 

198 ) 

199 excludeMaskPlanes = lsst.pex.config.ListField( 

200 dtype=str, 

201 default=("NO_DATA", "BAD", "SAT", "EDGE", "FAKE"), 

202 doc="Mask planes to exclude when selecting sources for PSF matching." 

203 ) 

204 badMaskPlanes = lsst.pex.config.ListField( 

205 dtype=str, 

206 default=("NO_DATA", "BAD", "SAT", "EDGE"), 

207 doc="Mask planes to interpolate over." 

208 ) 

209 preserveTemplateMask = lsst.pex.config.ListField( 

210 dtype=str, 

211 default=("NO_DATA", "BAD",), 

212 doc="Mask planes from the template to propagate to the image difference." 

213 ) 

214 renameTemplateMask = lsst.pex.config.ListField( 

215 dtype=str, 

216 default=("SAT", "INJECTED", "INJECTED_CORE",), 

217 doc="Mask planes from the template to propagate to the image difference" 

218 "with '_TEMPLATE' appended to the name." 

219 ) 

220 allowKernelSourceDetection = lsst.pex.config.Field( 

221 dtype=bool, 

222 default=False, 

223 doc="Re-run source detection for kernel candidates if an error is" 

224 " encountered while calculating the matching kernel." 

225 ) 

226 

227 def setDefaults(self): 

228 self.makeKernel.kernel.name = "AL" 

229 self.makeKernel.kernel.active.fitForBackground = self.doSubtractBackground 

230 self.makeKernel.kernel.active.spatialKernelOrder = 1 

231 self.makeKernel.kernel.active.spatialBgOrder = 2 

232 

233 

234class AlardLuptonSubtractConfig(AlardLuptonSubtractBaseConfig, lsst.pipe.base.PipelineTaskConfig, 

235 pipelineConnections=AlardLuptonSubtractConnections): 

236 mode = lsst.pex.config.ChoiceField( 

237 dtype=str, 

238 default="convolveTemplate", 

239 allowed={"auto": "Choose which image to convolve at runtime.", 

240 "convolveScience": "Only convolve the science image.", 

241 "convolveTemplate": "Only convolve the template image."}, 

242 doc="Choose which image to convolve at runtime, or require that a specific image is convolved." 

243 ) 

244 

245 

246class AlardLuptonSubtractTask(lsst.pipe.base.PipelineTask): 

247 """Compute the image difference of a science and template image using 

248 the Alard & Lupton (1998) algorithm. 

249 """ 

250 ConfigClass = AlardLuptonSubtractConfig 

251 _DefaultName = "alardLuptonSubtract" 

252 

253 def __init__(self, **kwargs): 

254 super().__init__(**kwargs) 

255 self.makeSubtask("decorrelate") 

256 self.makeSubtask("makeKernel") 

257 if self.config.doScaleVariance: 

258 self.makeSubtask("scaleVariance") 

259 

260 self.convolutionControl = lsst.afw.math.ConvolutionControl() 

261 # Normalization is an extra, unnecessary, calculation and will result 

262 # in mis-subtraction of the images if there are calibration errors. 

263 self.convolutionControl.setDoNormalize(False) 

264 self.convolutionControl.setDoCopyEdge(True) 

265 

266 def _applyExternalCalibrations(self, exposure, visitSummary): 

267 """Replace calibrations (psf, and ApCorrMap) on this exposure with 

268 external ones.". 

269 

270 Parameters 

271 ---------- 

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

273 Input exposure to adjust calibrations. 

274 visitSummary : `lsst.afw.table.ExposureCatalog` 

275 Exposure catalog with external calibrations to be applied. Catalog 

276 uses the detector id for the catalog id, sorted on id for fast 

277 lookup. 

278 

279 Returns 

280 ------- 

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

282 Exposure with adjusted calibrations. 

283 """ 

284 detectorId = exposure.info.getDetector().getId() 

285 

286 row = visitSummary.find(detectorId) 

287 if row is None: 

288 self.log.warning("Detector id %s not found in external calibrations catalog; " 

289 "Using original calibrations.", detectorId) 

290 else: 

291 psf = row.getPsf() 

292 apCorrMap = row.getApCorrMap() 

293 if psf is None: 

294 self.log.warning("Detector id %s has None for psf in " 

295 "external calibrations catalog; Using original psf and aperture correction.", 

296 detectorId) 

297 elif apCorrMap is None: 

298 self.log.warning("Detector id %s has None for apCorrMap in " 

299 "external calibrations catalog; Using original psf and aperture correction.", 

300 detectorId) 

301 else: 

302 exposure.setPsf(psf) 

303 exposure.info.setApCorrMap(apCorrMap) 

304 

305 return exposure 

306 

307 @timeMethod 

308 def run(self, template, science, sources, finalizedPsfApCorrCatalog=None, 

309 visitSummary=None): 

310 """PSF match, subtract, and decorrelate two images. 

311 

312 Parameters 

313 ---------- 

314 template : `lsst.afw.image.ExposureF` 

315 Template exposure, warped to match the science exposure. 

316 science : `lsst.afw.image.ExposureF` 

317 Science exposure to subtract from the template. 

318 sources : `lsst.afw.table.SourceCatalog` 

319 Identified sources on the science exposure. This catalog is used to 

320 select sources in order to perform the AL PSF matching on stamp 

321 images around them. 

322 finalizedPsfApCorrCatalog : `lsst.afw.table.ExposureCatalog`, optional 

323 Exposure catalog with finalized psf models and aperture correction 

324 maps to be applied. Catalog uses the detector id for the catalog 

325 id, sorted on id for fast lookup. Deprecated in favor of 

326 ``visitSummary``, and will be removed after v26. 

327 visitSummary : `lsst.afw.table.ExposureCatalog`, optional 

328 Exposure catalog with external calibrations to be applied. Catalog 

329 uses the detector id for the catalog id, sorted on id for fast 

330 lookup. Ignored (for temporary backwards compatibility) if 

331 ``finalizedPsfApCorrCatalog`` is provided. 

332 

333 Returns 

334 ------- 

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

336 ``difference`` : `lsst.afw.image.ExposureF` 

337 Result of subtracting template and science. 

338 ``matchedTemplate`` : `lsst.afw.image.ExposureF` 

339 Warped and PSF-matched template exposure. 

340 ``backgroundModel`` : `lsst.afw.math.Function2D` 

341 Background model that was fit while solving for the 

342 PSF-matching kernel 

343 ``psfMatchingKernel`` : `lsst.afw.math.Kernel` 

344 Kernel used to PSF-match the convolved image. 

345 

346 Raises 

347 ------ 

348 RuntimeError 

349 If an unsupported convolution mode is supplied. 

350 RuntimeError 

351 If there are too few sources to calculate the PSF matching kernel. 

352 lsst.pipe.base.NoWorkFound 

353 Raised if fraction of good pixels, defined as not having NO_DATA 

354 set, is less then the configured requiredTemplateFraction 

355 """ 

356 

357 if finalizedPsfApCorrCatalog is not None: 

358 warnings.warn( 

359 "The finalizedPsfApCorrCatalog argument is deprecated in favor of the visitSummary " 

360 "argument, and will be removed after v26.", 

361 FutureWarning, 

362 stacklevel=find_outside_stacklevel("lsst.ip.diffim"), 

363 ) 

364 visitSummary = finalizedPsfApCorrCatalog 

365 

366 self._prepareInputs(template, science, visitSummary=visitSummary) 

367 

368 # In the event that getPsfFwhm fails, evaluate the PSF on a grid. 

369 fwhmExposureBuffer = self.config.makeKernel.fwhmExposureBuffer 

370 fwhmExposureGrid = self.config.makeKernel.fwhmExposureGrid 

371 

372 # Calling getPsfFwhm on template.psf fails on some rare occasions when 

373 # the template has no input exposures at the average position of the 

374 # stars. So we try getPsfFwhm first on template, and if that fails we 

375 # evaluate the PSF on a grid specified by fwhmExposure* fields. 

376 # To keep consistent definitions for PSF size on the template and 

377 # science images, we use the same method for both. 

378 try: 

379 templatePsfSize = getPsfFwhm(template.psf) 

380 sciencePsfSize = getPsfFwhm(science.psf) 

381 except lsst.pex.exceptions.InvalidParameterError: 

382 self.log.info("Unable to evaluate PSF at the average position. " 

383 "Evaluting PSF on a grid of points." 

384 ) 

385 templatePsfSize = evaluateMeanPsfFwhm(template, 

386 fwhmExposureBuffer=fwhmExposureBuffer, 

387 fwhmExposureGrid=fwhmExposureGrid 

388 ) 

389 sciencePsfSize = evaluateMeanPsfFwhm(science, 

390 fwhmExposureBuffer=fwhmExposureBuffer, 

391 fwhmExposureGrid=fwhmExposureGrid 

392 ) 

393 self.log.info("Science PSF FWHM: %f pixels", sciencePsfSize) 

394 self.log.info("Template PSF FWHM: %f pixels", templatePsfSize) 

395 self.metadata.add("sciencePsfSize", sciencePsfSize) 

396 self.metadata.add("templatePsfSize", templatePsfSize) 

397 

398 if self.config.mode == "auto": 

399 convolveTemplate = _shapeTest(template, 

400 science, 

401 fwhmExposureBuffer=fwhmExposureBuffer, 

402 fwhmExposureGrid=fwhmExposureGrid) 

403 if convolveTemplate: 

404 if sciencePsfSize < templatePsfSize: 

405 self.log.info("Average template PSF size is greater, " 

406 "but science PSF greater in one dimension: convolving template image.") 

407 else: 

408 self.log.info("Science PSF size is greater: convolving template image.") 

409 else: 

410 self.log.info("Template PSF size is greater: convolving science image.") 

411 elif self.config.mode == "convolveTemplate": 

412 self.log.info("`convolveTemplate` is set: convolving template image.") 

413 convolveTemplate = True 

414 elif self.config.mode == "convolveScience": 

415 self.log.info("`convolveScience` is set: convolving science image.") 

416 convolveTemplate = False 

417 else: 

418 raise RuntimeError("Cannot handle AlardLuptonSubtract mode: %s", self.config.mode) 

419 

420 try: 

421 sourceMask = science.mask.clone() 

422 sourceMask.array |= template[science.getBBox()].mask.array 

423 selectSources = self._sourceSelector(sources, sourceMask) 

424 if convolveTemplate: 

425 self.metadata.add("convolvedExposure", "Template") 

426 subtractResults = self.runConvolveTemplate(template, science, selectSources) 

427 else: 

428 self.metadata.add("convolvedExposure", "Science") 

429 subtractResults = self.runConvolveScience(template, science, selectSources) 

430 

431 except (RuntimeError, lsst.pex.exceptions.Exception) as e: 

432 self.log.warning("Failed to match template. Checking coverage") 

433 # Raise NoWorkFound if template fraction is insufficient 

434 checkTemplateIsSufficient(template[science.getBBox()], self.log, 

435 self.config.minTemplateFractionForExpectedSuccess, 

436 exceptionMessage="Template coverage lower than expected to succeed." 

437 f" Failure is tolerable: {e}") 

438 # checkTemplateIsSufficient did not raise NoWorkFound, so raise original exception 

439 raise e 

440 

441 return subtractResults 

442 

443 def runConvolveTemplate(self, template, science, selectSources): 

444 """Convolve the template image with a PSF-matching kernel and subtract 

445 from the science image. 

446 

447 Parameters 

448 ---------- 

449 template : `lsst.afw.image.ExposureF` 

450 Template exposure, warped to match the science exposure. 

451 science : `lsst.afw.image.ExposureF` 

452 Science exposure to subtract from the template. 

453 selectSources : `lsst.afw.table.SourceCatalog` 

454 Identified sources on the science exposure. This catalog is used to 

455 select sources in order to perform the AL PSF matching on stamp 

456 images around them. 

457 

458 Returns 

459 ------- 

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

461 

462 ``difference`` : `lsst.afw.image.ExposureF` 

463 Result of subtracting template and science. 

464 ``matchedTemplate`` : `lsst.afw.image.ExposureF` 

465 Warped and PSF-matched template exposure. 

466 ``backgroundModel`` : `lsst.afw.math.Function2D` 

467 Background model that was fit while solving for the PSF-matching kernel 

468 ``psfMatchingKernel`` : `lsst.afw.math.Kernel` 

469 Kernel used to PSF-match the template to the science image. 

470 """ 

471 try: 

472 kernelSources = self.makeKernel.selectKernelSources(template, science, 

473 candidateList=selectSources, 

474 preconvolved=False) 

475 kernelResult = self.makeKernel.run(template, science, kernelSources, 

476 preconvolved=False) 

477 except Exception as e: 

478 if self.config.allowKernelSourceDetection: 

479 self.log.warning("Error encountered trying to construct the matching kernel" 

480 f" Running source detection and retrying. {e}") 

481 kernelSources = self.makeKernel.selectKernelSources(template, science, 

482 candidateList=None, 

483 preconvolved=False) 

484 kernelResult = self.makeKernel.run(template, science, kernelSources, 

485 preconvolved=False) 

486 else: 

487 raise e 

488 

489 matchedTemplate = self._convolveExposure(template, kernelResult.psfMatchingKernel, 

490 self.convolutionControl, 

491 bbox=science.getBBox(), 

492 psf=science.psf, 

493 photoCalib=science.photoCalib) 

494 

495 difference = _subtractImages(science, matchedTemplate, 

496 backgroundModel=(kernelResult.backgroundModel 

497 if self.config.doSubtractBackground else None)) 

498 correctedExposure = self.finalize(template, science, difference, 

499 kernelResult.psfMatchingKernel, 

500 templateMatched=True) 

501 

502 return lsst.pipe.base.Struct(difference=correctedExposure, 

503 matchedTemplate=matchedTemplate, 

504 matchedScience=science, 

505 backgroundModel=kernelResult.backgroundModel, 

506 psfMatchingKernel=kernelResult.psfMatchingKernel) 

507 

508 def runConvolveScience(self, template, science, selectSources): 

509 """Convolve the science image with a PSF-matching kernel and subtract 

510 the template image. 

511 

512 Parameters 

513 ---------- 

514 template : `lsst.afw.image.ExposureF` 

515 Template exposure, warped to match the science exposure. 

516 science : `lsst.afw.image.ExposureF` 

517 Science exposure to subtract from the template. 

518 selectSources : `lsst.afw.table.SourceCatalog` 

519 Identified sources on the science exposure. This catalog is used to 

520 select sources in order to perform the AL PSF matching on stamp 

521 images around them. 

522 

523 Returns 

524 ------- 

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

526 

527 ``difference`` : `lsst.afw.image.ExposureF` 

528 Result of subtracting template and science. 

529 ``matchedTemplate`` : `lsst.afw.image.ExposureF` 

530 Warped template exposure. Note that in this case, the template 

531 is not PSF-matched to the science image. 

532 ``backgroundModel`` : `lsst.afw.math.Function2D` 

533 Background model that was fit while solving for the PSF-matching kernel 

534 ``psfMatchingKernel`` : `lsst.afw.math.Kernel` 

535 Kernel used to PSF-match the science image to the template. 

536 """ 

537 bbox = science.getBBox() 

538 kernelSources = self.makeKernel.selectKernelSources(science, template, 

539 candidateList=selectSources, 

540 preconvolved=False) 

541 kernelResult = self.makeKernel.run(science, template, kernelSources, 

542 preconvolved=False) 

543 modelParams = kernelResult.backgroundModel.getParameters() 

544 # We must invert the background model if the matching kernel is solved for the science image. 

545 kernelResult.backgroundModel.setParameters([-p for p in modelParams]) 

546 

547 kernelImage = lsst.afw.image.ImageD(kernelResult.psfMatchingKernel.getDimensions()) 

548 norm = kernelResult.psfMatchingKernel.computeImage(kernelImage, doNormalize=False) 

549 

550 matchedScience = self._convolveExposure(science, kernelResult.psfMatchingKernel, 

551 self.convolutionControl, 

552 psf=template.psf) 

553 

554 # Place back on native photometric scale 

555 matchedScience.maskedImage /= norm 

556 matchedTemplate = template.clone()[bbox] 

557 matchedTemplate.maskedImage /= norm 

558 matchedTemplate.setPhotoCalib(science.photoCalib) 

559 

560 difference = _subtractImages(matchedScience, matchedTemplate, 

561 backgroundModel=(kernelResult.backgroundModel 

562 if self.config.doSubtractBackground else None)) 

563 

564 correctedExposure = self.finalize(template, science, difference, 

565 kernelResult.psfMatchingKernel, 

566 templateMatched=False) 

567 

568 return lsst.pipe.base.Struct(difference=correctedExposure, 

569 matchedTemplate=matchedTemplate, 

570 matchedScience=matchedScience, 

571 backgroundModel=kernelResult.backgroundModel, 

572 psfMatchingKernel=kernelResult.psfMatchingKernel,) 

573 

574 def finalize(self, template, science, difference, kernel, 

575 templateMatched=True, 

576 preConvMode=False, 

577 preConvKernel=None, 

578 spatiallyVarying=False): 

579 """Decorrelate the difference image to undo the noise correlations 

580 caused by convolution. 

581 

582 Parameters 

583 ---------- 

584 template : `lsst.afw.image.ExposureF` 

585 Template exposure, warped to match the science exposure. 

586 science : `lsst.afw.image.ExposureF` 

587 Science exposure to subtract from the template. 

588 difference : `lsst.afw.image.ExposureF` 

589 Result of subtracting template and science. 

590 kernel : `lsst.afw.math.Kernel` 

591 An (optionally spatially-varying) PSF matching kernel 

592 templateMatched : `bool`, optional 

593 Was the template PSF-matched to the science image? 

594 preConvMode : `bool`, optional 

595 Was the science image preconvolved with its own PSF 

596 before PSF matching the template? 

597 preConvKernel : `lsst.afw.detection.Psf`, optional 

598 If not `None`, then the science image was pre-convolved with 

599 (the reflection of) this kernel. Must be normalized to sum to 1. 

600 spatiallyVarying : `bool`, optional 

601 Compute the decorrelation kernel spatially varying across the image? 

602 

603 Returns 

604 ------- 

605 correctedExposure : `lsst.afw.image.ExposureF` 

606 The decorrelated image difference. 

607 """ 

608 if self.config.doDecorrelation: 

609 self.log.info("Decorrelating image difference.") 

610 # We have cleared the template mask plane, so copy the mask plane of 

611 # the image difference so that we can calculate correct statistics 

612 # during decorrelation 

613 correctedExposure = self.decorrelate.run(science, template[science.getBBox()], difference, kernel, 

614 templateMatched=templateMatched, 

615 preConvMode=preConvMode, 

616 preConvKernel=preConvKernel, 

617 spatiallyVarying=spatiallyVarying).correctedExposure 

618 else: 

619 self.log.info("NOT decorrelating image difference.") 

620 correctedExposure = difference 

621 return correctedExposure 

622 

623 @staticmethod 

624 def _validateExposures(template, science): 

625 """Check that the WCS of the two Exposures match, and the template bbox 

626 contains the science bbox. 

627 

628 Parameters 

629 ---------- 

630 template : `lsst.afw.image.ExposureF` 

631 Template exposure, warped to match the science exposure. 

632 science : `lsst.afw.image.ExposureF` 

633 Science exposure to subtract from the template. 

634 

635 Raises 

636 ------ 

637 AssertionError 

638 Raised if the WCS of the template is not equal to the science WCS, 

639 or if the science image is not fully contained in the template 

640 bounding box. 

641 """ 

642 assert template.wcs == science.wcs,\ 

643 "Template and science exposure WCS are not identical." 

644 templateBBox = template.getBBox() 

645 scienceBBox = science.getBBox() 

646 

647 assert templateBBox.contains(scienceBBox),\ 

648 "Template bbox does not contain all of the science image." 

649 

650 def _convolveExposure(self, exposure, kernel, convolutionControl, 

651 bbox=None, 

652 psf=None, 

653 photoCalib=None, 

654 interpolateBadMaskPlanes=False, 

655 ): 

656 """Convolve an exposure with the given kernel. 

657 

658 Parameters 

659 ---------- 

660 exposure : `lsst.afw.Exposure` 

661 exposure to convolve. 

662 kernel : `lsst.afw.math.LinearCombinationKernel` 

663 PSF matching kernel computed in the ``makeKernel`` subtask. 

664 convolutionControl : `lsst.afw.math.ConvolutionControl` 

665 Configuration for convolve algorithm. 

666 bbox : `lsst.geom.Box2I`, optional 

667 Bounding box to trim the convolved exposure to. 

668 psf : `lsst.afw.detection.Psf`, optional 

669 Point spread function (PSF) to set for the convolved exposure. 

670 photoCalib : `lsst.afw.image.PhotoCalib`, optional 

671 Photometric calibration of the convolved exposure. 

672 

673 Returns 

674 ------- 

675 convolvedExp : `lsst.afw.Exposure` 

676 The convolved image. 

677 """ 

678 convolvedExposure = exposure.clone() 

679 if psf is not None: 

680 convolvedExposure.setPsf(psf) 

681 if photoCalib is not None: 

682 convolvedExposure.setPhotoCalib(photoCalib) 

683 if interpolateBadMaskPlanes and self.config.badMaskPlanes is not None: 

684 nInterp = _interpolateImage(convolvedExposure.maskedImage, 

685 self.config.badMaskPlanes) 

686 self.metadata.add("nInterpolated", nInterp) 

687 convolvedImage = lsst.afw.image.MaskedImageF(convolvedExposure.getBBox()) 

688 lsst.afw.math.convolve(convolvedImage, convolvedExposure.maskedImage, kernel, convolutionControl) 

689 convolvedExposure.setMaskedImage(convolvedImage) 

690 if bbox is None: 

691 return convolvedExposure 

692 else: 

693 return convolvedExposure[bbox] 

694 

695 def _sourceSelector(self, sources, mask): 

696 """Select sources from a catalog that meet the selection criteria. 

697 

698 Parameters 

699 ---------- 

700 sources : `lsst.afw.table.SourceCatalog` 

701 Input source catalog to select sources from. 

702 mask : `lsst.afw.image.Mask` 

703 The image mask plane to use to reject sources 

704 based on their location on the ccd. 

705 

706 Returns 

707 ------- 

708 selectSources : `lsst.afw.table.SourceCatalog` 

709 The input source catalog, with flagged and low signal-to-noise 

710 sources removed. 

711 

712 Raises 

713 ------ 

714 RuntimeError 

715 If there are too few sources to compute the PSF matching kernel 

716 remaining after source selection. 

717 """ 

718 flags = np.ones(len(sources), dtype=bool) 

719 for flag in self.config.badSourceFlags: 

720 try: 

721 flags *= ~sources[flag] 

722 except Exception as e: 

723 self.log.warning("Could not apply source flag: %s", e) 

724 signalToNoise = sources.getPsfInstFlux()/sources.getPsfInstFluxErr() 

725 sToNFlag = signalToNoise > self.config.detectionThreshold 

726 flags *= sToNFlag 

727 sToNFlagMax = signalToNoise < self.config.detectionThresholdMax 

728 flags *= sToNFlagMax 

729 flags *= self._checkMask(mask, sources, self.config.excludeMaskPlanes) 

730 selectSources = sources[flags].copy(deep=True) 

731 if (len(selectSources) > self.config.maxKernelSources) & (self.config.maxKernelSources > 0): 

732 signalToNoise = selectSources.getPsfInstFlux()/selectSources.getPsfInstFluxErr() 

733 indices = np.argsort(signalToNoise) 

734 indices = indices[-self.config.maxKernelSources:] 

735 flags = np.zeros(len(selectSources), dtype=bool) 

736 flags[indices] = True 

737 selectSources = selectSources[flags].copy(deep=True) 

738 

739 self.log.info("%i/%i=%.1f%% of sources selected for PSF matching from the input catalog", 

740 len(selectSources), len(sources), 100*len(selectSources)/len(sources)) 

741 if len(selectSources) < self.config.minKernelSources: 

742 self.log.error("Too few sources to calculate the PSF matching kernel: " 

743 "%i selected but %i needed for the calculation.", 

744 len(selectSources), self.config.minKernelSources) 

745 raise RuntimeError("Cannot compute PSF matching kernel: too few sources selected.") 

746 self.metadata.add("nPsfSources", len(selectSources)) 

747 

748 return selectSources 

749 

750 @staticmethod 

751 def _checkMask(mask, sources, excludeMaskPlanes): 

752 """Exclude sources that are located on masked pixels. 

753 

754 Parameters 

755 ---------- 

756 mask : `lsst.afw.image.Mask` 

757 The image mask plane to use to reject sources 

758 based on the location of their centroid on the ccd. 

759 sources : `lsst.afw.table.SourceCatalog` 

760 The source catalog to evaluate. 

761 excludeMaskPlanes : `list` of `str` 

762 List of the names of the mask planes to exclude. 

763 

764 Returns 

765 ------- 

766 flags : `numpy.ndarray` of `bool` 

767 Array indicating whether each source in the catalog should be 

768 kept (True) or rejected (False) based on the value of the 

769 mask plane at its location. 

770 """ 

771 setExcludeMaskPlanes = [ 

772 maskPlane for maskPlane in excludeMaskPlanes if maskPlane in mask.getMaskPlaneDict() 

773 ] 

774 

775 excludePixelMask = mask.getPlaneBitMask(setExcludeMaskPlanes) 

776 

777 xv = np.rint(sources.getX() - mask.getX0()) 

778 yv = np.rint(sources.getY() - mask.getY0()) 

779 

780 mv = mask.array[yv.astype(int), xv.astype(int)] 

781 flags = np.bitwise_and(mv, excludePixelMask) == 0 

782 return flags 

783 

784 def _prepareInputs(self, template, science, visitSummary=None): 

785 """Perform preparatory calculations common to all Alard&Lupton Tasks. 

786 

787 Parameters 

788 ---------- 

789 template : `lsst.afw.image.ExposureF` 

790 Template exposure, warped to match the science exposure. The 

791 variance plane of the template image is modified in place. 

792 science : `lsst.afw.image.ExposureF` 

793 Science exposure to subtract from the template. The variance plane 

794 of the science image is modified in place. 

795 visitSummary : `lsst.afw.table.ExposureCatalog`, optional 

796 Exposure catalog with external calibrations to be applied. Catalog 

797 uses the detector id for the catalog id, sorted on id for fast 

798 lookup. 

799 """ 

800 self._validateExposures(template, science) 

801 if visitSummary is not None: 

802 self._applyExternalCalibrations(science, visitSummary=visitSummary) 

803 templateCoverageFraction = checkTemplateIsSufficient( 

804 template[science.getBBox()], self.log, 

805 requiredTemplateFraction=self.config.requiredTemplateFraction, 

806 exceptionMessage="Not attempting subtraction. To force subtraction," 

807 " set config requiredTemplateFraction=0" 

808 ) 

809 self.metadata.add("templateCoveragePercent", 100*templateCoverageFraction) 

810 

811 if self.config.doScaleVariance: 

812 # Scale the variance of the template and science images before 

813 # convolution, subtraction, or decorrelation so that they have the 

814 # correct ratio. 

815 templateVarFactor = self.scaleVariance.run(template.maskedImage) 

816 sciVarFactor = self.scaleVariance.run(science.maskedImage) 

817 self.log.info("Template variance scaling factor: %.2f", templateVarFactor) 

818 self.metadata.add("scaleTemplateVarianceFactor", templateVarFactor) 

819 self.log.info("Science variance scaling factor: %.2f", sciVarFactor) 

820 self.metadata.add("scaleScienceVarianceFactor", sciVarFactor) 

821 

822 # Erase existing detection mask planes. 

823 # We don't want the detection mask from the science image 

824 self.updateMasks(template, science) 

825 

826 def updateMasks(self, template, science): 

827 """Update the science and template mask planes before differencing. 

828 

829 Parameters 

830 ---------- 

831 template : `lsst.afw.image.Exposure` 

832 Template exposure, warped to match the science exposure. 

833 The template mask planes will be erased, except for a few specified 

834 in the task config. 

835 science : `lsst.afw.image.Exposure` 

836 Science exposure to subtract from the template. 

837 The DETECTED and DETECTED_NEGATIVE mask planes of the science image 

838 will be erased. 

839 """ 

840 self._clearMask(science.mask, clearMaskPlanes=["DETECTED", "DETECTED_NEGATIVE"]) 

841 

842 # We will clear ALL template mask planes, except for those specified 

843 # via the `preserveTemplateMask` config. Mask planes specified via 

844 # the `renameTemplateMask` config will be copied to new planes with 

845 # "_TEMPLATE" appended to their names, and the original mask plane will 

846 # be cleared. 

847 clearMaskPlanes = [mp for mp in template.mask.getMaskPlaneDict().keys() 

848 if mp not in self.config.preserveTemplateMask] 

849 renameMaskPlanes = [mp for mp in self.config.renameTemplateMask 

850 if mp in template.mask.getMaskPlaneDict().keys()] 

851 

852 # propagate the mask plane related to Fake source injection 

853 # NOTE: the fake source injection sets FAKE plane, but it should be INJECTED 

854 # NOTE: This can be removed in DM-40796 

855 if "FAKE" in science.mask.getMaskPlaneDict().keys(): 

856 self.log.info("Adding injected mask plane to science image") 

857 self._renameMaskPlanes(science.mask, "FAKE", "INJECTED") 

858 if "FAKE" in template.mask.getMaskPlaneDict().keys(): 

859 self.log.info("Adding injected mask plane to template image") 

860 self._renameMaskPlanes(template.mask, "FAKE", "INJECTED_TEMPLATE") 

861 if "INJECTED" in renameMaskPlanes: 

862 renameMaskPlanes.remove("INJECTED") 

863 if "INJECTED_TEMPLATE" in clearMaskPlanes: 

864 clearMaskPlanes.remove("INJECTED_TEMPLATE") 

865 

866 for maskPlane in renameMaskPlanes: 

867 self._renameMaskPlanes(template.mask, maskPlane, maskPlane + "_TEMPLATE") 

868 self._clearMask(template.mask, clearMaskPlanes=clearMaskPlanes) 

869 

870 @staticmethod 

871 def _renameMaskPlanes(mask, maskPlane, newMaskPlane): 

872 """Rename a mask plane by adding the new name and copying the data. 

873 

874 Parameters 

875 ---------- 

876 mask : `lsst.afw.image.Mask` 

877 The mask image to update in place. 

878 maskPlane : `str` 

879 The name of the existing mask plane to copy. 

880 newMaskPlane : `str` 

881 The new name of the mask plane that will be added. 

882 If the mask plane already exists, it will be updated in place. 

883 """ 

884 mask.addMaskPlane(newMaskPlane) 

885 originBitMask = mask.getPlaneBitMask(maskPlane) 

886 destinationBitMask = mask.getPlaneBitMask(newMaskPlane) 

887 mask.array |= ((mask.array & originBitMask) > 0)*destinationBitMask 

888 

889 def _clearMask(self, mask, clearMaskPlanes=None): 

890 """Clear the mask plane of the template. 

891 

892 Parameters 

893 ---------- 

894 mask : `lsst.afw.image.Mask` 

895 The mask plane to erase, which will be modified in place. 

896 clearMaskPlanes : `list` of `str`, optional 

897 Erase the specified mask planes. 

898 If not supplied, the entire mask will be erased. 

899 """ 

900 if clearMaskPlanes is None: 

901 clearMaskPlanes = list(mask.getMaskPlaneDict().keys()) 

902 

903 bitMaskToClear = mask.getPlaneBitMask(clearMaskPlanes) 

904 mask &= ~bitMaskToClear 

905 

906 

907class AlardLuptonPreconvolveSubtractConnections(SubtractInputConnections, 

908 SubtractScoreOutputConnections): 

909 pass 

910 

911 

912class AlardLuptonPreconvolveSubtractConfig(AlardLuptonSubtractBaseConfig, lsst.pipe.base.PipelineTaskConfig, 

913 pipelineConnections=AlardLuptonPreconvolveSubtractConnections): 

914 pass 

915 

916 

917class AlardLuptonPreconvolveSubtractTask(AlardLuptonSubtractTask): 

918 """Subtract a template from a science image, convolving the science image 

919 before computing the kernel, and also convolving the template before 

920 subtraction. 

921 """ 

922 ConfigClass = AlardLuptonPreconvolveSubtractConfig 

923 _DefaultName = "alardLuptonPreconvolveSubtract" 

924 

925 def run(self, template, science, sources, visitSummary=None): 

926 """Preconvolve the science image with its own PSF, 

927 convolve the template image with a PSF-matching kernel and subtract 

928 from the preconvolved science image. 

929 

930 Parameters 

931 ---------- 

932 template : `lsst.afw.image.ExposureF` 

933 The template image, which has previously been warped to the science 

934 image. The template bbox will be padded by a few pixels compared to 

935 the science bbox. 

936 science : `lsst.afw.image.ExposureF` 

937 The science exposure. 

938 sources : `lsst.afw.table.SourceCatalog` 

939 Identified sources on the science exposure. This catalog is used to 

940 select sources in order to perform the AL PSF matching on stamp 

941 images around them. 

942 visitSummary : `lsst.afw.table.ExposureCatalog`, optional 

943 Exposure catalog with complete external calibrations. Catalog uses 

944 the detector id for the catalog id, sorted on id for fast lookup. 

945 

946 Returns 

947 ------- 

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

949 ``scoreExposure`` : `lsst.afw.image.ExposureF` 

950 Result of subtracting the convolved template and science 

951 images. Attached PSF is that of the original science image. 

952 ``matchedTemplate`` : `lsst.afw.image.ExposureF` 

953 Warped and PSF-matched template exposure. Attached PSF is that 

954 of the original science image. 

955 ``matchedScience`` : `lsst.afw.image.ExposureF` 

956 The science exposure after convolving with its own PSF. 

957 Attached PSF is that of the original science image. 

958 ``backgroundModel`` : `lsst.afw.math.Function2D` 

959 Background model that was fit while solving for the 

960 PSF-matching kernel 

961 ``psfMatchingKernel`` : `lsst.afw.math.Kernel` 

962 Final kernel used to PSF-match the template to the science 

963 image. 

964 """ 

965 self._prepareInputs(template, science, visitSummary=visitSummary) 

966 

967 # TODO: DM-37212 we need to mirror the kernel in order to get correct cross correlation 

968 scienceKernel = science.psf.getKernel() 

969 matchedScience = self._convolveExposure(science, scienceKernel, self.convolutionControl, 

970 interpolateBadMaskPlanes=True) 

971 self.metadata.add("convolvedExposure", "Preconvolution") 

972 try: 

973 selectSources = self._sourceSelector(sources, matchedScience.mask) 

974 subtractResults = self.runPreconvolve(template, science, matchedScience, 

975 selectSources, scienceKernel) 

976 

977 except (RuntimeError, lsst.pex.exceptions.Exception) as e: 

978 self.log.warning("Failed to match template. Checking coverage") 

979 # Raise NoWorkFound if template fraction is insufficient 

980 checkTemplateIsSufficient(template[science.getBBox()], self.log, 

981 self.config.minTemplateFractionForExpectedSuccess, 

982 exceptionMessage="Template coverage lower than expected to succeed." 

983 f" Failure is tolerable: {e}") 

984 # checkTemplateIsSufficient did not raise NoWorkFound, so raise original exception 

985 raise e 

986 

987 return subtractResults 

988 

989 def runPreconvolve(self, template, science, matchedScience, selectSources, preConvKernel): 

990 """Convolve the science image with its own PSF, then convolve the 

991 template with a matching kernel and subtract to form the Score 

992 exposure. 

993 

994 Parameters 

995 ---------- 

996 template : `lsst.afw.image.ExposureF` 

997 Template exposure, warped to match the science exposure. 

998 science : `lsst.afw.image.ExposureF` 

999 Science exposure to subtract from the template. 

1000 matchedScience : `lsst.afw.image.ExposureF` 

1001 The science exposure, convolved with the reflection of its own PSF. 

1002 selectSources : `lsst.afw.table.SourceCatalog` 

1003 Identified sources on the science exposure. This catalog is used to 

1004 select sources in order to perform the AL PSF matching on stamp 

1005 images around them. 

1006 preConvKernel : `lsst.afw.math.Kernel` 

1007 The reflection of the kernel that was used to preconvolve the 

1008 `science` exposure. Must be normalized to sum to 1. 

1009 

1010 Returns 

1011 ------- 

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

1013 

1014 ``scoreExposure`` : `lsst.afw.image.ExposureF` 

1015 Result of subtracting the convolved template and science 

1016 images. Attached PSF is that of the original science image. 

1017 ``matchedTemplate`` : `lsst.afw.image.ExposureF` 

1018 Warped and PSF-matched template exposure. Attached PSF is that 

1019 of the original science image. 

1020 ``matchedScience`` : `lsst.afw.image.ExposureF` 

1021 The science exposure after convolving with its own PSF. 

1022 Attached PSF is that of the original science image. 

1023 ``backgroundModel`` : `lsst.afw.math.Function2D` 

1024 Background model that was fit while solving for the 

1025 PSF-matching kernel 

1026 ``psfMatchingKernel`` : `lsst.afw.math.Kernel` 

1027 Final kernel used to PSF-match the template to the science 

1028 image. 

1029 """ 

1030 bbox = science.getBBox() 

1031 innerBBox = preConvKernel.shrinkBBox(bbox) 

1032 

1033 kernelSources = self.makeKernel.selectKernelSources(template[innerBBox], matchedScience[innerBBox], 

1034 candidateList=selectSources, 

1035 preconvolved=True) 

1036 kernelResult = self.makeKernel.run(template[innerBBox], matchedScience[innerBBox], kernelSources, 

1037 preconvolved=True) 

1038 

1039 matchedTemplate = self._convolveExposure(template, kernelResult.psfMatchingKernel, 

1040 self.convolutionControl, 

1041 bbox=bbox, 

1042 psf=science.psf, 

1043 interpolateBadMaskPlanes=True, 

1044 photoCalib=science.photoCalib) 

1045 score = _subtractImages(matchedScience, matchedTemplate, 

1046 backgroundModel=(kernelResult.backgroundModel 

1047 if self.config.doSubtractBackground else None)) 

1048 correctedScore = self.finalize(template[bbox], science, score, 

1049 kernelResult.psfMatchingKernel, 

1050 templateMatched=True, preConvMode=True, 

1051 preConvKernel=preConvKernel) 

1052 

1053 return lsst.pipe.base.Struct(scoreExposure=correctedScore, 

1054 matchedTemplate=matchedTemplate, 

1055 matchedScience=matchedScience, 

1056 backgroundModel=kernelResult.backgroundModel, 

1057 psfMatchingKernel=kernelResult.psfMatchingKernel) 

1058 

1059 

1060def checkTemplateIsSufficient(templateExposure, logger, requiredTemplateFraction=0., 

1061 exceptionMessage=""): 

1062 """Raise NoWorkFound if template coverage < requiredTemplateFraction 

1063 

1064 Parameters 

1065 ---------- 

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

1067 The template exposure to check 

1068 logger : `lsst.log.Log` 

1069 Logger for printing output. 

1070 requiredTemplateFraction : `float`, optional 

1071 Fraction of pixels of the science image required to have coverage 

1072 in the template. 

1073 exceptionMessage : `str`, optional 

1074 Message to include in the exception raised if the template coverage 

1075 is insufficient. 

1076 

1077 Returns 

1078 ------- 

1079 templateCoverageFraction: `float` 

1080 Fraction of pixels in the template with data. 

1081 

1082 Raises 

1083 ------ 

1084 lsst.pipe.base.NoWorkFound 

1085 Raised if fraction of good pixels, defined as not having NO_DATA 

1086 set, is less than the requiredTemplateFraction 

1087 """ 

1088 # Count the number of pixels with the NO_DATA mask bit set 

1089 # counting NaN pixels is insufficient because pixels without data are often intepolated over) 

1090 pixNoData = np.count_nonzero(templateExposure.mask.array 

1091 & templateExposure.mask.getPlaneBitMask('NO_DATA')) 

1092 pixGood = templateExposure.getBBox().getArea() - pixNoData 

1093 templateCoverageFraction = pixGood/templateExposure.getBBox().getArea() 

1094 logger.info("template has %d good pixels (%.1f%%)", pixGood, 100*templateCoverageFraction) 

1095 

1096 if templateCoverageFraction < requiredTemplateFraction: 

1097 message = ("Insufficient Template Coverage. (%.1f%% < %.1f%%)" % ( 

1098 100*templateCoverageFraction, 

1099 100*requiredTemplateFraction)) 

1100 raise lsst.pipe.base.NoWorkFound(message + " " + exceptionMessage) 

1101 return templateCoverageFraction 

1102 

1103 

1104def _subtractImages(science, template, backgroundModel=None): 

1105 """Subtract template from science, propagating relevant metadata. 

1106 

1107 Parameters 

1108 ---------- 

1109 science : `lsst.afw.Exposure` 

1110 The input science image. 

1111 template : `lsst.afw.Exposure` 

1112 The template to subtract from the science image. 

1113 backgroundModel : `lsst.afw.MaskedImage`, optional 

1114 Differential background model 

1115 

1116 Returns 

1117 ------- 

1118 difference : `lsst.afw.Exposure` 

1119 The subtracted image. 

1120 """ 

1121 difference = science.clone() 

1122 if backgroundModel is not None: 

1123 difference.maskedImage -= backgroundModel 

1124 difference.maskedImage -= template.maskedImage 

1125 return difference 

1126 

1127 

1128def _shapeTest(exp1, exp2, fwhmExposureBuffer, fwhmExposureGrid): 

1129 """Determine that the PSF of ``exp1`` is not wider than that of ``exp2``. 

1130 

1131 Parameters 

1132 ---------- 

1133 exp1 : `~lsst.afw.image.Exposure` 

1134 Exposure with the reference point spread function (PSF) to evaluate. 

1135 exp2 : `~lsst.afw.image.Exposure` 

1136 Exposure with a candidate point spread function (PSF) to evaluate. 

1137 fwhmExposureBuffer : `float` 

1138 Fractional buffer margin to be left out of all sides of the image 

1139 during the construction of the grid to compute mean PSF FWHM in an 

1140 exposure, if the PSF is not available at its average position. 

1141 fwhmExposureGrid : `int` 

1142 Grid size to compute the mean FWHM in an exposure, if the PSF is not 

1143 available at its average position. 

1144 Returns 

1145 ------- 

1146 result : `bool` 

1147 True if ``exp1`` has a PSF that is not wider than that of ``exp2`` in 

1148 either dimension. 

1149 """ 

1150 try: 

1151 shape1 = getPsfFwhm(exp1.psf, average=False) 

1152 shape2 = getPsfFwhm(exp2.psf, average=False) 

1153 except lsst.pex.exceptions.InvalidParameterError: 

1154 shape1 = evaluateMeanPsfFwhm(exp1, 

1155 fwhmExposureBuffer=fwhmExposureBuffer, 

1156 fwhmExposureGrid=fwhmExposureGrid 

1157 ) 

1158 shape2 = evaluateMeanPsfFwhm(exp2, 

1159 fwhmExposureBuffer=fwhmExposureBuffer, 

1160 fwhmExposureGrid=fwhmExposureGrid 

1161 ) 

1162 return shape1 <= shape2 

1163 

1164 # Results from getPsfFwhm is a tuple of two values, one for each dimension. 

1165 xTest = shape1[0] <= shape2[0] 

1166 yTest = shape1[1] <= shape2[1] 

1167 return xTest | yTest 

1168 

1169 

1170def _interpolateImage(maskedImage, badMaskPlanes, fallbackValue=None): 

1171 """Replace masked image pixels with interpolated values. 

1172 

1173 Parameters 

1174 ---------- 

1175 maskedImage : `lsst.afw.image.MaskedImage` 

1176 Image on which to perform interpolation. 

1177 badMaskPlanes : `list` of `str` 

1178 List of mask planes to interpolate over. 

1179 fallbackValue : `float`, optional 

1180 Value to set when interpolation fails. 

1181 

1182 Returns 

1183 ------- 

1184 result: `float` 

1185 The number of masked pixels that were replaced. 

1186 """ 

1187 imgBadMaskPlanes = [ 

1188 maskPlane for maskPlane in badMaskPlanes if maskPlane in maskedImage.mask.getMaskPlaneDict() 

1189 ] 

1190 

1191 image = maskedImage.image.array 

1192 badPixels = (maskedImage.mask.array & maskedImage.mask.getPlaneBitMask(imgBadMaskPlanes)) > 0 

1193 image[badPixels] = np.nan 

1194 if fallbackValue is None: 

1195 fallbackValue = np.nanmedian(image) 

1196 # For this initial implementation, skip the interpolation and just fill with 

1197 # the median value. 

1198 image[badPixels] = fallbackValue 

1199 return np.sum(badPixels)