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

340 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-20 13:02 +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",), 

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

238 ) 

239 renameTemplateMask = lsst.pex.config.ListField( 

240 dtype=str, 

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

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

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

244 ) 

245 allowKernelSourceDetection = lsst.pex.config.Field( 

246 dtype=bool, 

247 default=False, 

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

249 " encountered while calculating the matching kernel." 

250 ) 

251 

252 def setDefaults(self): 

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

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

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

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

257 

258 

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

260 pipelineConnections=AlardLuptonSubtractConnections): 

261 mode = lsst.pex.config.ChoiceField( 

262 dtype=str, 

263 default="convolveTemplate", 

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

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

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

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

268 ) 

269 

270 

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

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

273 the Alard & Lupton (1998) algorithm. 

274 """ 

275 ConfigClass = AlardLuptonSubtractConfig 

276 _DefaultName = "alardLuptonSubtract" 

277 

278 def __init__(self, **kwargs): 

279 super().__init__(**kwargs) 

280 self.makeSubtask("decorrelate") 

281 self.makeSubtask("makeKernel") 

282 if self.config.doScaleVariance: 

283 self.makeSubtask("scaleVariance") 

284 

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

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

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

288 self.convolutionControl.setDoNormalize(False) 

289 self.convolutionControl.setDoCopyEdge(True) 

290 

291 def _applyExternalCalibrations(self, exposure, visitSummary): 

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

293 external ones.". 

294 

295 Parameters 

296 ---------- 

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

298 Input exposure to adjust calibrations. 

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

300 Exposure catalog with external calibrations to be applied. Catalog 

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

302 lookup. 

303 

304 Returns 

305 ------- 

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

307 Exposure with adjusted calibrations. 

308 """ 

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

310 

311 row = visitSummary.find(detectorId) 

312 if row is None: 

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

314 "Using original calibrations.", detectorId) 

315 else: 

316 psf = row.getPsf() 

317 apCorrMap = row.getApCorrMap() 

318 if psf is None: 

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

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

321 detectorId) 

322 elif apCorrMap is None: 

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

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

325 detectorId) 

326 else: 

327 exposure.setPsf(psf) 

328 exposure.info.setApCorrMap(apCorrMap) 

329 

330 return exposure 

331 

332 @timeMethod 

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

334 visitSummary=None): 

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

336 

337 Parameters 

338 ---------- 

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

340 Template exposure, warped to match the science exposure. 

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

342 Science exposure to subtract from the template. 

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

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

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

346 images around them. 

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

348 Exposure catalog with finalized psf models and aperture correction 

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

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

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

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

353 Exposure catalog with external calibrations to be applied. Catalog 

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

355 lookup. Ignored (for temporary backwards compatibility) if 

356 ``finalizedPsfApCorrCatalog`` is provided. 

357 

358 Returns 

359 ------- 

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

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

362 Result of subtracting template and science. 

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

364 Warped and PSF-matched template exposure. 

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

366 Background model that was fit while solving for the 

367 PSF-matching kernel 

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

369 Kernel used to PSF-match the convolved image. 

370 

371 Raises 

372 ------ 

373 RuntimeError 

374 If an unsupported convolution mode is supplied. 

375 RuntimeError 

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

377 lsst.pipe.base.NoWorkFound 

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

379 set, is less then the configured requiredTemplateFraction 

380 """ 

381 

382 if finalizedPsfApCorrCatalog is not None: 

383 warnings.warn( 

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

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

386 FutureWarning, 

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

388 ) 

389 visitSummary = finalizedPsfApCorrCatalog 

390 

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

392 

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

394 fwhmExposureBuffer = self.config.makeKernel.fwhmExposureBuffer 

395 fwhmExposureGrid = self.config.makeKernel.fwhmExposureGrid 

396 

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

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

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

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

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

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

403 try: 

404 templatePsfSize = getPsfFwhm(template.psf) 

405 sciencePsfSize = getPsfFwhm(science.psf) 

406 except lsst.pex.exceptions.InvalidParameterError: 

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

408 "Evaluting PSF on a grid of points." 

409 ) 

410 templatePsfSize = evaluateMeanPsfFwhm(template, 

411 fwhmExposureBuffer=fwhmExposureBuffer, 

412 fwhmExposureGrid=fwhmExposureGrid 

413 ) 

414 sciencePsfSize = evaluateMeanPsfFwhm(science, 

415 fwhmExposureBuffer=fwhmExposureBuffer, 

416 fwhmExposureGrid=fwhmExposureGrid 

417 ) 

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

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

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

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

422 

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

424 convolveTemplate = _shapeTest(template, 

425 science, 

426 fwhmExposureBuffer=fwhmExposureBuffer, 

427 fwhmExposureGrid=fwhmExposureGrid) 

428 if convolveTemplate: 

429 if sciencePsfSize < templatePsfSize: 

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

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

432 else: 

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

434 else: 

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

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

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

438 convolveTemplate = True 

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

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

441 convolveTemplate = False 

442 else: 

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

444 

445 try: 

446 sourceMask = science.mask.clone() 

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

448 selectSources = self._sourceSelector(sources, sourceMask) 

449 if convolveTemplate: 

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

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

452 else: 

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

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

455 

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

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

458 # Raise NoWorkFound if template fraction is insufficient 

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

460 self.config.minTemplateFractionForExpectedSuccess, 

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

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

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

464 raise e 

465 

466 return subtractResults 

467 

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

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

470 from the science image. 

471 

472 Parameters 

473 ---------- 

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

475 Template exposure, warped to match the science exposure. 

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

477 Science exposure to subtract from the template. 

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

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

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

481 images around them. 

482 

483 Returns 

484 ------- 

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

486 

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

488 Result of subtracting template and science. 

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

490 Warped and PSF-matched template exposure. 

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

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

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

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

495 """ 

