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

315 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-14 12:35 +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 badSourceFlags = lsst.pex.config.ListField( 

197 dtype=str, 

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

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

200 default=("sky_source", "slot_Centroid_flag", 

201 "slot_ApFlux_flag", "slot_PsfFlux_flag", ), 

202 ) 

203 badMaskPlanes = lsst.pex.config.ListField( 

204 dtype=str, 

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

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

207 ) 

208 preserveTemplateMask = lsst.pex.config.ListField( 

209 dtype=str, 

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

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

212 ) 

213 allowKernelSourceDetection = lsst.pex.config.Field( 

214 dtype=bool, 

215 default=False, 

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

217 " encountered while calculating the matching kernel." 

218 ) 

219 

220 def setDefaults(self): 

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

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

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

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

225 

226 

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

228 pipelineConnections=AlardLuptonSubtractConnections): 

229 mode = lsst.pex.config.ChoiceField( 

230 dtype=str, 

231 default="convolveTemplate", 

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

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

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

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

236 ) 

237 

238 

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

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

241 the Alard & Lupton (1998) algorithm. 

242 """ 

243 ConfigClass = AlardLuptonSubtractConfig 

244 _DefaultName = "alardLuptonSubtract" 

245 

246 def __init__(self, **kwargs): 

247 super().__init__(**kwargs) 

248 self.makeSubtask("decorrelate") 

249 self.makeSubtask("makeKernel") 

250 if self.config.doScaleVariance: 

251 self.makeSubtask("scaleVariance") 

252 

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

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

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

256 self.convolutionControl.setDoNormalize(False) 

257 self.convolutionControl.setDoCopyEdge(True) 

258 

259 def _applyExternalCalibrations(self, exposure, visitSummary): 

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

261 external ones.". 

262 

263 Parameters 

264 ---------- 

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

266 Input exposure to adjust calibrations. 

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

268 Exposure catalog with external calibrations to be applied. Catalog 

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

270 lookup. 

271 

272 Returns 

273 ------- 

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

275 Exposure with adjusted calibrations. 

276 """ 

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

278 

279 row = visitSummary.find(detectorId) 

280 if row is None: 

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

282 "Using original calibrations.", detectorId) 

283 else: 

284 psf = row.getPsf() 

285 apCorrMap = row.getApCorrMap() 

286 if psf is None: 

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

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

289 detectorId) 

290 elif apCorrMap is None: 

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

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

293 detectorId) 

294 else: 

295 exposure.setPsf(psf) 

296 exposure.info.setApCorrMap(apCorrMap) 

297 

298 return exposure 

299 

300 @timeMethod 

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

302 visitSummary=None): 

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

304 

305 Parameters 

306 ---------- 

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

308 Template exposure, warped to match the science exposure. 

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

310 Science exposure to subtract from the template. 

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

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

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

314 images around them. 

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

316 Exposure catalog with finalized psf models and aperture correction 

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

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

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

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

321 Exposure catalog with external calibrations to be applied. Catalog 

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

323 lookup. Ignored (for temporary backwards compatibility) if 

324 ``finalizedPsfApCorrCatalog`` is provided. 

325 

326 Returns 

327 ------- 

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

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

330 Result of subtracting template and science. 

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

332 Warped and PSF-matched template exposure. 

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

334 Background model that was fit while solving for the 

335 PSF-matching kernel 

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

337 Kernel used to PSF-match the convolved image. 

338 

339 Raises 

340 ------ 

341 RuntimeError 

342 If an unsupported convolution mode is supplied. 

343 RuntimeError 

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

345 lsst.pipe.base.NoWorkFound 

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

347 set, is less then the configured requiredTemplateFraction 

