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

331 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-15 12:20 +0000

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 finalizedPsfApCorrCatalog = connectionTypes.Input( 

69 doc=("Per-visit finalized psf models and aperture correction maps. " 

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 # TODO: remove on DM-39854. 

76 deprecated=( 

77 "Deprecated in favor of visitSummary. Will be removed after v26." 

78 ) 

79 ) 

80 visitSummary = connectionTypes.Input( 

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

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

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

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

85 storageClass="ExposureCatalog", 

86 name="finalVisitSummary", 

87 ) 

88 

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

90 super().__init__(config=config) 

91 if not config.doApplyFinalizedPsf: 

92 self.inputs.remove("finalizedPsfApCorrCatalog") 

93 if not config.doApplyExternalCalibrations or config.doApplyFinalizedPsf: 

94 del self.visitSummary 

95 

96 

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

98 dimensions=_dimensions, 

99 defaultTemplates=_defaultTemplates): 

100 difference = connectionTypes.Output( 

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

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

103 storageClass="ExposureF", 

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

105 ) 

106 matchedTemplate = connectionTypes.Output( 

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

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

109 storageClass="ExposureF", 

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

111 ) 

112 

113 

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

115 dimensions=_dimensions, 

116 defaultTemplates=_defaultTemplates): 

117 scoreExposure = connectionTypes.Output( 

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

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

120 storageClass="ExposureF", 

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

122 ) 

123 

124 

125class AlardLuptonSubtractConnections(SubtractInputConnections, SubtractImageOutputConnections): 

126 pass 

127 

128 

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

130 makeKernel = lsst.pex.config.ConfigurableField( 

131 target=MakeKernelTask, 

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

133 ) 

134 doDecorrelation = lsst.pex.config.Field( 

135 dtype=bool, 

136 default=True, 

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

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

139 ) 

140 decorrelate = lsst.pex.config.ConfigurableField( 

141 target=DecorrelateALKernelTask, 

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

143 ) 

144 requiredTemplateFraction = lsst.pex.config.Field( 

145 dtype=float, 

146 default=0.1, 

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

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

149 ) 

150 minTemplateFractionForExpectedSuccess = lsst.pex.config.Field( 

151 dtype=float, 

152 default=0.2, 

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

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

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

156 ) 

157 doScaleVariance = lsst.pex.config.Field( 

158 dtype=bool, 

159 default=True, 

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

161 ) 

162 scaleVariance = lsst.pex.config.ConfigurableField( 

163 target=ScaleVarianceTask, 

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

165 ) 

166 doSubtractBackground = lsst.pex.config.Field( 

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

168 dtype=bool, 

169 default=True, 

170 ) 

171 doApplyFinalizedPsf = lsst.pex.config.Field( 

172 doc="Replace science Exposure's psf and aperture correction map" 

173 " with those in finalizedPsfApCorrCatalog.", 

174 dtype=bool, 

175 default=False, 

176 # TODO: remove on DM-39854. 

177 deprecated=( 

178 "Deprecated in favor of doApplyExternalCalibrations. " 

179 "Will be removed after v26." 

180 ) 

181 ) 

182 doApplyExternalCalibrations = lsst.pex.config.Field( 

183 doc=( 

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

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

186 ), 

187 dtype=bool, 

188 default=False, 

189 ) 

190 detectionThreshold = lsst.pex.config.Field( 

191 dtype=float, 

192 default=10, 

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

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

195 ) 

196 detectionThresholdMax = lsst.pex.config.Field( 

197 dtype=float, 

198 default=500, 

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

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

201 ) 

202 maxKernelSources = lsst.pex.config.Field( 

203 dtype=int, 

204 default=1000, 

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

206 "Set to -1 to disable." 

207 ) 

208 minKernelSources = lsst.pex.config.Field( 

209 dtype=int, 

210 default=3, 

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

212 ) 

