Coverage for python/lsst/ip/diffim/subtractImages.py: 26%
180 statements
« prev ^ index » next coverage.py v6.4.4, created at 2022-08-20 02:24 -0700
« prev ^ index » next coverage.py v6.4.4, created at 2022-08-20 02:24 -0700
1# This file is part of ip_diffim.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22import numpy as np
24import lsst.afw.image
25import lsst.afw.math
26import lsst.geom
27from lsst.ip.diffim.utils import getPsfFwhm
28from lsst.meas.algorithms import ScaleVarianceTask
29import lsst.pex.config
30import lsst.pipe.base
31from lsst.pipe.base import connectionTypes
32from . import MakeKernelTask, DecorrelateALKernelTask
33from lsst.utils.timer import timeMethod
35__all__ = ["AlardLuptonSubtractConfig", "AlardLuptonSubtractTask"]
37_dimensions = ("instrument", "visit", "detector")
38_defaultTemplates = {"coaddName": "deep", "fakesType": ""}
41class SubtractInputConnections(lsst.pipe.base.PipelineTaskConnections,
42 dimensions=_dimensions,
43 defaultTemplates=_defaultTemplates):
44 template = connectionTypes.Input(
45 doc="Input warped template to subtract.",
46 dimensions=("instrument", "visit", "detector"),
47 storageClass="ExposureF",
48 name="{fakesType}{coaddName}Diff_templateExp"
49 )
50 science = connectionTypes.Input(
51 doc="Input science exposure to subtract from.",
52 dimensions=("instrument", "visit", "detector"),
53 storageClass="ExposureF",
54 name="{fakesType}calexp"
55 )
56 sources = connectionTypes.Input(
57 doc="Sources measured on the science exposure; "
58 "used to select sources for making the matching kernel.",
59 dimensions=("instrument", "visit", "detector"),
60 storageClass="SourceCatalog",
61 name="{fakesType}src"
62 )
63 finalizedPsfApCorrCatalog = connectionTypes.Input(
64 doc=("Per-visit finalized psf models and aperture correction maps. "
65 "These catalogs use the detector id for the catalog id, "
66 "sorted on id for fast lookup."),
67 dimensions=("instrument", "visit"),
68 storageClass="ExposureCatalog",
69 name="finalized_psf_ap_corr_catalog",
70 )
73class SubtractImageOutputConnections(lsst.pipe.base.PipelineTaskConnections,
74 dimensions=_dimensions,
75 defaultTemplates=_defaultTemplates):
76 difference = connectionTypes.Output(
77 doc="Result of subtracting convolved template from science image.",
78 dimensions=("instrument", "visit", "detector"),
79 storageClass="ExposureF",
80 name="{fakesType}{coaddName}Diff_differenceTempExp",
81 )
82 matchedTemplate = connectionTypes.Output(
83 doc="Warped and PSF-matched template used to create `subtractedExposure`.",
84 dimensions=("instrument", "visit", "detector"),
85 storageClass="ExposureF",
86 name="{fakesType}{coaddName}Diff_matchedExp",
87 )
90class AlardLuptonSubtractConnections(SubtractInputConnections, SubtractImageOutputConnections):
92 def __init__(self, *, config=None):
93 super().__init__(config=config)
94 if not config.doApplyFinalizedPsf:
95 self.inputs.remove("finalizedPsfApCorrCatalog")
98class AlardLuptonSubtractConfig(lsst.pipe.base.PipelineTaskConfig,
99 pipelineConnections=AlardLuptonSubtractConnections):
100 mode = lsst.pex.config.ChoiceField(
101 dtype=str,
102 default="convolveTemplate",
103 allowed={"auto": "Choose which image to convolve at runtime.",
104 "convolveScience": "Only convolve the science image.",
105 "convolveTemplate": "Only convolve the template image."},
106 doc="Choose which image to convolve at runtime, or require that a specific image is convolved."
107 )
108 makeKernel = lsst.pex.config.ConfigurableField(
109 target=MakeKernelTask,
110 doc="Task to construct a matching kernel for convolution.",
111 )
112 doDecorrelation = lsst.pex.config.Field(
113 dtype=bool,
114 default=True,
115 doc="Perform diffim decorrelation to undo pixel correlation due to A&L "
116 "kernel convolution? If True, also update the diffim PSF."
117 )
118 decorrelate = lsst.pex.config.ConfigurableField(
119 target=DecorrelateALKernelTask,
120 doc="Task to decorrelate the image difference.",
121 )
122 requiredTemplateFraction = lsst.pex.config.Field(
123 dtype=float,
124 default=0.1,
125 doc="Abort task if template covers less than this fraction of pixels."
126 " Setting to 0 will always attempt image subtraction."
127 )
128 doScaleVariance = lsst.pex.config.Field(
129 dtype=bool,
130 default=True,
131 doc="Scale variance of the image difference?"
132 )
133 scaleVariance = lsst.pex.config.ConfigurableField(
134 target=ScaleVarianceTask,
135 doc="Subtask to rescale the variance of the template to the statistically expected level."
136 )
137 doSubtractBackground = lsst.pex.config.Field(
138 doc="Subtract the background fit when solving the kernel?",
139 dtype=bool,
140 default=True,
141 )
142 doApplyFinalizedPsf = lsst.pex.config.Field(
143 doc="Replace science Exposure's psf and aperture correction map"
144 " with those in finalizedPsfApCorrCatalog.",
145 dtype=bool,
146 default=False,
147 )
149 forceCompatibility = lsst.pex.config.Field(
150 dtype=bool,
151 default=False,
152 doc="Set up and run diffim using settings that ensure the results"
153 "are compatible with the old version in pipe_tasks.",
154 deprecated="This option is only for backwards compatibility purposes"
155 " and will be removed after v24.",
156 )
158 def setDefaults(self):
159 self.makeKernel.kernel.name = "AL"
160 self.makeKernel.kernel.active.fitForBackground = self.doSubtractBackground
161 self.makeKernel.kernel.active.spatialKernelOrder = 1
162 self.makeKernel.kernel.active.spatialBgOrder = 2
164 def validate(self):
165 if self.forceCompatibility and not (self.mode == "convolveTemplate"):
166 msg = f"forceCompatibility=True requires mode='convolveTemplate', but mode was '{self.mode}'."
167 raise lsst.pex.config.FieldValidationError(AlardLuptonSubtractConfig.forceCompatibility,
168 self, msg)
171class AlardLuptonSubtractTask(lsst.pipe.base.PipelineTask):
172 """Compute the image difference of a science and template image using
173 the Alard & Lupton (1998) algorithm.
174 """
175 ConfigClass = AlardLuptonSubtractConfig
176 _DefaultName = "alardLuptonSubtract"
178 def __init__(self, **kwargs):
179 super().__init__(**kwargs)
180 self.makeSubtask("decorrelate")
181 self.makeSubtask("makeKernel")
182 if self.config.doScaleVariance:
183 self.makeSubtask("scaleVariance")
185 self.convolutionControl = lsst.afw.math.ConvolutionControl()
186 # Normalization is an extra, unnecessary, calculation and will result
187 # in mis-subtraction of the images if there are calibration errors.
188 self.convolutionControl.setDoNormalize(False)
189 self.convolutionControl.setDoCopyEdge(True)
191 def _applyExternalCalibrations(self, exposure, finalizedPsfApCorrCatalog):
192 """Replace calibrations (psf, and ApCorrMap) on this exposure with external ones.".
194 Parameters
195 ----------
196 exposure : `lsst.afw.image.exposure.Exposure`
197 Input exposure to adjust calibrations.
198 finalizedPsfApCorrCatalog : `lsst.afw.table.ExposureCatalog`
199 Exposure catalog with finalized psf models and aperture correction
200 maps to be applied if config.doApplyFinalizedPsf=True. Catalog uses
201 the detector id for the catalog id, sorted on id for fast lookup.
203 Returns
204 -------
205 exposure : `lsst.afw.image.exposure.Exposure`
206 Exposure with adjusted calibrations.
207 """
208 detectorId = exposure.info.getDetector().getId()
210 row = finalizedPsfApCorrCatalog.find(detectorId)
211 if row is None:
212 self.log.warning("Detector id %s not found in finalizedPsfApCorrCatalog; "
213 "Using original psf.", detectorId)
214 else:
215 psf = row.getPsf()
216 apCorrMap = row.getApCorrMap()
217 if psf is None:
218 self.log.warning("Detector id %s has None for psf in "
219 "finalizedPsfApCorrCatalog; Using original psf and aperture correction.",
220 detectorId)
221 elif apCorrMap is None:
222 self.log.warning("Detector id %s has None for apCorrMap in "
223 "finalizedPsfApCorrCatalog; Using original psf and aperture correction.",
224 detectorId)
225 else:
226 exposure.setPsf(psf)
227 exposure.info.setApCorrMap(apCorrMap)
229 return exposure
231 @timeMethod
232 def run(self, template, science, sources, finalizedPsfApCorrCatalog=None):
233 """PSF match, subtract, and decorrelate two images.
235 Parameters
236 ----------
237 template : `lsst.afw.image.ExposureF`
238 Template exposure, warped to match the science exposure.
239 science : `lsst.afw.image.ExposureF`
240 Science exposure to subtract from the template.
241 sources : `lsst.afw.table.SourceCatalog`
242 Identified sources on the science exposure. This catalog is used to
243 select sources in order to perform the AL PSF matching on stamp
244 images around them.
245 finalizedPsfApCorrCatalog : `lsst.afw.table.ExposureCatalog`, optional
246 Exposure catalog with finalized psf models and aperture correction
247 maps to be applied if config.doApplyFinalizedPsf=True. Catalog uses
248 the detector id for the catalog id, sorted on id for fast lookup.
250 Returns
251 -------
252 results : `lsst.pipe.base.Struct`
253 ``difference`` : `lsst.afw.image.ExposureF`
254 Result of subtracting template and science.
255 ``matchedTemplate`` : `lsst.afw.image.ExposureF`
256 Warped and PSF-matched template exposure.
257 ``backgroundModel`` : `lsst.afw.math.Chebyshev1Function2D`
258 Background model that was fit while solving for the PSF-matching kernel
259 ``psfMatchingKernel`` : `lsst.afw.math.Kernel`
260 Kernel used to PSF-match the convolved image.
262 Raises
263 ------
264 RuntimeError
265 If an unsupported convolution mode is supplied.
266 lsst.pipe.base.NoWorkFound
267 Raised if fraction of good pixels, defined as not having NO_DATA
268 set, is less then the configured requiredTemplateFraction
269 """
270 self._validateExposures(template, science)
271 if self.config.doApplyFinalizedPsf:
272 self._applyExternalCalibrations(science,
273 finalizedPsfApCorrCatalog=finalizedPsfApCorrCatalog)
274 checkTemplateIsSufficient(template, self.log,
275 requiredTemplateFraction=self.config.requiredTemplateFraction)
276 if self.config.forceCompatibility:
277 # Compatibility option to maintain old functionality
278 # This should be removed in the future!
279 self.log.warning("Running with `config.forceCompatibility=True`")
280 sources = None
281 sciencePsfSize = getPsfFwhm(science.psf)
282 templatePsfSize = getPsfFwhm(template.psf)
283 self.log.info("Science PSF size: %f", sciencePsfSize)
284 self.log.info("Template PSF size: %f", templatePsfSize)
285 if self.config.mode == "auto":
286 if sciencePsfSize < templatePsfSize:
287 self.log.info("Template PSF size is greater: convolving science image.")
288 convolveTemplate = False
289 else:
290 self.log.info("Science PSF size is greater: convolving template image.")
291 convolveTemplate = True
292 elif self.config.mode == "convolveTemplate":
293 self.log.info("`convolveTemplate` is set: convolving template image.")
294 convolveTemplate = True
295 elif self.config.mode == "convolveScience":
296 self.log.info("`convolveScience` is set: convolving science image.")
297 convolveTemplate = False
298 else:
299 raise RuntimeError("Cannot handle AlardLuptonSubtract mode: %s", self.config.mode)
301 if self.config.doScaleVariance and not self.config.forceCompatibility:
302 # Scale the variance of the template and science images before
303 # convolution, subtraction, or decorrelation so that they have the
304 # correct ratio.
305 templateVarFactor = self.scaleVariance.run(template.maskedImage)
306 sciVarFactor = self.scaleVariance.run(science.maskedImage)
307 self.log.info("Template variance scaling factor: %.2f", templateVarFactor)
308 self.metadata.add("scaleTemplateVarianceFactor", templateVarFactor)
309 self.log.info("Science variance scaling factor: %.2f", sciVarFactor)
310 self.metadata.add("scaleScienceVarianceFactor", sciVarFactor)
312 kernelSources = self.makeKernel.selectKernelSources(template, science,
313 candidateList=sources,
314 preconvolved=False)
315 if convolveTemplate:
316 subtractResults = self.runConvolveTemplate(template, science, kernelSources)
317 else:
318 subtractResults = self.runConvolveScience(template, science, kernelSources)
320 if self.config.doScaleVariance and self.config.forceCompatibility:
321 # The old behavior scaled the variance of the final image difference.
322 diffimVarFactor = self.scaleVariance.run(subtractResults.difference.maskedImage)
323 self.log.info("Diffim variance scaling factor: %.2f", diffimVarFactor)
324 self.metadata.add("scaleDiffimVarianceFactor", diffimVarFactor)
326 return subtractResults
328 def runConvolveTemplate(self, template, science, sources):
329 """Convolve the template image with a PSF-matching kernel and subtract
330 from the science image.
332 Parameters
333 ----------
334 template : `lsst.afw.image.ExposureF`
335 Template exposure, warped to match the science exposure.
336 science : `lsst.afw.image.ExposureF`
337 Science exposure to subtract from the template.
338 sources : `lsst.afw.table.SourceCatalog`
339 Identified sources on the science exposure. This catalog is used to
340 select sources in order to perform the AL PSF matching on stamp
341 images around them.
343 Returns
344 -------
345 results : `lsst.pipe.base.Struct`
347 ``difference`` : `lsst.afw.image.ExposureF`
348 Result of subtracting template and science.
349 ``matchedTemplate`` : `lsst.afw.image.ExposureF`
350 Warped and PSF-matched template exposure.
351 ``backgroundModel`` : `lsst.afw.math.Chebyshev1Function2D`
352 Background model that was fit while solving for the PSF-matching kernel
353 ``psfMatchingKernel`` : `lsst.afw.math.Kernel`
354 Kernel used to PSF-match the template to the science image.
355 """
356 if self.config.forceCompatibility:
357 # Compatibility option to maintain old behavior
358 # This should be removed in the future!
359 template = template[science.getBBox()]
360 kernelResult = self.makeKernel.run(template, science, sources, preconvolved=False)
362 matchedTemplate = self._convolveExposure(template, kernelResult.psfMatchingKernel,
363 self.convolutionControl,
364 bbox=science.getBBox(),
365 psf=science.psf,
366 photoCalib=science.getPhotoCalib())
367 difference = _subtractImages(science, matchedTemplate,
368 backgroundModel=(kernelResult.backgroundModel
369 if self.config.doSubtractBackground else None))
370 correctedExposure = self.finalize(template, science, difference, kernelResult.psfMatchingKernel,
371 templateMatched=True)
373 return lsst.pipe.base.Struct(difference=correctedExposure,
374 matchedTemplate=matchedTemplate,
375 matchedScience=science,
376 backgroundModel=kernelResult.backgroundModel,
377 psfMatchingKernel=kernelResult.psfMatchingKernel)
379 def runConvolveScience(self, template, science, sources):
380 """Convolve the science image with a PSF-matching kernel and subtract the template image.
382 Parameters
383 ----------
384 template : `lsst.afw.image.ExposureF`
385 Template exposure, warped to match the science exposure.
386 science : `lsst.afw.image.ExposureF`
387 Science exposure to subtract from the template.
388 sources : `lsst.afw.table.SourceCatalog`
389 Identified sources on the science exposure. This catalog is used to
390 select sources in order to perform the AL PSF matching on stamp
391 images around them.
393 Returns
394 -------
395 results : `lsst.pipe.base.Struct`
397 ``difference`` : `lsst.afw.image.ExposureF`
398 Result of subtracting template and science.
399 ``matchedTemplate`` : `lsst.afw.image.ExposureF`
400 Warped template exposure. Note that in this case, the template
401 is not PSF-matched to the science image.
402 ``backgroundModel`` : `lsst.afw.math.Chebyshev1Function2D`
403 Background model that was fit while solving for the PSF-matching kernel
404 ``psfMatchingKernel`` : `lsst.afw.math.Kernel`
405 Kernel used to PSF-match the science image to the template.
406 """
407 if self.config.forceCompatibility:
408 # Compatibility option to maintain old behavior
409 # This should be removed in the future!
410 template = template[science.getBBox()]
411 kernelResult = self.makeKernel.run(science, template, sources, preconvolved=False)
412 modelParams = kernelResult.backgroundModel.getParameters()
413 # We must invert the background model if the matching kernel is solved for the science image.
414 kernelResult.backgroundModel.setParameters([-p for p in modelParams])
416 kernelImage = lsst.afw.image.ImageD(kernelResult.psfMatchingKernel.getDimensions())
417 norm = kernelResult.psfMatchingKernel.computeImage(kernelImage, doNormalize=False)
419 matchedScience = self._convolveExposure(science, kernelResult.psfMatchingKernel,
420 self.convolutionControl,
421 psf=template.psf)
423 # Place back on native photometric scale
424 matchedScience.maskedImage /= norm
425 matchedTemplate = template.clone()[science.getBBox()]
426 matchedTemplate.maskedImage /= norm
427 matchedTemplate.setPhotoCalib(science.getPhotoCalib())
429 difference = _subtractImages(matchedScience, matchedTemplate,
430 backgroundModel=(kernelResult.backgroundModel
431 if self.config.doSubtractBackground else None))
433 correctedExposure = self.finalize(template, science, difference, kernelResult.psfMatchingKernel,
434 templateMatched=False)
436 return lsst.pipe.base.Struct(difference=correctedExposure,
437 matchedTemplate=matchedTemplate,
438 matchedScience=matchedScience,
439 backgroundModel=kernelResult.backgroundModel,
440 psfMatchingKernel=kernelResult.psfMatchingKernel,)
442 def finalize(self, template, science, difference, kernel,
443 templateMatched=True,
444 preConvMode=False,
445 preConvKernel=None,
446 spatiallyVarying=False):
447 """Decorrelate the difference image to undo the noise correlations
448 caused by convolution.
450 Parameters
451 ----------
452 template : `lsst.afw.image.ExposureF`
453 Template exposure, warped to match the science exposure.
454 science : `lsst.afw.image.ExposureF`
455 Science exposure to subtract from the template.
456 difference : `lsst.afw.image.ExposureF`
457 Result of subtracting template and science.
458 kernel : `lsst.afw.math.Kernel`
459 An (optionally spatially-varying) PSF matching kernel
460 templateMatched : `bool`, optional
461 Was the template PSF-matched to the science image?
462 preConvMode : `bool`, optional
463 Was the science image preconvolved with its own PSF
464 before PSF matching the template?
465 preConvKernel : `lsst.afw.detection.Psf`, optional
466 If not `None`, then the science image was pre-convolved with
467 (the reflection of) this kernel. Must be normalized to sum to 1.
468 spatiallyVarying : `bool`, optional
469 Compute the decorrelation kernel spatially varying across the image?
471 Returns
472 -------
473 correctedExposure : `lsst.afw.image.ExposureF`
474 The decorrelated image difference.
475 """
476 # Erase existing detection mask planes.
477 # We don't want the detection mask from the science image
478 mask = difference.mask
479 mask &= ~(mask.getPlaneBitMask("DETECTED") | mask.getPlaneBitMask("DETECTED_NEGATIVE"))
481 if self.config.doDecorrelation:
482 self.log.info("Decorrelating image difference.")
483 correctedExposure = self.decorrelate.run(science, template[science.getBBox()], difference, kernel,
484 templateMatched=templateMatched,
485 preConvMode=preConvMode,
486 preConvKernel=preConvKernel,
487 spatiallyVarying=spatiallyVarying).correctedExposure
488 else:
489 self.log.info("NOT decorrelating image difference.")
490 correctedExposure = difference
491 return correctedExposure
493 @staticmethod
494 def _validateExposures(template, science):
495 """Check that the WCS of the two Exposures match, and the template bbox
496 contains the science bbox.
498 Parameters
499 ----------
500 template : `lsst.afw.image.ExposureF`
501 Template exposure, warped to match the science exposure.
502 science : `lsst.afw.image.ExposureF`
503 Science exposure to subtract from the template.
505 Raises
506 ------
507 AssertionError
508 Raised if the WCS of the template is not equal to the science WCS,
509 or if the science image is not fully contained in the template
510 bounding box.
511 """
512 assert template.wcs == science.wcs,\
513 "Template and science exposure WCS are not identical."
514 templateBBox = template.getBBox()
515 scienceBBox = science.getBBox()
517 assert templateBBox.contains(scienceBBox),\
518 "Template bbox does not contain all of the science image."
520 @staticmethod
521 def _convolveExposure(exposure, kernel, convolutionControl,
522 bbox=None,
523 psf=None,
524 photoCalib=None):
525 """Convolve an exposure with the given kernel.
527 Parameters
528 ----------
529 exposure : `lsst.afw.Exposure`
530 exposure to convolve.
531 kernel : `lsst.afw.math.LinearCombinationKernel`
532 PSF matching kernel computed in the ``makeKernel`` subtask.
533 convolutionControl : `lsst.afw.math.ConvolutionControl`
534 Configuration for convolve algorithm.
535 bbox : `lsst.geom.Box2I`, optional
536 Bounding box to trim the convolved exposure to.
537 psf : `lsst.afw.detection.Psf`, optional
538 Point spread function (PSF) to set for the convolved exposure.
539 photoCalib : `lsst.afw.image.PhotoCalib`, optional
540 Photometric calibration of the convolved exposure.
542 Returns
543 -------
544 convolvedExp : `lsst.afw.Exposure`
545 The convolved image.
546 """
547 convolvedExposure = exposure.clone()
548 if psf is not None:
549 convolvedExposure.setPsf(psf)
550 if photoCalib is not None:
551 convolvedExposure.setPhotoCalib(photoCalib)
552 convolvedImage = lsst.afw.image.MaskedImageF(exposure.getBBox())
553 lsst.afw.math.convolve(convolvedImage, exposure.maskedImage, kernel, convolutionControl)
554 convolvedExposure.setMaskedImage(convolvedImage)
555 if bbox is None:
556 return convolvedExposure
557 else:
558 return convolvedExposure[bbox]
561def checkTemplateIsSufficient(templateExposure, logger, requiredTemplateFraction=0.):
562 """Raise NoWorkFound if template coverage < requiredTemplateFraction
564 Parameters
565 ----------
566 templateExposure : `lsst.afw.image.ExposureF`
567 The template exposure to check
568 logger : `lsst.log.Log`
569 Logger for printing output.
570 requiredTemplateFraction : `float`, optional
571 Fraction of pixels of the science image required to have coverage
572 in the template.
574 Raises
575 ------
576 lsst.pipe.base.NoWorkFound
577 Raised if fraction of good pixels, defined as not having NO_DATA
578 set, is less then the configured requiredTemplateFraction
579 """
580 # Count the number of pixels with the NO_DATA mask bit set
581 # counting NaN pixels is insufficient because pixels without data are often intepolated over)
582 pixNoData = np.count_nonzero(templateExposure.mask.array
583 & templateExposure.mask.getPlaneBitMask('NO_DATA'))
584 pixGood = templateExposure.getBBox().getArea() - pixNoData
585 logger.info("template has %d good pixels (%.1f%%)", pixGood,
586 100*pixGood/templateExposure.getBBox().getArea())
588 if pixGood/templateExposure.getBBox().getArea() < requiredTemplateFraction:
589 message = ("Insufficient Template Coverage. (%.1f%% < %.1f%%) Not attempting subtraction. "
590 "To force subtraction, set config requiredTemplateFraction=0." % (
591 100*pixGood/templateExposure.getBBox().getArea(),
592 100*requiredTemplateFraction))
593 raise lsst.pipe.base.NoWorkFound(message)
596def _subtractImages(science, template, backgroundModel=None):
597 """Subtract template from science, propagating relevant metadata.
599 Parameters
600 ----------
601 science : `lsst.afw.Exposure`
602 The input science image.
603 template : `lsst.afw.Exposure`
604 The template to subtract from the science image.
605 backgroundModel : `lsst.afw.MaskedImage`, optional
606 Differential background model
608 Returns
609 -------
610 difference : `lsst.afw.Exposure`
611 The subtracted image.
612 """
613 difference = science.clone()
614 if backgroundModel is not None:
615 difference.maskedImage -= backgroundModel
616 difference.maskedImage -= template.maskedImage
617 return difference