348 """ 

349 

350 if finalizedPsfApCorrCatalog is not None: 

351 warnings.warn( 

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

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

354 FutureWarning, 

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

356 ) 

357 visitSummary = finalizedPsfApCorrCatalog 

358 

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

360 

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

362 fwhmExposureBuffer = self.config.makeKernel.fwhmExposureBuffer 

363 fwhmExposureGrid = self.config.makeKernel.fwhmExposureGrid 

364 

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

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

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

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

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

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

371 try: 

372 templatePsfSize = getPsfFwhm(template.psf) 

373 sciencePsfSize = getPsfFwhm(science.psf) 

374 except lsst.pex.exceptions.InvalidParameterError: 

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

376 "Evaluting PSF on a grid of points." 

377 ) 

378 templatePsfSize = evaluateMeanPsfFwhm(template, 

379 fwhmExposureBuffer=fwhmExposureBuffer, 

380 fwhmExposureGrid=fwhmExposureGrid 

381 ) 

382 sciencePsfSize = evaluateMeanPsfFwhm(science, 

383 fwhmExposureBuffer=fwhmExposureBuffer, 

384 fwhmExposureGrid=fwhmExposureGrid 

385 ) 

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

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

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

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

390 

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

392 convolveTemplate = _shapeTest(template, 

393 science, 

394 fwhmExposureBuffer=fwhmExposureBuffer, 

395 fwhmExposureGrid=fwhmExposureGrid) 

396 if convolveTemplate: 

397 if sciencePsfSize < templatePsfSize: 

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

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

400 else: 

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

402 else: 

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

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

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

406 convolveTemplate = True 

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

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

409 convolveTemplate = False 

410 else: 

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

412 

413 try: 

414 selectSources = self._sourceSelector(sources, science.mask) 

415 if convolveTemplate: 

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

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

418 else: 

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

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

421 

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

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

424 # Raise NoWorkFound if template fraction is insufficient 

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

426 self.config.minTemplateFractionForExpectedSuccess, 

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

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

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

430 raise e 

431 

432 return subtractResults 

433 

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

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

436 from the science image. 

437 

438 Parameters 

439 ---------- 

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

441 Template exposure, warped to match the science exposure. 

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

443 Science exposure to subtract from the template. 

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

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

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

447 images around them. 

448 

449 Returns 

450 ------- 

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

452 

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

454 Result of subtracting template and science. 

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

456 Warped and PSF-matched template exposure. 

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

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

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

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

461 """ 

462 try: 

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

464 candidateList=selectSources, 

465 preconvolved=False) 

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

467 preconvolved=False) 

468 except Exception as e: 

469 if self.config.allowKernelSourceDetection: 

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

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

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

473 candidateList=None, 

474 preconvolved=False) 

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

476 preconvolved=False) 

477 else: 

478 raise e 

479 

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

481 self.convolutionControl, 

482 bbox=science.getBBox(), 

483 psf=science.psf, 

484 photoCalib=science.photoCalib) 

485 

486 difference = _subtractImages(science, matchedTemplate, 

487 backgroundModel=(kernelResult.backgroundModel 

488 if self.config.doSubtractBackground else None)) 

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

490 kernelResult.psfMatchingKernel, 

491 templateMatched=True) 

492 

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

494 matchedTemplate=matchedTemplate, 

495 matchedScience=science, 

496 backgroundModel=kernelResult.backgroundModel, 

497 psfMatchingKernel=kernelResult.psfMatchingKernel) 

498 

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

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

501 the template image. 

502 

503 Parameters 

504 ---------- 

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

506 Template exposure, warped to match the science exposure. 

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

508 Science exposure to subtract from the template. 

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

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

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

512 images around them. 

513 

514 Returns 

515 ------- 

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

517 

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

519 Result of subtracting template and science. 

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

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

522 is not PSF-matched to the science image. 

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

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

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

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

527 """ 

528 bbox = science.getBBox() 

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

530 candidateList=selectSources, 

531 preconvolved=False) 

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

533 preconvolved=False) 

534 modelParams = kernelResult.backgroundModel.getParameters() 

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

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

537 

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

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

540 

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

542 self.convolutionControl, 

543 psf=template.psf) 

544 

545 # Place back on native photometric scale 

546 matchedScience.maskedImage /= norm 

547 matchedTemplate = template.clone()[bbox] 

548 matchedTemplate.maskedImage /= norm 

549 matchedTemplate.setPhotoCalib(science.photoCalib) 

550 

551 difference = _subtractImages(matchedScience, matchedTemplate, 

552 backgroundModel=(kernelResult.backgroundModel 

553 if self.config.doSubtractBackground else None)) 

554 

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

556 kernelResult.psfMatchingKernel, 

557 templateMatched=False) 

558 

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

560 matchedTemplate=matchedTemplate, 

561 matchedScience=matchedScience, 

562 backgroundModel=kernelResult.backgroundModel, 

563 psfMatchingKernel=kernelResult.psfMatchingKernel,) 

564 

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

566 templateMatched=True, 

567 preConvMode=False, 

568 preConvKernel=None, 

569 spatiallyVarying=False): 

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

571 caused by convolution. 

572 

573 Parameters 

574 ---------- 

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

576 Template exposure, warped to match the science exposure. 

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

578 Science exposure to subtract from the template. 

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

580 Result of subtracting template and science. 

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

582 An (optionally spatially-varying) PSF matching kernel 

583 templateMatched : `bool`, optional 

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

585 preConvMode : `bool`, optional 

586 Was the science image preconvolved with its own PSF 

587 before PSF matching the template? 

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

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

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

591 spatiallyVarying : `bool`, optional 

592 Compute the decorrelation kernel spatially varying across the image? 

593 

594 Returns 

595 ------- 

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

597 The decorrelated image difference. 

598 """ 