213 badSourceFlags = lsst.pex.config.ListField( 

214 dtype=str, 

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

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

217 default=("sky_source", "slot_Centroid_flag", 

218 "slot_ApFlux_flag", "slot_PsfFlux_flag", 

219 "base_PixelFlags_flag_interpolated", 

220 "base_PixelFlags_flag_saturated", 

221 "base_PixelFlags_flag_bad", 

222 ), 

223 ) 

224 excludeMaskPlanes = lsst.pex.config.ListField( 

225 dtype=str, 

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

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

228 ) 

229 badMaskPlanes = lsst.pex.config.ListField( 

230 dtype=str, 

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

232 doc="Mask planes to interpolate over." 

233 ) 

234 preserveTemplateMask = lsst.pex.config.ListField( 

235 dtype=str, 

236 default=("NO_DATA", "BAD", "SAT", "FAKE", "INJECTED", "INJECTED_CORE"), 

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

238 ) 

239 allowKernelSourceDetection = lsst.pex.config.Field( 

240 dtype=bool, 

241 default=False, 

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

243 " encountered while calculating the matching kernel." 

244 ) 

245 

246 def setDefaults(self): 

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

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

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

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

251 

252 

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

254 pipelineConnections=AlardLuptonSubtractConnections): 

255 mode = lsst.pex.config.ChoiceField( 

256 dtype=str, 

257 default="convolveTemplate", 

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

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

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

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

262 ) 

263 

264 

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

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

267 the Alard & Lupton (1998) algorithm. 

268 """ 

269 ConfigClass = AlardLuptonSubtractConfig 

270 _DefaultName = "alardLuptonSubtract" 

271 

272 def __init__(self, **kwargs): 

273 super().__init__(**kwargs) 

274 self.makeSubtask("decorrelate") 

275 self.makeSubtask("makeKernel") 

276 if self.config.doScaleVariance: 

277 self.makeSubtask("scaleVariance") 

278 

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

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

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

282 self.convolutionControl.setDoNormalize(False) 

283 self.convolutionControl.setDoCopyEdge(True) 

284 

285 def _applyExternalCalibrations(self, exposure, visitSummary): 

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

287 external ones.". 

288 

289 Parameters 

290 ---------- 

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

292 Input exposure to adjust calibrations. 

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

294 Exposure catalog with external calibrations to be applied. Catalog 

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

296 lookup. 

297 

298 Returns 

299 ------- 

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

301 Exposure with adjusted calibrations. 

302 """ 

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

304 

305 row = visitSummary.find(detectorId) 

306 if row is None: 

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

308 "Using original calibrations.", detectorId) 

309 else: 

310 psf = row.getPsf() 

311 apCorrMap = row.getApCorrMap() 

312 if psf is None: 

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

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

315 detectorId) 

316 elif apCorrMap is None: 

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

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

319 detectorId) 

320 else: 

321 exposure.setPsf(psf) 

322 exposure.info.setApCorrMap(apCorrMap) 

323 

324 return exposure 

325 

326 @timeMethod 

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

328 visitSummary=None): 

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

330 

331 Parameters 

332 ---------- 

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

334 Template exposure, warped to match the science exposure. 

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

336 Science exposure to subtract from the template. 

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

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

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

340 images around them. 

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

342 Exposure catalog with finalized psf models and aperture correction 

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

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

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

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

347 Exposure catalog with external calibrations to be applied. Catalog 

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

349 lookup. Ignored (for temporary backwards compatibility) if 

350 ``finalizedPsfApCorrCatalog`` is provided. 

351 

352 Returns 

353 ------- 

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

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

356 Result of subtracting template and science. 

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

358 Warped and PSF-matched template exposure. 

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

360 Background model that was fit while solving for the 

361 PSF-matching kernel 

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

363 Kernel used to PSF-match the convolved image. 

364 

365 Raises 

366 ------ 

367 RuntimeError 

368 If an unsupported convolution mode is supplied. 

369 RuntimeError 

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

371 lsst.pipe.base.NoWorkFound 

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

373 set, is less then the configured requiredTemplateFraction 