496 try: 

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

498 candidateList=selectSources, 

499 preconvolved=False) 

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

501 preconvolved=False) 

502 except Exception as e: 

503 if self.config.allowKernelSourceDetection: 

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

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

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

507 candidateList=None, 

508 preconvolved=False) 

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

510 preconvolved=False) 

511 else: 

512 raise e 

513 

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

515 self.convolutionControl, 

516 bbox=science.getBBox(), 

517 psf=science.psf, 

518 photoCalib=science.photoCalib) 

519 

520 difference = _subtractImages(science, matchedTemplate, 

521 backgroundModel=(kernelResult.backgroundModel 

522 if self.config.doSubtractBackground else None)) 

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

524 kernelResult.psfMatchingKernel, 

525 templateMatched=True) 

526 

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

528 matchedTemplate=matchedTemplate, 

529 matchedScience=science, 

530 backgroundModel=kernelResult.backgroundModel, 

531 psfMatchingKernel=kernelResult.psfMatchingKernel) 

532 

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

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

535 the template image. 

536 

537 Parameters 

538 ---------- 

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

540 Template exposure, warped to match the science exposure. 

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

542 Science exposure to subtract from the template. 

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

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

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

546 images around them. 

547 

548 Returns 

549 ------- 

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

551 

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

553 Result of subtracting template and science. 

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

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

556 is not PSF-matched to the science image. 

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

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

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

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

561 """ 

562 bbox = science.getBBox() 

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

564 candidateList=selectSources, 

565 preconvolved=False) 

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

567 preconvolved=False) 

568 modelParams = kernelResult.backgroundModel.getParameters() 

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

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

571 

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

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

574 

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

576 self.convolutionControl, 

577 psf=template.psf) 

578 

579 # Place back on native photometric scale 

580 matchedScience.maskedImage /= norm 

581 matchedTemplate = template.clone()[bbox] 

582 matchedTemplate.maskedImage /= norm 

583 matchedTemplate.setPhotoCalib(science.photoCalib) 

584 

585 difference = _subtractImages(matchedScience, matchedTemplate, 

586 backgroundModel=(kernelResult.backgroundModel 

587 if self.config.doSubtractBackground else None)) 

588 

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

590 kernelResult.psfMatchingKernel, 

591 templateMatched=False) 

592 

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

594 matchedTemplate=matchedTemplate, 

595 matchedScience=matchedScience, 

596 backgroundModel=kernelResult.backgroundModel, 

597 psfMatchingKernel=kernelResult.psfMatchingKernel,) 

598 

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

600 templateMatched=True, 

601 preConvMode=False, 

602 preConvKernel=None, 

603 spatiallyVarying=False): 

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

605 caused by convolution. 

606 

607 Parameters 

608 ---------- 

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

610 Template exposure, warped to match the science exposure. 

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

612 Science exposure to subtract from the template. 

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

614 Result of subtracting template and science. 

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

616 An (optionally spatially-varying) PSF matching kernel 

617 templateMatched : `bool`, optional 

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

619 preConvMode : `bool`, optional 

620 Was the science image preconvolved with its own PSF 

621 before PSF matching the template? 

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

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

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

625 spatiallyVarying : `bool`, optional 

626 Compute the decorrelation kernel spatially varying across the image? 

627 

628 Returns 

629 ------- 

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

631 The decorrelated image difference. 

632 """ 