599 # Erase existing detection mask planes. 

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

601 

602 self.updateMasks(template, science, difference) 

603 

604 if self.config.doDecorrelation: 

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

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

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

608 # during decorrelation 

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

610 templateMatched=templateMatched, 

611 preConvMode=preConvMode, 

612 preConvKernel=preConvKernel, 

613 spatiallyVarying=spatiallyVarying).correctedExposure 

614 else: 

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

616 correctedExposure = difference 

617 return correctedExposure 

618 

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

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

621 

622 bbox = science.getBBox() 

623 mask = difference.mask 

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

625 

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

627 # propagate the mask plane related to Fake source injection 

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

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

630 

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

632 mask.addMaskPlane("INJECTED") 

633 diffInjectedBitMask = mask.getPlaneBitMask("INJECTED") 

634 

635 mask.addMaskPlane("INJECTED_TEMPLATE") 

636 diffInjTmpltBitMask = mask.getPlaneBitMask("INJECTED_TEMPLATE") 

637 

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

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

640 

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

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

643 

644 mask.array |= injScienceMaskArray 

645 mask.array |= injTemplateMaskArray 

646 

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

648 

649 @staticmethod 

650 def _validateExposures(template, science): 

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

652 contains the science bbox. 

653 

654 Parameters 

655 ---------- 

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

657 Template exposure, warped to match the science exposure. 

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

659 Science exposure to subtract from the template. 

660 

661 Raises 

662 ------ 

663 AssertionError 

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

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

666 bounding box. 

667 """ 

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

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

670 templateBBox = template.getBBox() 

671 scienceBBox = science.getBBox() 

672 

673 assert templateBBox.contains(scienceBBox),\ 

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

675 

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

677 bbox=None, 

678 psf=None, 

679 photoCalib=None, 

680 interpolateBadMaskPlanes=False, 

681 ): 

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

683 

684 Parameters 

685 ---------- 

686 exposure : `lsst.afw.Exposure` 

687 exposure to convolve. 

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

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

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

691 Configuration for convolve algorithm. 

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

693 Bounding box to trim the convolved exposure to. 

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

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

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

697 Photometric calibration of the convolved exposure. 

698 

699 Returns 

700 ------- 

701 convolvedExp : `lsst.afw.Exposure` 

702 The convolved image. 

703 """ 

704 convolvedExposure = exposure.clone() 

705 if psf is not None: 

706 convolvedExposure.setPsf(psf) 

707 if photoCalib is not None: 

708 convolvedExposure.setPhotoCalib(photoCalib) 

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

710 nInterp = _interpolateImage(convolvedExposure.maskedImage, 

711 self.config.badMaskPlanes) 

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

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

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

715 convolvedExposure.setMaskedImage(convolvedImage) 

716 if bbox is None: 

717 return convolvedExposure 

718 else: 

719 return convolvedExposure[bbox] 

720 

721 def _sourceSelector(self, sources, mask): 

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

723 

724 Parameters 

725 ---------- 

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

727 Input source catalog to select sources from. 

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

729 The image mask plane to use to reject sources 

730 based on their location on the ccd. 

731 

732 Returns 

733 ------- 

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

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

736 sources removed. 

737 

738 Raises 

739 ------ 

740 RuntimeError 

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

742 remaining after source selection. 

743 """ 

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

745 for flag in self.config.badSourceFlags: 

746 try: 

747 flags *= ~sources[flag] 

748 except Exception as e: 

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

750 sToNFlag = (sources.getPsfInstFlux()/sources.getPsfInstFluxErr()) > self.config.detectionThreshold 

751 flags *= sToNFlag 

752 flags *= self._checkMask(mask, sources, self.config.badMaskPlanes) 

753 selectSources = sources[flags] 

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

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

756 if len(selectSources) < self.config.makeKernel.nStarPerCell: 

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

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

759 len(selectSources), self.config.makeKernel.nStarPerCell) 

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

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

762 

763 return selectSources.copy(deep=True) 

764 

765 @staticmethod 

766 def _checkMask(mask, sources, badMaskPlanes): 

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

768 

769 Parameters 

770 ---------- 

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

772 The image mask plane to use to reject sources 

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

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

775 The source catalog to evaluate. 

776 badMaskPlanes : `list` of `str` 

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

778 

779 Returns 

780 ------- 

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

782 Array indicating whether each source in the catalog should be 

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

784 mask plane at its location. 

785 """ 