374 """ 

375 

376 if finalizedPsfApCorrCatalog is not None: 

377 warnings.warn( 

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

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

380 FutureWarning, 

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

382 ) 

383 visitSummary = finalizedPsfApCorrCatalog 

384 

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

386 

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

388 fwhmExposureBuffer = self.config.makeKernel.fwhmExposureBuffer 

389 fwhmExposureGrid = self.config.makeKernel.fwhmExposureGrid 

390 

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

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

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

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

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

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

397 try: 

398 templatePsfSize = getPsfFwhm(template.psf) 

399 sciencePsfSize = getPsfFwhm(science.psf) 

400 except lsst.pex.exceptions.InvalidParameterError: 

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

402 "Evaluting PSF on a grid of points." 

403 ) 

404 templatePsfSize = evaluateMeanPsfFwhm(template, 

405 fwhmExposureBuffer=fwhmExposureBuffer, 

406 fwhmExposureGrid=fwhmExposureGrid 

407 ) 

408 sciencePsfSize = evaluateMeanPsfFwhm(science, 

409 fwhmExposureBuffer=fwhmExposureBuffer, 

410 fwhmExposureGrid=fwhmExposureGrid 

411 ) 

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

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

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

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

416 

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

418 convolveTemplate = _shapeTest(template, 

419 science, 

420 fwhmExposureBuffer=fwhmExposureBuffer, 

421 fwhmExposureGrid=fwhmExposureGrid) 

422 if convolveTemplate: 

423 if sciencePsfSize < templatePsfSize: 

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

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

426 else: 

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

428 else: 

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

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

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

432 convolveTemplate = True 

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

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

435 convolveTemplate = False 

436 else: 

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

438 

439 try: 

440 sourceMask = science.mask.clone() 

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

442 selectSources = self._sourceSelector(sources, sourceMask) 

443 if convolveTemplate: 

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

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

446 else: 

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

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

449 

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

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

452 # Raise NoWorkFound if template fraction is insufficient 

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

454 self.config.minTemplateFractionForExpectedSuccess, 

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

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

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

458 raise e 

459 

460 return subtractResults 

461 

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

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

464 from the science image. 

465 

466 Parameters 

467 ---------- 

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

469 Template exposure, warped to match the science exposure. 

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

471 Science exposure to subtract from the template. 

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

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

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

475 images around them. 

476 

477 Returns 

478 ------- 

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

480 

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

482 Result of subtracting template and science. 

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

484 Warped and PSF-matched template exposure. 

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

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

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

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

489 """ 

490 try: 

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

492 candidateList=selectSources, 

493 preconvolved=False) 

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

495 preconvolved=False) 

496 except Exception as e: 

497 if self.config.allowKernelSourceDetection: 

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

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

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

501 candidateList=None, 

502 preconvolved=False) 

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

504 preconvolved=False) 

505 else: 

506 raise e 

507 

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

509 self.convolutionControl, 

510 bbox=science.getBBox(), 

511 psf=science.psf, 

512 photoCalib=science.photoCalib) 

513 

514 difference = _subtractImages(science, matchedTemplate, 

515 backgroundModel=(kernelResult.backgroundModel 

516 if self.config.doSubtractBackground else None)) 

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

518 kernelResult.psfMatchingKernel, 

519 templateMatched=True) 

520 

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

522 matchedTemplate=matchedTemplate, 

523 matchedScience=science, 

524 backgroundModel=kernelResult.backgroundModel, 

525 psfMatchingKernel=kernelResult.psfMatchingKernel) 

526 

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

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

529 the template image. 

530 

531 Parameters 

532 ---------- 

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

534 Template exposure, warped to match the science exposure. 

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

536 Science exposure to subtract from the template. 

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

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

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

540 images around them. 

541 

542 Returns 

543 ------- 

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

545 

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

547 Result of subtracting template and science. 

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

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

550 is not PSF-matched to the science image. 

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

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

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

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