633 if self.config.doDecorrelation: 

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

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

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

637 # during decorrelation 

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

639 templateMatched=templateMatched, 

640 preConvMode=preConvMode, 

641 preConvKernel=preConvKernel, 

642 spatiallyVarying=spatiallyVarying).correctedExposure 

643 else: 

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

645 correctedExposure = difference 

646 return correctedExposure 

647 

648 @staticmethod 

649 def _validateExposures(template, science): 

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

651 contains the science bbox. 

652 

653 Parameters 

654 ---------- 

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

656 Template exposure, warped to match the science exposure. 

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

658 Science exposure to subtract from the template. 

659 

660 Raises 

661 ------ 

662 AssertionError 

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

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

665 bounding box. 

666 """ 

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

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

669 templateBBox = template.getBBox() 

670 scienceBBox = science.getBBox() 

671 

672 assert templateBBox.contains(scienceBBox),\ 

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

674 

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

676 bbox=None, 

677 psf=None, 

678 photoCalib=None, 

679 interpolateBadMaskPlanes=False, 

680 ): 

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

682 

683 Parameters 

684 ---------- 

685 exposure : `lsst.afw.Exposure` 

686 exposure to convolve. 

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

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

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

690 Configuration for convolve algorithm. 

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

692 Bounding box to trim the convolved exposure to. 

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

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

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

696 Photometric calibration of the convolved exposure. 

697 

698 Returns 

699 ------- 

700 convolvedExp : `lsst.afw.Exposure` 

701 The convolved image. 

702 """ 

703 convolvedExposure = exposure.clone() 

704 if psf is not None: 

705 convolvedExposure.setPsf(psf) 

706 if photoCalib is not None: 

707 convolvedExposure.setPhotoCalib(photoCalib) 

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

709 nInterp = _interpolateImage(convolvedExposure.maskedImage, 

710 self.config.badMaskPlanes) 

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

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

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

714 convolvedExposure.setMaskedImage(convolvedImage) 

715 if bbox is None: 

716 return convolvedExposure 

717 else: 

718 return convolvedExposure[bbox] 

719 

720 def _sourceSelector(self, sources, mask): 

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

722 

723 Parameters 

724 ---------- 

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

726 Input source catalog to select sources from. 

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

728 The image mask plane to use to reject sources 

729 based on their location on the ccd. 

730 

731 Returns 

732 ------- 

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

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

735 sources removed. 

736 

737 Raises 

738 ------ 

739 RuntimeError 

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

741 remaining after source selection. 

742 """ 

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

744 for flag in self.config.badSourceFlags: 

745 try: 

746 flags *= ~sources[flag] 

747 except Exception as e: 

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

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

750 sToNFlag = signalToNoise > self.config.detectionThreshold 

751 flags *= sToNFlag 

752 sToNFlagMax = signalToNoise < self.config.detectionThresholdMax 

753 flags *= sToNFlagMax 

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

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

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

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

758 indices = np.argsort(signalToNoise) 

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

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

761 flags[indices] = True 

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

763 

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

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

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

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

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

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

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

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

772 

773 return selectSources 

774 

775 @staticmethod 

776 def _checkMask(mask, sources, excludeMaskPlanes): 

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

778 

779 Parameters 

780 ---------- 

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

782 The image mask plane to use to reject sources 

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

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

785 The source catalog to evaluate. 

786 excludeMaskPlanes : `list` of `str` 

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

788 

789 Returns 

790 ------- 

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

792 Array indicating whether each source in the catalog should be 

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

794 mask plane at its location. 

795 """ 

796 setExcludeMaskPlanes = [ 

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

798 ] 

799 

800 excludePixelMask = mask.getPlaneBitMask(setExcludeMaskPlanes) 

801 

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

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

804 

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

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

807 return flags 

808 

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

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

811 

812 Parameters 

813 ---------- 

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

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

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

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

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

819 of the science image is modified in place. 

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

821 Exposure catalog with external calibrations to be applied. Catalog 

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

823 lookup. 

824 """ 

825 self._validateExposures(template, science) 

826 if visitSummary is not None: 

827 self._applyExternalCalibrations(science, visitSummary=visitSummary) 

828 templateCoverageFraction = checkTemplateIsSufficient( 

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

830 requiredTemplateFraction=self.config.requiredTemplateFraction, 

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

832 " set config requiredTemplateFraction=0" 

833 ) 

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

835 

836 if self.config.doScaleVariance: 

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

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

839 # correct ratio. 

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

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

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

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

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

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

846 

847 # Erase existing detection mask planes. 

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

849 self.updateMasks(template, science) 

850 

851 def updateMasks(self, template, science): 

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

853 

854 Parameters 

855 ---------- 

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

857 Template exposure, warped to match the science exposure. 

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

859 in the task config. 

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

861 Science exposure to subtract from the template. 

862 The DETECTED and DETECTED_NEGATIVE mask planes of the science image 

863 will be erased. 

864 """ 

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