786 setBadMaskPlanes = [ 

787 maskPlane for maskPlane in badMaskPlanes if maskPlane in mask.getMaskPlaneDict() 

788 ] 

789 

790 badPixelMask = mask.getPlaneBitMask(setBadMaskPlanes) 

791 

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

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

794 

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

796 flags = np.bitwise_and(mv, badPixelMask) == 0 

797 return flags 

798 

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

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

801 

802 Parameters 

803 ---------- 

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

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

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

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

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

809 of the science image is modified in place. 

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

811 Exposure catalog with external calibrations to be applied. Catalog 

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

813 lookup. 

814 """ 

815 self._validateExposures(template, science) 

816 if visitSummary is not None: 

817 self._applyExternalCalibrations(science, visitSummary=visitSummary) 

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

819 requiredTemplateFraction=self.config.requiredTemplateFraction, 

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

821 " set config requiredTemplateFraction=0") 

822 

823 if self.config.doScaleVariance: 

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

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

826 # correct ratio. 

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

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

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

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

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

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

833 self._clearMask(template) 

834 

835 def _clearMask(self, template): 

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

837 

838 Parameters 

839 ---------- 

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

841 Template exposure, warped to match the science exposure. 

842 The mask plane will be modified in place. 

843 """ 

844 mask = template.mask 

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

846 if maskplane not in self.config.preserveTemplateMask] 

847 

848 bitMaskToClear = mask.getPlaneBitMask(clearMaskPlanes) 

849 mask &= ~bitMaskToClear 

850 

851 

852class AlardLuptonPreconvolveSubtractConnections(SubtractInputConnections, 

853 SubtractScoreOutputConnections): 

854 pass 

855 

856 

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

858 pipelineConnections=AlardLuptonPreconvolveSubtractConnections): 

859 pass 

860 

861 

862class AlardLuptonPreconvolveSubtractTask(AlardLuptonSubtractTask): 

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

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

865 subtraction. 

866 """ 

867 ConfigClass = AlardLuptonPreconvolveSubtractConfig 

868 _DefaultName = "alardLuptonPreconvolveSubtract" 

869 

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

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

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

873 from the preconvolved science image. 

874 

875 Parameters 

876 ---------- 

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

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

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

880 the science bbox. 

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

882 The science exposure. 

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

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

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

886 images around them. 

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

888 Exposure catalog with finalized psf models and aperture correction 

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

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

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

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

893 Exposure catalog with complete external calibrations. Catalog uses 

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

895 Ignored (for temporary backwards compatibility) if 

896 ``finalizedPsfApCorrCatalog`` is provided. 

897 

898 Returns 

899 ------- 

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

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

902 Result of subtracting the convolved template and science 

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

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

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

906 of the original science image. 

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

908 The science exposure after convolving with its own PSF. 

909 Attached PSF is that of the original science image. 

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

911 Background model that was fit while solving for the 

912 PSF-matching kernel 

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

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

915 image. 

916 """ 

917 if finalizedPsfApCorrCatalog is not None: 

918 warnings.warn( 

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

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

921 FutureWarning, 

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

923 ) 

924 visitSummary = finalizedPsfApCorrCatalog 

925 

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

927 

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

929 scienceKernel = science.psf.getKernel() 

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

931 interpolateBadMaskPlanes=True) 

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

933 try: 

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

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

936 selectSources, scienceKernel) 

937 

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

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

940 # Raise NoWorkFound if template fraction is insufficient 

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

942 self.config.minTemplateFractionForExpectedSuccess, 

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

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

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

946 raise e 

947 

948 return subtractResults 

949 

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

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

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

953 exposure. 

954 

955 Parameters 

956 ---------- 

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

958 Template exposure, warped to match the science exposure. 

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

960 Science exposure to subtract from the template. 

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

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

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

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

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

966 images around them. 

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

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

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

970 

971 Returns 

972 ------- 

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

974 

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

976 Result of subtracting the convolved template and science 

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

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

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

980 of the original science image. 

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

982 The science exposure after convolving with its own PSF. 