555 """ 

556 bbox = science.getBBox() 

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

558 candidateList=selectSources, 

559 preconvolved=False) 

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

561 preconvolved=False) 

562 modelParams = kernelResult.backgroundModel.getParameters() 

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

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

565 

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

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

568 

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

570 self.convolutionControl, 

571 psf=template.psf) 

572 

573 # Place back on native photometric scale 

574 matchedScience.maskedImage /= norm 

575 matchedTemplate = template.clone()[bbox] 

576 matchedTemplate.maskedImage /= norm 

577 matchedTemplate.setPhotoCalib(science.photoCalib) 

578 

579 difference = _subtractImages(matchedScience, matchedTemplate, 

580 backgroundModel=(kernelResult.backgroundModel 

581 if self.config.doSubtractBackground else None)) 

582 

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

584 kernelResult.psfMatchingKernel, 

585 templateMatched=False) 

586 

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

588 matchedTemplate=matchedTemplate, 

589 matchedScience=matchedScience, 

590 backgroundModel=kernelResult.backgroundModel, 

591 psfMatchingKernel=kernelResult.psfMatchingKernel,) 

592 

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

594 templateMatched=True, 

595 preConvMode=False, 

596 preConvKernel=None, 

597 spatiallyVarying=False): 

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

599 caused by convolution. 

600 

601 Parameters 

602 ---------- 

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

604 Template exposure, warped to match the science exposure. 

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

606 Science exposure to subtract from the template. 

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

608 Result of subtracting template and science. 

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

610 An (optionally spatially-varying) PSF matching kernel 

611 templateMatched : `bool`, optional 

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

613 preConvMode : `bool`, optional 

614 Was the science image preconvolved with its own PSF 

615 before PSF matching the template? 

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

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

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

619 spatiallyVarying : `bool`, optional 

620 Compute the decorrelation kernel spatially varying across the image? 

621 

622 Returns 

623 ------- 

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

625 The decorrelated image difference. 

626 """ 

627 # Erase existing detection mask planes. 

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

629 

630 self.updateMasks(template, science, difference) 

631 

632 if self.config.doDecorrelation: 

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

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

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

636 # during decorrelation 

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

638 templateMatched=templateMatched, 

639 preConvMode=preConvMode, 

640 preConvKernel=preConvKernel, 

641 spatiallyVarying=spatiallyVarying).correctedExposure 

642 else: 

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

644 correctedExposure = difference 

645 return correctedExposure 

646 

647 def updateMasks(self, template, science, difference): 

648 """Update the mask planes on images for finalizing.""" 

649 

650 bbox = science.getBBox() 

651 mask = difference.mask 

652 mask &= ~(mask.getPlaneBitMask("DETECTED") | mask.getPlaneBitMask("DETECTED_NEGATIVE")) 

653 

654 self.log.info("Adding injected mask planes") 

655 mask.addMaskPlane("INJECTED") 

656 mask.addMaskPlane("INJECTED_TEMPLATE") 

657 

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

659 # propagate the mask plane related to Fake source injection 

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

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

662 diffInjectedBitMask = mask.getPlaneBitMask("INJECTED") 

663 diffInjTmpltBitMask = mask.getPlaneBitMask("INJECTED_TEMPLATE") 

664 

665 scienceFakeBitMask = science.mask.getPlaneBitMask('FAKE') 

666 tmpltFakeBitMask = template[bbox].mask.getPlaneBitMask('FAKE') 

667 

668 injScienceMaskArray = ((science.mask.array & scienceFakeBitMask) > 0) * diffInjectedBitMask 

669 injTemplateMaskArray = ((template[bbox].mask.array & tmpltFakeBitMask) > 0) * diffInjTmpltBitMask 

670 

671 mask.array |= injScienceMaskArray 

672 mask.array |= injTemplateMaskArray 

673 

674 template[bbox].mask.array[...] = difference.mask.array[...] 

675 

676 @staticmethod 

677 def _validateExposures(template, science): 

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