866 

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

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

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

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

871 # be cleared. 

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

873 if mp not in self.config.preserveTemplateMask] 

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

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

876 

877 # propagate the mask plane related to Fake source injection 

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

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

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

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

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

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

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

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

886 if "INJECTED" in renameMaskPlanes: 

887 renameMaskPlanes.remove("INJECTED") 

888 if "INJECTED_TEMPLATE" in clearMaskPlanes: 

889 clearMaskPlanes.remove("INJECTED_TEMPLATE") 

890 

891 for maskPlane in renameMaskPlanes: 

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

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

894 

895 @staticmethod 

896 def _renameMaskPlanes(mask, maskPlane, newMaskPlane): 

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

898 

899 Parameters 

900 ---------- 

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

902 The mask image to update in place. 

903 maskPlane : `str` 

904 The name of the existing mask plane to copy. 

905 newMaskPlane : `str` 

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

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

908 """ 

909 mask.addMaskPlane(newMaskPlane) 

910 originBitMask = mask.getPlaneBitMask(maskPlane) 

911 destinationBitMask = mask.getPlaneBitMask(newMaskPlane) 

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

913 

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

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

916 

917 Parameters 

918 ---------- 

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

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

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

922 Erase the specified mask planes. 

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

924 """ 

925 if clearMaskPlanes is None: 

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

927 

928 bitMaskToClear = mask.getPlaneBitMask(clearMaskPlanes) 

929 mask &= ~bitMaskToClear 

930 

931 

932class AlardLuptonPreconvolveSubtractConnections(SubtractInputConnections, 

933 SubtractScoreOutputConnections): 

934 pass 

935 

936 

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

938 pipelineConnections=AlardLuptonPreconvolveSubtractConnections): 

939 pass 

940 

941 

942class AlardLuptonPreconvolveSubtractTask(AlardLuptonSubtractTask): 

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

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

945 subtraction. 

946 """ 

947 ConfigClass = AlardLuptonPreconvolveSubtractConfig 

948 _DefaultName = "alardLuptonPreconvolveSubtract" 

949 

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

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

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

953 from the preconvolved science image. 

954 

955 Parameters 

956 ---------- 

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

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

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

960 the science bbox. 

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

962 The science exposure. 

963 sources : `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 finalizedPsfApCorrCatalog : `lsst.afw.table.ExposureCatalog`, optional 

968 Exposure catalog with finalized psf models and aperture correction 

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

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

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

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

973 Exposure catalog with complete external calibrations. Catalog uses 

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

975 Ignored (for temporary backwards compatibility) if 

976 ``finalizedPsfApCorrCatalog`` is provided. 

977 

978 Returns 

979 ------- 

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

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

982 Result of subtracting the convolved template and science 

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

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

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

986 of the original science image. 

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

988 The science exposure after convolving with its own PSF. 

989 Attached PSF is that of the original science image. 

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

991 Background model that was fit while solving for the 

992 PSF-matching kernel 

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

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

995 image. 

996 """ 

997 if finalizedPsfApCorrCatalog is not None: 

998 warnings.warn( 

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

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

1001 FutureWarning, 

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

1003 ) 

1004 visitSummary = finalizedPsfApCorrCatalog 

1005 

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

1007 

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

1009 scienceKernel = science.psf.getKernel() 

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

1011 interpolateBadMaskPlanes=True) 

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

1013 try: 

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

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

1016 selectSources, scienceKernel) 

1017 

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

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

1020 # Raise NoWorkFound if template fraction is insufficient 

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

1022 self.config.minTemplateFractionForExpectedSuccess, 

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

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

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

1026 raise e 

1027 

1028 return subtractResults 

1029 

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

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

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

1033 exposure. 

1034 

1035 Parameters 

1036 ---------- 

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

1038 Template exposure, warped to match the science exposure. 

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

1040 Science exposure to subtract from the template. 

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

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

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

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

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

1046 images around them. 

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

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

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

1050 

1051 Returns 

1052 ------- 

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

1054 

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

1056 Result of subtracting the convolved template and science 

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

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

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

1060 of the original science image. 

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

1062 The science exposure after convolving with its own PSF. 

1063 Attached PSF is that of the original science image. 

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

1065 Background model that was fit while solving for the 

1066 PSF-matching kernel 

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

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

1069 image. 