983 Attached PSF is that of the original science image. 

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

985 Background model that was fit while solving for the 

986 PSF-matching kernel 

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

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

989 image. 

990 """ 

991 bbox = science.getBBox() 

992 innerBBox = preConvKernel.shrinkBBox(bbox) 

993 

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

995 candidateList=selectSources, 

996 preconvolved=True) 

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

998 preconvolved=True) 

999 

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

1001 self.convolutionControl, 

1002 bbox=bbox, 

1003 psf=science.psf, 

1004 interpolateBadMaskPlanes=True, 

1005 photoCalib=science.photoCalib) 

1006 score = _subtractImages(matchedScience, matchedTemplate, 

1007 backgroundModel=(kernelResult.backgroundModel 

1008 if self.config.doSubtractBackground else None)) 

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

1010 kernelResult.psfMatchingKernel, 

1011 templateMatched=True, preConvMode=True, 

1012 preConvKernel=preConvKernel) 

1013 

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

1015 matchedTemplate=matchedTemplate, 

1016 matchedScience=matchedScience, 

1017 backgroundModel=kernelResult.backgroundModel, 

1018 psfMatchingKernel=kernelResult.psfMatchingKernel) 

1019 

1020 

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

1022 exceptionMessage=""): 

1023 """Raise NoWorkFound if template coverage < requiredTemplateFraction 

1024 

1025 Parameters 

1026 ---------- 

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

1028 The template exposure to check 

1029 logger : `lsst.log.Log` 

1030 Logger for printing output. 

1031 requiredTemplateFraction : `float`, optional 

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

1033 in the template. 

1034 exceptionMessage : `str`, optional 

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

1036 is insufficient. 

1037 

1038 Raises 

1039 ------ 

1040 lsst.pipe.base.NoWorkFound 

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

1042 set, is less than the requiredTemplateFraction 

1043 """ 

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

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

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

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

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

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

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

1051 

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

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

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

1055 100*requiredTemplateFraction)) 

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

1057 

1058 

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

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

1061 

1062 Parameters 

1063 ---------- 

1064 science : `lsst.afw.Exposure` 

1065 The input science image. 

1066 template : `lsst.afw.Exposure` 

1067 The template to subtract from the science image. 

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

1069 Differential background model 

1070 

1071 Returns 

1072 ------- 

1073 difference : `lsst.afw.Exposure` 

1074 The subtracted image. 

1075 """ 

1076 difference = science.clone() 

1077 if backgroundModel is not None: 

1078 difference.maskedImage -= backgroundModel 

1079 difference.maskedImage -= template.maskedImage 

1080 return difference 

1081 

1082 

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

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

1085 

1086 Parameters 

1087 ---------- 

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

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

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

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

1092 fwhmExposureBuffer : `float` 

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

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

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

1096 fwhmExposureGrid : `int` 

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

1098 available at its average position. 

1099 Returns 

1100 ------- 

1101 result : `bool` 

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

1103 either dimension. 

1104 """ 

1105 try: 

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

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

1108 except lsst.pex.exceptions.InvalidParameterError: 

1109 shape1 = evaluateMeanPsfFwhm(exp1, 

1110 fwhmExposureBuffer=fwhmExposureBuffer, 

1111 fwhmExposureGrid=fwhmExposureGrid 

1112 ) 

1113 shape2 = evaluateMeanPsfFwhm(exp2, 

1114 fwhmExposureBuffer=fwhmExposureBuffer, 

1115 fwhmExposureGrid=fwhmExposureGrid 

1116 ) 

1117 return shape1 <= shape2 

1118 

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

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

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

1122 return xTest | yTest 

1123 

1124 

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

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

1127 

1128 Parameters 

1129 ---------- 

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

1131 Image on which to perform interpolation. 

1132 badMaskPlanes : `list` of `str` 

1133 List of mask planes to interpolate over. 

1134 fallbackValue : `float`, optional 

1135 Value to set when interpolation fails. 

1136 

1137 Returns 

1138 ------- 

1139 result: `float` 

1140 The number of masked pixels that were replaced. 

1141 """ 

1142 imgBadMaskPlanes = [ 

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

1144 ] 

1145 

1146 image = maskedImage.image.array 

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

1148 image[badPixels] = np.nan 

1149 if fallbackValue is None: 

1150 fallbackValue = np.nanmedian(image) 

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

1152 # the median value. 

1153 image[badPixels] = fallbackValue 

1154 return np.sum(badPixels)