679 contains the science bbox. 

680 

681 Parameters 

682 ---------- 

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

684 Template exposure, warped to match the science exposure. 

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

686 Science exposure to subtract from the template. 

687 

688 Raises 

689 ------ 

690 AssertionError 

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

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

693 bounding box. 

694 """ 

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

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

697 templateBBox = template.getBBox() 

698 scienceBBox = science.getBBox() 

699 

700 assert templateBBox.contains(scienceBBox),\ 

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

702 

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

704 bbox=None, 

705 psf=None, 

706 photoCalib=None, 

707 interpolateBadMaskPlanes=False, 

708 ): 

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

710 

711 Parameters 

712 ---------- 

713 exposure : `lsst.afw.Exposure` 

714 exposure to convolve. 

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

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

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

718 Configuration for convolve algorithm. 

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

720 Bounding box to trim the convolved exposure to. 

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

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

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

724 Photometric calibration of the convolved exposure. 

725 

726 Returns 

727 ------- 

728 convolvedExp : `lsst.afw.Exposure` 

729 The convolved image. 

730 """ 

731 convolvedExposure = exposure.clone() 

732 if psf is not None: 

733 convolvedExposure.setPsf(psf) 

734 if photoCalib is not None: 

735 convolvedExposure.setPhotoCalib(photoCalib) 

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

737 nInterp = _interpolateImage(convolvedExposure.maskedImage, 

738 self.config.badMaskPlanes) 

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

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

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

742 convolvedExposure.setMaskedImage(convolvedImage) 

743 if bbox is None: 

744 return convolvedExposure 

745 else: 

746 return convolvedExposure[bbox] 

747 

748 def _sourceSelector(self, sources, mask): 

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

750 

751 Parameters 

752 ---------- 

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

754 Input source catalog to select sources from. 

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

756 The image mask plane to use to reject sources 

757 based on their location on the ccd. 

758 

759 Returns 

760 ------- 

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

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

763 sources removed. 

764 

765 Raises 

766 ------ 

767 RuntimeError 

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

769 remaining after source selection. 

770 """ 

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

772 for flag in self.config.badSourceFlags: 

773 try: 

774 flags *= ~sources[flag] 

775 except Exception as e: 

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

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

778 sToNFlag = signalToNoise > self.config.detectionThreshold 

779 flags *= sToNFlag 

780 sToNFlagMax = signalToNoise < self.config.detectionThresholdMax 

781 flags *= sToNFlagMax 

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

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

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

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

786 indices = np.argsort(signalToNoise) 

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

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

789 flags[indices] = True 

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

791 

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

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

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

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

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

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

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

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

800 

801 return selectSources 

802 

803 @staticmethod 

804 def _checkMask(mask, sources, excludeMaskPlanes): 

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

806 

807 Parameters 

808 ---------- 

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

810 The image mask plane to use to reject sources 

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

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

813 The source catalog to evaluate. 

814 excludeMaskPlanes : `list` of `str` 

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

816 

817 Returns 

818 ------- 

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

820 Array indicating whether each source in the catalog should be 

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

822 mask plane at its location. 

823 """ 

824 setExcludeMaskPlanes = [ 

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

826 ] 

827 

828 excludePixelMask = mask.getPlaneBitMask(setExcludeMaskPlanes) 

829 

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

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

832 

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

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

835 return flags 

836 

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

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

839 

840 Parameters 

841 ---------- 

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

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

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

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

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

847 of the science image is modified in place. 

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

849 Exposure catalog with external calibrations to be applied. Catalog 

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

851 lookup. 

852 """ 

853 self._validateExposures(template, science) 

854 if visitSummary is not None: 

855 self._applyExternalCalibrations(science, visitSummary=visitSummary) 

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

857 requiredTemplateFraction=self.config.requiredTemplateFraction, 

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

859 " set config requiredTemplateFraction=0") 

860 

861 if self.config.doScaleVariance: 

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

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

864 # correct ratio. 

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

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

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

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

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

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

871 self._clearMask(template) 

872 

873 def _clearMask(self, template): 

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

875 

876 Parameters 

877 ---------- 

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

879 Template exposure, warped to match the science exposure. 

880 The mask plane will be modified in place. 

881 """ 