1070 """ 

1071 bbox = science.getBBox() 

1072 innerBBox = preConvKernel.shrinkBBox(bbox) 

1073 

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

1075 candidateList=selectSources, 

1076 preconvolved=True) 

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

1078 preconvolved=True) 

1079 

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

1081 self.convolutionControl, 

1082 bbox=bbox, 

1083 psf=science.psf, 

1084 interpolateBadMaskPlanes=True, 

1085 photoCalib=science.photoCalib) 

1086 score = _subtractImages(matchedScience, matchedTemplate, 

1087 backgroundModel=(kernelResult.backgroundModel 

1088 if self.config.doSubtractBackground else None)) 

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

1090 kernelResult.psfMatchingKernel, 

1091 templateMatched=True, preConvMode=True, 

1092 preConvKernel=preConvKernel) 

1093 

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

1095 matchedTemplate=matchedTemplate, 

1096 matchedScience=matchedScience, 

1097 backgroundModel=kernelResult.backgroundModel, 

1098 psfMatchingKernel=kernelResult.psfMatchingKernel) 

1099 

1100 

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

1102 exceptionMessage=""): 

1103 """Raise NoWorkFound if template coverage < requiredTemplateFraction 

1104 

1105 Parameters 

1106 ---------- 

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

1108 The template exposure to check 

1109 logger : `lsst.log.Log` 

1110 Logger for printing output. 

1111 requiredTemplateFraction : `float`, optional 

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

1113 in the template. 

1114 exceptionMessage : `str`, optional 

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

1116 is insufficient. 

1117 

1118 Returns 

1119 ------- 

1120 templateCoverageFraction: `float` 

1121 Fraction of pixels in the template with data. 

1122 

1123 Raises 

1124 ------ 

1125 lsst.pipe.base.NoWorkFound 

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

1127 set, is less than the requiredTemplateFraction 

1128 """ 

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

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

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

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

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

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

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

1136 

1137 if templateCoverageFraction < requiredTemplateFraction: 

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

1139 100*templateCoverageFraction, 

1140 100*requiredTemplateFraction)) 

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

1142 return templateCoverageFraction 

1143 

1144 

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

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

1147 

1148 Parameters 

1149 ---------- 

1150 science : `lsst.afw.Exposure` 

1151 The input science image. 

1152 template : `lsst.afw.Exposure` 

1153 The template to subtract from the science image. 

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

1155 Differential background model 

1156 

1157 Returns 

1158 ------- 

1159 difference : `lsst.afw.Exposure` 

1160 The subtracted image. 

1161 """ 

1162 difference = science.clone() 

1163 if backgroundModel is not None: 

1164 difference.maskedImage -= backgroundModel 

1165 difference.maskedImage -= template.maskedImage 

1166 return difference 

1167 

1168 

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

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

1171 

1172 Parameters 

1173 ---------- 

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

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

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

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

1178 fwhmExposureBuffer : `float` 

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

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

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

1182 fwhmExposureGrid : `int` 

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

1184 available at its average position. 

1185 Returns 

1186 ------- 

1187 result : `bool` 

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

1189 either dimension. 

1190 """ 

1191 try: 

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

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

1194 except lsst.pex.exceptions.InvalidParameterError: 

1195 shape1 = evaluateMeanPsfFwhm(exp1, 

1196 fwhmExposureBuffer=fwhmExposureBuffer, 

1197 fwhmExposureGrid=fwhmExposureGrid 

1198 ) 

1199 shape2 = evaluateMeanPsfFwhm(exp2, 

1200 fwhmExposureBuffer=fwhmExposureBuffer, 

1201 fwhmExposureGrid=fwhmExposureGrid 

1202 ) 

1203 return shape1 <= shape2 

1204 

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

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

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

1208 return xTest | yTest 

1209 

1210 

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

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

1213 

1214 Parameters 

1215 ---------- 

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

1217 Image on which to perform interpolation. 

1218 badMaskPlanes : `list` of `str` 

1219 List of mask planes to interpolate over. 

1220 fallbackValue : `float`, optional 

1221 Value to set when interpolation fails. 

1222 

1223 Returns 

1224 ------- 

1225 result: `float` 

1226 The number of masked pixels that were replaced. 

1227 """ 

1228 imgBadMaskPlanes = [ 

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

1230 ] 

1231 

1232 image = maskedImage.image.array 

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

1234 image[badPixels] = np.nan 

1235 if fallbackValue is None: 

1236 fallbackValue = np.nanmedian(image) 

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

1238 # the median value. 

1239 image[badPixels] = fallbackValue 

1240 return np.sum(badPixels)