882 mask = template.mask 

883 clearMaskPlanes = [maskplane for maskplane in mask.getMaskPlaneDict().keys() 

884 if maskplane not in self.config.preserveTemplateMask] 

885 

886 bitMaskToClear = mask.getPlaneBitMask(clearMaskPlanes) 

887 mask &= ~bitMaskToClear 

888 

889 

890class AlardLuptonPreconvolveSubtractConnections(SubtractInputConnections, 

891 SubtractScoreOutputConnections): 

892 pass 

893 

894 

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

896 pipelineConnections=AlardLuptonPreconvolveSubtractConnections): 

897 pass 

898 

899 

900class AlardLuptonPreconvolveSubtractTask(AlardLuptonSubtractTask): 

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

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

903 subtraction. 

904 """ 

905 ConfigClass = AlardLuptonPreconvolveSubtractConfig 

906 _DefaultName = "alardLuptonPreconvolveSubtract" 

907 

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

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

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

911 from the preconvolved science image. 

912 

913 Parameters 

914 ---------- 

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

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

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

918 the science bbox. 

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

920 The science exposure. 

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

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

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

924 images around them. 

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

926 Exposure catalog with finalized psf models and aperture correction 

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

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

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

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

931 Exposure catalog with complete external calibrations. Catalog uses 

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

933 Ignored (for temporary backwards compatibility) if 

934 ``finalizedPsfApCorrCatalog`` is provided. 

935 

936 Returns 

937 ------- 

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

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

940 Result of subtracting the convolved template and science 

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

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

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

944 of the original science image. 

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

946 The science exposure after convolving with its own PSF. 

947 Attached PSF is that of the original science image. 

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

949 Background model that was fit while solving for the 

950 PSF-matching kernel 

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

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

953 image. 

954 """ 

955 if finalizedPsfApCorrCatalog is not None: 

956 warnings.warn( 

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

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

959 FutureWarning, 

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

961 ) 

962 visitSummary = finalizedPsfApCorrCatalog 

963 

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

965 

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

967 scienceKernel = science.psf.getKernel() 

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

969 interpolateBadMaskPlanes=True) 

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

971 try: 

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

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

974 selectSources, scienceKernel) 

975 

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

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

978 # Raise NoWorkFound if template fraction is insufficient 

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

980 self.config.minTemplateFractionForExpectedSuccess, 

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

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

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

984 raise e 

985 

986 return subtractResults 

987 

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

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

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

991 exposure. 

992 

993 Parameters 

994 ---------- 

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

996 Template exposure, warped to match the science exposure. 

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

998 Science exposure to subtract from the template. 

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

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

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

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

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

1004 images around them. 

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

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

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

1008 

1009 Returns 

1010 ------- 

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

1012 

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

1014 Result of subtracting the convolved template and science 

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

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

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

1018 of the original science image. 

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

1020 The science exposure after convolving with its own PSF. 

1021 Attached PSF is that of the original science image. 

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

1023 Background model that was fit while solving for the 

1024 PSF-matching kernel 

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

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

1027 image. 

1028 """ 

1029 bbox = science.getBBox() 

1030 innerBBox = preConvKernel.shrinkBBox(bbox) 

1031 

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

1033 candidateList=selectSources, 

1034 preconvolved=True) 

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

1036 preconvolved=True) 

1037 

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

1039 self.convolutionControl, 

1040 bbox=bbox, 

1041 psf=science.psf, 

1042 interpolateBadMaskPlanes=True, 

1043 photoCalib=science.photoCalib) 

1044 score = _subtractImages(matchedScience, matchedTemplate, 

1045 backgroundModel=(kernelResult.backgroundModel 

1046 if self.config.doSubtractBackground else None)) 

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

1048 kernelResult.psfMatchingKernel, 

1049 templateMatched=True, preConvMode=True, 

1050 preConvKernel=preConvKernel) 

1051 

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

1053 matchedTemplate=matchedTemplate, 

1054 matchedScience=matchedScience, 

1055 backgroundModel=kernelResult.backgroundModel, 

1056 psfMatchingKernel=kernelResult.psfMatchingKernel) 

1057 

1058 

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

1060 exceptionMessage=""): 

1061 """Raise NoWorkFound if template coverage < requiredTemplateFraction 

1062 

1063 Parameters 

1064 ---------- 

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

1066 The template exposure to check 

1067 logger : `lsst.log.Log` 

1068 Logger for printing output. 

1069 requiredTemplateFraction : `float`, optional 

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

1071 in the template. 

1072 exceptionMessage : `str`, optional 

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

1074 is insufficient. 

1075 

1076 Raises 

1077 ------ 

1078 lsst.pipe.base.NoWorkFound 

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

1080 set, is less than the requiredTemplateFraction 

1081 """ 

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

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

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

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

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

1087 logger.info("template has %d good pixels (%.1f%%)", pixGood, 

1088 100*pixGood/templateExposure.getBBox().getArea()) 

1089 

1090 if pixGood/templateExposure.getBBox().getArea() < requiredTemplateFraction: 

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

1092 100*pixGood/templateExposure.getBBox().getArea(), 

1093 100*requiredTemplateFraction)) 

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

1095 

1096 

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

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

1099 

1100 Parameters 

1101 ---------- 

1102 science : `lsst.afw.Exposure` 

1103 The input science image. 

1104 template : `lsst.afw.Exposure` 

1105 The template to subtract from the science image. 

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

1107 Differential background model 

1108 

1109 Returns 

1110 ------- 

1111 difference : `lsst.afw.Exposure` 

1112 The subtracted image. 

1113 """ 

1114 difference = science.clone() 

1115 if backgroundModel is not None: 

1116 difference.maskedImage -= backgroundModel 

1117 difference.maskedImage -= template.maskedImage 

1118 return difference 

1119 

1120 

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

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

1123 

1124 Parameters 

1125 ---------- 

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

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

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

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

1130 fwhmExposureBuffer : `float` 

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

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

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

1134 fwhmExposureGrid : `int` 

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

1136 available at its average position. 

1137 Returns 

1138 ------- 

1139 result : `bool` 

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

1141 either dimension. 

1142 """ 

1143 try: 

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

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

1146 except lsst.pex.exceptions.InvalidParameterError: 

1147 shape1 = evaluateMeanPsfFwhm(exp1, 

1148 fwhmExposureBuffer=fwhmExposureBuffer, 

1149 fwhmExposureGrid=fwhmExposureGrid 

1150 ) 

1151 shape2 = evaluateMeanPsfFwhm(exp2, 

1152 fwhmExposureBuffer=fwhmExposureBuffer, 

1153 fwhmExposureGrid=fwhmExposureGrid 

1154 ) 

1155 return shape1 <= shape2 

1156 

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

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

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

1160 return xTest | yTest 

1161 

1162 

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

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

1165 

1166 Parameters 

1167 ---------- 

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

1169 Image on which to perform interpolation. 

1170 badMaskPlanes : `list` of `str` 

1171 List of mask planes to interpolate over. 

1172 fallbackValue : `float`, optional 

1173 Value to set when interpolation fails. 

1174 

1175 Returns 

1176 ------- 

1177 result: `float` 

1178 The number of masked pixels that were replaced. 

1179 """ 

1180 imgBadMaskPlanes = [ 

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

1182 ] 

1183 

1184 image = maskedImage.image.array 

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

1186 image[badPixels] = np.nan 

1187 if fallbackValue is None: 

1188 fallbackValue = np.nanmedian(image) 

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

1190 # the median value. 

1191 image[badPixels] = fallbackValue 

1192 return np.sum(badPixels)