Coverage for python/lsst/ip/diffim/subtractImages.py: 23%
333 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-15 03:21 -0700
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-15 03:21 -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 warnings
24import numpy as np
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
39__all__ = ["AlardLuptonSubtractConfig", "AlardLuptonSubtractTask",
40 "AlardLuptonPreconvolveSubtractConfig", "AlardLuptonPreconvolveSubtractTask"]
42_dimensions = ("instrument", "visit", "detector")
43_defaultTemplates = {"coaddName": "deep", "fakesType": ""}
46class SubtractInputConnections(lsst.pipe.base.PipelineTaskConnections,
47 dimensions=_dimensions,
48 defaultTemplates=_defaultTemplates):
49 template = connectionTypes.Input(
50 doc="Input warped template to subtract.",
51 dimensions=("instrument", "visit", "detector"),
52 storageClass="ExposureF",
53 name="{fakesType}{coaddName}Diff_templateExp"
54 )
55 science = connectionTypes.Input(
56 doc="Input science exposure to subtract from.",
57 dimensions=("instrument", "visit", "detector"),
58 storageClass="ExposureF",
59 name="{fakesType}calexp"
60 )
61 sources = connectionTypes.Input(
62 doc="Sources measured on the science exposure; "
63 "used to select sources for making the matching kernel.",
64 dimensions=("instrument", "visit", "detector"),
65 storageClass="SourceCatalog",
66 name="{fakesType}src"
67 )
68 visitSummary = connectionTypes.Input(
69 doc=("Per-visit catalog with final calibration objects. "
70 "These catalogs use the detector id for the catalog id, "
71 "sorted on id for fast lookup."),
72 dimensions=("instrument", "visit"),
73 storageClass="ExposureCatalog",
74 name="finalVisitSummary",
75 )
77 def __init__(self, *, config=None):
78 super().__init__(config=config)
79 if not config.doApplyExternalCalibrations:
80 del self.visitSummary
83class SubtractImageOutputConnections(lsst.pipe.base.PipelineTaskConnections,
84 dimensions=_dimensions,
85 defaultTemplates=_defaultTemplates):
86 difference = connectionTypes.Output(
87 doc="Result of subtracting convolved template from science image.",
88 dimensions=("instrument", "visit", "detector"),
89 storageClass="ExposureF",
90 name="{fakesType}{coaddName}Diff_differenceTempExp",
91 )
92 matchedTemplate = connectionTypes.Output(
93 doc="Warped and PSF-matched template used to create `subtractedExposure`.",
94 dimensions=("instrument", "visit", "detector"),
95 storageClass="ExposureF",
96 name="{fakesType}{coaddName}Diff_matchedExp",
97 )
100class SubtractScoreOutputConnections(lsst.pipe.base.PipelineTaskConnections,
101 dimensions=_dimensions,
102 defaultTemplates=_defaultTemplates):
103 scoreExposure = connectionTypes.Output(
104 doc="The maximum likelihood image, used for the detection of diaSources.",
105 dimensions=("instrument", "visit", "detector"),
106 storageClass="ExposureF",
107 name="{fakesType}{coaddName}Diff_scoreExp",
108 )
111class AlardLuptonSubtractConnections(SubtractInputConnections, SubtractImageOutputConnections):
112 pass
115class AlardLuptonSubtractBaseConfig(lsst.pex.config.Config):
116 makeKernel = lsst.pex.config.ConfigurableField(
117 target=MakeKernelTask,
118 doc="Task to construct a matching kernel for convolution.",
119 )
120 doDecorrelation = lsst.pex.config.Field(
121 dtype=bool,
122 default=True,
123 doc="Perform diffim decorrelation to undo pixel correlation due to A&L "
124 "kernel convolution? If True, also update the diffim PSF."
125 )
126 decorrelate = lsst.pex.config.ConfigurableField(
127 target=DecorrelateALKernelTask,
128 doc="Task to decorrelate the image difference.",
129 )
130 requiredTemplateFraction = lsst.pex.config.Field(
131 dtype=float,
132 default=0.1,
133 doc="Raise NoWorkFound and do not attempt image subtraction if template covers less than this "
134 " fraction of pixels. Setting to 0 will always attempt image subtraction."
135 )
136 minTemplateFractionForExpectedSuccess = lsst.pex.config.Field(
137 dtype=float,
138 default=0.2,
139 doc="Raise NoWorkFound if PSF-matching fails and template covers less than this fraction of pixels."
140 " If the fraction of pixels covered by the template is less than this value (and greater than"
141 " requiredTemplateFraction) this task is attempted but failure is anticipated and tolerated."
142 )
143 doScaleVariance = lsst.pex.config.Field(
144 dtype=bool,
145 default=True,
146 doc="Scale variance of the image difference?"
147 )
148 scaleVariance = lsst.pex.config.ConfigurableField(
149 target=ScaleVarianceTask,
150 doc="Subtask to rescale the variance of the template to the statistically expected level."
151 )
152 doSubtractBackground = lsst.pex.config.Field(
153 doc="Subtract the background fit when solving the kernel?",
154 dtype=bool,
155 default=True,
156 )
157 doApplyExternalCalibrations = lsst.pex.config.Field(
158 doc=(
159 "Replace science Exposure's calibration objects with those"
160 " in visitSummary. Ignored if `doApplyFinalizedPsf is True."
161 ),
162 dtype=bool,
163 default=False,
164 )
165 detectionThreshold = lsst.pex.config.Field(
166 dtype=float,
167 default=10,
168 doc="Minimum signal to noise ratio of detected sources "
169 "to use for calculating the PSF matching kernel."
170 )
171 detectionThresholdMax = lsst.pex.config.Field(
172 dtype=float,
173 default=500,
174 doc="Maximum signal to noise ratio of detected sources "
175 "to use for calculating the PSF matching kernel."
176 )
177 maxKernelSources = lsst.pex.config.Field(
178 dtype=int,
179 default=1000,
180 doc="Maximum number of sources to use for calculating the PSF matching kernel."
181 "Set to -1 to disable."
182 )
183 minKernelSources = lsst.pex.config.Field(
184 dtype=int,
185 default=3,
186 doc="Minimum number of sources needed for calculating the PSF matching kernel."
187 )
188 badSourceFlags = lsst.pex.config.ListField(
189 dtype=str,
190 doc="Flags that, if set, the associated source should not "
191 "be used to determine the PSF matching kernel.",
192 default=("sky_source", "slot_Centroid_flag",
193 "slot_ApFlux_flag", "slot_PsfFlux_flag",
194 "base_PixelFlags_flag_interpolated",
195 "base_PixelFlags_flag_saturated",
196 "base_PixelFlags_flag_bad",
197 ),
198 )
199 excludeMaskPlanes = lsst.pex.config.ListField(
200 dtype=str,
201 default=("NO_DATA", "BAD", "SAT", "EDGE", "FAKE"),
202 doc="Mask planes to exclude when selecting sources for PSF matching."
203 )
204 badMaskPlanes = lsst.pex.config.ListField(
205 dtype=str,
206 default=("NO_DATA", "BAD", "SAT", "EDGE"),
207 doc="Mask planes to interpolate over."
208 )
209 preserveTemplateMask = lsst.pex.config.ListField(
210 dtype=str,
211 default=("NO_DATA", "BAD",),
212 doc="Mask planes from the template to propagate to the image difference."
213 )
214 renameTemplateMask = lsst.pex.config.ListField(
215 dtype=str,
216 default=("SAT", "INJECTED", "INJECTED_CORE",),
217 doc="Mask planes from the template to propagate to the image difference"
218 "with '_TEMPLATE' appended to the name."
219 )
220 allowKernelSourceDetection = lsst.pex.config.Field(
221 dtype=bool,
222 default=False,
223 doc="Re-run source detection for kernel candidates if an error is"
224 " encountered while calculating the matching kernel."
225 )
227 def setDefaults(self):
228 self.makeKernel.kernel.name = "AL"
229 self.makeKernel.kernel.active.fitForBackground = self.doSubtractBackground
230 self.makeKernel.kernel.active.spatialKernelOrder = 1
231 self.makeKernel.kernel.active.spatialBgOrder = 2
234class AlardLuptonSubtractConfig(AlardLuptonSubtractBaseConfig, lsst.pipe.base.PipelineTaskConfig,
235 pipelineConnections=AlardLuptonSubtractConnections):
236 mode = lsst.pex.config.ChoiceField(
237 dtype=str,
238 default="convolveTemplate",
239 allowed={"auto": "Choose which image to convolve at runtime.",
240 "convolveScience": "Only convolve the science image.",
241 "convolveTemplate": "Only convolve the template image."},
242 doc="Choose which image to convolve at runtime, or require that a specific image is convolved."
243 )
246class AlardLuptonSubtractTask(lsst.pipe.base.PipelineTask):
247 """Compute the image difference of a science and template image using
248 the Alard & Lupton (1998) algorithm.
249 """
250 ConfigClass = AlardLuptonSubtractConfig
251 _DefaultName = "alardLuptonSubtract"
253 def __init__(self, **kwargs):
254 super().__init__(**kwargs)
255 self.makeSubtask("decorrelate")
256 self.makeSubtask("makeKernel")
257 if self.config.doScaleVariance:
258 self.makeSubtask("scaleVariance")
260 self.convolutionControl = lsst.afw.math.ConvolutionControl()
261 # Normalization is an extra, unnecessary, calculation and will result
262 # in mis-subtraction of the images if there are calibration errors.
263 self.convolutionControl.setDoNormalize(False)
264 self.convolutionControl.setDoCopyEdge(True)
266 def _applyExternalCalibrations(self, exposure, visitSummary):
267 """Replace calibrations (psf, and ApCorrMap) on this exposure with
268 external ones.".
270 Parameters
271 ----------
272 exposure : `lsst.afw.image.exposure.Exposure`
273 Input exposure to adjust calibrations.
274 visitSummary : `lsst.afw.table.ExposureCatalog`
275 Exposure catalog with external calibrations to be applied. Catalog
276 uses the detector id for the catalog id, sorted on id for fast
277 lookup.
279 Returns
280 -------
281 exposure : `lsst.afw.image.exposure.Exposure`
282 Exposure with adjusted calibrations.
283 """
284 detectorId = exposure.info.getDetector().getId()
286 row = visitSummary.find(detectorId)
287 if row is None:
288 self.log.warning("Detector id %s not found in external calibrations catalog; "
289 "Using original calibrations.", detectorId)
290 else:
291 psf = row.getPsf()
292 apCorrMap = row.getApCorrMap()
293 if psf is None:
294 self.log.warning("Detector id %s has None for psf in "
295 "external calibrations catalog; Using original psf and aperture correction.",
296 detectorId)
297 elif apCorrMap is None:
298 self.log.warning("Detector id %s has None for apCorrMap in "
299 "external calibrations catalog; Using original psf and aperture correction.",
300 detectorId)
301 else:
302 exposure.setPsf(psf)
303 exposure.info.setApCorrMap(apCorrMap)
305 return exposure
307 @timeMethod
308 def run(self, template, science, sources, finalizedPsfApCorrCatalog=None,
309 visitSummary=None):
310 """PSF match, subtract, and decorrelate two images.
312 Parameters
313 ----------
314 template : `lsst.afw.image.ExposureF`
315 Template exposure, warped to match the science exposure.
316 science : `lsst.afw.image.ExposureF`
317 Science exposure to subtract from the template.
318 sources : `lsst.afw.table.SourceCatalog`
319 Identified sources on the science exposure. This catalog is used to
320 select sources in order to perform the AL PSF matching on stamp
321 images around them.
322 finalizedPsfApCorrCatalog : `lsst.afw.table.ExposureCatalog`, optional
323 Exposure catalog with finalized psf models and aperture correction
324 maps to be applied. Catalog uses the detector id for the catalog
325 id, sorted on id for fast lookup. Deprecated in favor of
326 ``visitSummary``, and will be removed after v26.
327 visitSummary : `lsst.afw.table.ExposureCatalog`, optional
328 Exposure catalog with external calibrations to be applied. Catalog
329 uses the detector id for the catalog id, sorted on id for fast
330 lookup. Ignored (for temporary backwards compatibility) if
331 ``finalizedPsfApCorrCatalog`` is provided.
333 Returns
334 -------
335 results : `lsst.pipe.base.Struct`
336 ``difference`` : `lsst.afw.image.ExposureF`
337 Result of subtracting template and science.
338 ``matchedTemplate`` : `lsst.afw.image.ExposureF`
339 Warped and PSF-matched template exposure.
340 ``backgroundModel`` : `lsst.afw.math.Function2D`
341 Background model that was fit while solving for the
342 PSF-matching kernel
343 ``psfMatchingKernel`` : `lsst.afw.math.Kernel`
344 Kernel used to PSF-match the convolved image.
346 Raises
347 ------
348 RuntimeError
349 If an unsupported convolution mode is supplied.
350 RuntimeError
351 If there are too few sources to calculate the PSF matching kernel.
352 lsst.pipe.base.NoWorkFound
353 Raised if fraction of good pixels, defined as not having NO_DATA
354 set, is less then the configured requiredTemplateFraction
355 """
357 if finalizedPsfApCorrCatalog is not None:
358 warnings.warn(
359 "The finalizedPsfApCorrCatalog argument is deprecated in favor of the visitSummary "
360 "argument, and will be removed after v26.",
361 FutureWarning,
362 stacklevel=find_outside_stacklevel("lsst.ip.diffim"),
363 )
364 visitSummary = finalizedPsfApCorrCatalog
366 self._prepareInputs(template, science, visitSummary=visitSummary)
368 # In the event that getPsfFwhm fails, evaluate the PSF on a grid.
369 fwhmExposureBuffer = self.config.makeKernel.fwhmExposureBuffer
370 fwhmExposureGrid = self.config.makeKernel.fwhmExposureGrid
372 # Calling getPsfFwhm on template.psf fails on some rare occasions when
373 # the template has no input exposures at the average position of the
374 # stars. So we try getPsfFwhm first on template, and if that fails we
375 # evaluate the PSF on a grid specified by fwhmExposure* fields.
376 # To keep consistent definitions for PSF size on the template and
377 # science images, we use the same method for both.
378 try:
379 templatePsfSize = getPsfFwhm(template.psf)
380 sciencePsfSize = getPsfFwhm(science.psf)
381 except lsst.pex.exceptions.InvalidParameterError:
382 self.log.info("Unable to evaluate PSF at the average position. "
383 "Evaluting PSF on a grid of points."
384 )
385 templatePsfSize = evaluateMeanPsfFwhm(template,
386 fwhmExposureBuffer=fwhmExposureBuffer,
387 fwhmExposureGrid=fwhmExposureGrid
388 )
389 sciencePsfSize = evaluateMeanPsfFwhm(science,
390 fwhmExposureBuffer=fwhmExposureBuffer,
391 fwhmExposureGrid=fwhmExposureGrid
392 )
393 self.log.info("Science PSF FWHM: %f pixels", sciencePsfSize)
394 self.log.info("Template PSF FWHM: %f pixels", templatePsfSize)
395 self.metadata.add("sciencePsfSize", sciencePsfSize)
396 self.metadata.add("templatePsfSize", templatePsfSize)
398 if self.config.mode == "auto":
399 convolveTemplate = _shapeTest(template,
400 science,
401 fwhmExposureBuffer=fwhmExposureBuffer,
402 fwhmExposureGrid=fwhmExposureGrid)
403 if convolveTemplate:
404 if sciencePsfSize < templatePsfSize:
405 self.log.info("Average template PSF size is greater, "
406 "but science PSF greater in one dimension: convolving template image.")
407 else:
408 self.log.info("Science PSF size is greater: convolving template image.")
409 else:
410 self.log.info("Template PSF size is greater: convolving science image.")
411 elif self.config.mode == "convolveTemplate":
412 self.log.info("`convolveTemplate` is set: convolving template image.")
413 convolveTemplate = True
414 elif self.config.mode == "convolveScience":
415 self.log.info("`convolveScience` is set: convolving science image.")
416 convolveTemplate = False
417 else:
418 raise RuntimeError("Cannot handle AlardLuptonSubtract mode: %s", self.config.mode)
420 try:
421 sourceMask = science.mask.clone()
422 sourceMask.array |= template[science.getBBox()].mask.array
423 selectSources = self._sourceSelector(sources, sourceMask)
424 if convolveTemplate:
425 self.metadata.add("convolvedExposure", "Template")
426 subtractResults = self.runConvolveTemplate(template, science, selectSources)
427 else:
428 self.metadata.add("convolvedExposure", "Science")
429 subtractResults = self.runConvolveScience(template, science, selectSources)
431 except (RuntimeError, lsst.pex.exceptions.Exception) as e:
432 self.log.warning("Failed to match template. Checking coverage")
433 # Raise NoWorkFound if template fraction is insufficient
434 checkTemplateIsSufficient(template[science.getBBox()], self.log,
435 self.config.minTemplateFractionForExpectedSuccess,
436 exceptionMessage="Template coverage lower than expected to succeed."
437 f" Failure is tolerable: {e}")
438 # checkTemplateIsSufficient did not raise NoWorkFound, so raise original exception
439 raise e
441 return subtractResults
443 def runConvolveTemplate(self, template, science, selectSources):
444 """Convolve the template image with a PSF-matching kernel and subtract
445 from the science image.
447 Parameters
448 ----------
449 template : `lsst.afw.image.ExposureF`
450 Template exposure, warped to match the science exposure.
451 science : `lsst.afw.image.ExposureF`
452 Science exposure to subtract from the template.
453 selectSources : `lsst.afw.table.SourceCatalog`
454 Identified sources on the science exposure. This catalog is used to
455 select sources in order to perform the AL PSF matching on stamp
456 images around them.
458 Returns
459 -------
460 results : `lsst.pipe.base.Struct`
462 ``difference`` : `lsst.afw.image.ExposureF`
463 Result of subtracting template and science.
464 ``matchedTemplate`` : `lsst.afw.image.ExposureF`
465 Warped and PSF-matched template exposure.
466 ``backgroundModel`` : `lsst.afw.math.Function2D`
467 Background model that was fit while solving for the PSF-matching kernel
468 ``psfMatchingKernel`` : `lsst.afw.math.Kernel`
469 Kernel used to PSF-match the template to the science image.
470 """
471 try:
472 kernelSources = self.makeKernel.selectKernelSources(template, science,
473 candidateList=selectSources,
474 preconvolved=False)
475 kernelResult = self.makeKernel.run(template, science, kernelSources,
476 preconvolved=False)
477 except Exception as e:
478 if self.config.allowKernelSourceDetection:
479 self.log.warning("Error encountered trying to construct the matching kernel"
480 f" Running source detection and retrying. {e}")
481 kernelSources = self.makeKernel.selectKernelSources(template, science,
482 candidateList=None,
483 preconvolved=False)
484 kernelResult = self.makeKernel.run(template, science, kernelSources,
485 preconvolved=False)
486 else:
487 raise e
489 matchedTemplate = self._convolveExposure(template, kernelResult.psfMatchingKernel,
490 self.convolutionControl,
491 bbox=science.getBBox(),
492 psf=science.psf,
493 photoCalib=science.photoCalib)
495 difference = _subtractImages(science, matchedTemplate,
496 backgroundModel=(kernelResult.backgroundModel
497 if self.config.doSubtractBackground else None))
498 correctedExposure = self.finalize(template, science, difference,
499 kernelResult.psfMatchingKernel,
500 templateMatched=True)
502 return lsst.pipe.base.Struct(difference=correctedExposure,
503 matchedTemplate=matchedTemplate,
504 matchedScience=science,
505 backgroundModel=kernelResult.backgroundModel,
506 psfMatchingKernel=kernelResult.psfMatchingKernel)
508 def runConvolveScience(self, template, science, selectSources):
509 """Convolve the science image with a PSF-matching kernel and subtract
510 the template image.
512 Parameters
513 ----------
514 template : `lsst.afw.image.ExposureF`
515 Template exposure, warped to match the science exposure.
516 science : `lsst.afw.image.ExposureF`
517 Science exposure to subtract from the template.
518 selectSources : `lsst.afw.table.SourceCatalog`
519 Identified sources on the science exposure. This catalog is used to
520 select sources in order to perform the AL PSF matching on stamp
521 images around them.
523 Returns
524 -------
525 results : `lsst.pipe.base.Struct`
527 ``difference`` : `lsst.afw.image.ExposureF`
528 Result of subtracting template and science.
529 ``matchedTemplate`` : `lsst.afw.image.ExposureF`
530 Warped template exposure. Note that in this case, the template
531 is not PSF-matched to the science image.
532 ``backgroundModel`` : `lsst.afw.math.Function2D`
533 Background model that was fit while solving for the PSF-matching kernel
534 ``psfMatchingKernel`` : `lsst.afw.math.Kernel`
535 Kernel used to PSF-match the science image to the template.
536 """
537 bbox = science.getBBox()
538 kernelSources = self.makeKernel.selectKernelSources(science, template,
539 candidateList=selectSources,
540 preconvolved=False)
541 kernelResult = self.makeKernel.run(science, template, kernelSources,
542 preconvolved=False)
543 modelParams = kernelResult.backgroundModel.getParameters()
544 # We must invert the background model if the matching kernel is solved for the science image.
545 kernelResult.backgroundModel.setParameters([-p for p in modelParams])
547 kernelImage = lsst.afw.image.ImageD(kernelResult.psfMatchingKernel.getDimensions())
548 norm = kernelResult.psfMatchingKernel.computeImage(kernelImage, doNormalize=False)
550 matchedScience = self._convolveExposure(science, kernelResult.psfMatchingKernel,
551 self.convolutionControl,
552 psf=template.psf)
554 # Place back on native photometric scale
555 matchedScience.maskedImage /= norm
556 matchedTemplate = template.clone()[bbox]
557 matchedTemplate.maskedImage /= norm
558 matchedTemplate.setPhotoCalib(science.photoCalib)
560 difference = _subtractImages(matchedScience, matchedTemplate,
561 backgroundModel=(kernelResult.backgroundModel
562 if self.config.doSubtractBackground else None))
564 correctedExposure = self.finalize(template, science, difference,
565 kernelResult.psfMatchingKernel,
566 templateMatched=False)
568 return lsst.pipe.base.Struct(difference=correctedExposure,
569 matchedTemplate=matchedTemplate,
570 matchedScience=matchedScience,
571 backgroundModel=kernelResult.backgroundModel,
572 psfMatchingKernel=kernelResult.psfMatchingKernel,)
574 def finalize(self, template, science, difference, kernel,
575 templateMatched=True,
576 preConvMode=False,
577 preConvKernel=None,
578 spatiallyVarying=False):
579 """Decorrelate the difference image to undo the noise correlations
580 caused by convolution.
582 Parameters
583 ----------
584 template : `lsst.afw.image.ExposureF`
585 Template exposure, warped to match the science exposure.
586 science : `lsst.afw.image.ExposureF`
587 Science exposure to subtract from the template.
588 difference : `lsst.afw.image.ExposureF`
589 Result of subtracting template and science.
590 kernel : `lsst.afw.math.Kernel`
591 An (optionally spatially-varying) PSF matching kernel
592 templateMatched : `bool`, optional
593 Was the template PSF-matched to the science image?
594 preConvMode : `bool`, optional
595 Was the science image preconvolved with its own PSF
596 before PSF matching the template?
597 preConvKernel : `lsst.afw.detection.Psf`, optional
598 If not `None`, then the science image was pre-convolved with
599 (the reflection of) this kernel. Must be normalized to sum to 1.
600 spatiallyVarying : `bool`, optional
601 Compute the decorrelation kernel spatially varying across the image?
603 Returns
604 -------
605 correctedExposure : `lsst.afw.image.ExposureF`
606 The decorrelated image difference.
607 """
608 if self.config.doDecorrelation:
609 self.log.info("Decorrelating image difference.")
610 # We have cleared the template mask plane, so copy the mask plane of
611 # the image difference so that we can calculate correct statistics
612 # during decorrelation
613 correctedExposure = self.decorrelate.run(science, template[science.getBBox()], difference, kernel,
614 templateMatched=templateMatched,
615 preConvMode=preConvMode,
616 preConvKernel=preConvKernel,
617 spatiallyVarying=spatiallyVarying).correctedExposure
618 else:
619 self.log.info("NOT decorrelating image difference.")
620 correctedExposure = difference
621 return correctedExposure
623 @staticmethod
624 def _validateExposures(template, science):
625 """Check that the WCS of the two Exposures match, and the template bbox
626 contains the science bbox.
628 Parameters
629 ----------
630 template : `lsst.afw.image.ExposureF`
631 Template exposure, warped to match the science exposure.
632 science : `lsst.afw.image.ExposureF`
633 Science exposure to subtract from the template.
635 Raises
636 ------
637 AssertionError
638 Raised if the WCS of the template is not equal to the science WCS,
639 or if the science image is not fully contained in the template
640 bounding box.
641 """
642 assert template.wcs == science.wcs,\
643 "Template and science exposure WCS are not identical."
644 templateBBox = template.getBBox()
645 scienceBBox = science.getBBox()
647 assert templateBBox.contains(scienceBBox),\
648 "Template bbox does not contain all of the science image."
650 def _convolveExposure(self, exposure, kernel, convolutionControl,
651 bbox=None,
652 psf=None,
653 photoCalib=None,
654 interpolateBadMaskPlanes=False,
655 ):
656 """Convolve an exposure with the given kernel.
658 Parameters
659 ----------
660 exposure : `lsst.afw.Exposure`
661 exposure to convolve.
662 kernel : `lsst.afw.math.LinearCombinationKernel`
663 PSF matching kernel computed in the ``makeKernel`` subtask.
664 convolutionControl : `lsst.afw.math.ConvolutionControl`
665 Configuration for convolve algorithm.
666 bbox : `lsst.geom.Box2I`, optional
667 Bounding box to trim the convolved exposure to.
668 psf : `lsst.afw.detection.Psf`, optional
669 Point spread function (PSF) to set for the convolved exposure.
670 photoCalib : `lsst.afw.image.PhotoCalib`, optional
671 Photometric calibration of the convolved exposure.
673 Returns
674 -------
675 convolvedExp : `lsst.afw.Exposure`
676 The convolved image.
677 """
678 convolvedExposure = exposure.clone()
679 if psf is not None:
680 convolvedExposure.setPsf(psf)
681 if photoCalib is not None:
682 convolvedExposure.setPhotoCalib(photoCalib)
683 if interpolateBadMaskPlanes and self.config.badMaskPlanes is not None:
684 nInterp = _interpolateImage(convolvedExposure.maskedImage,
685 self.config.badMaskPlanes)
686 self.metadata.add("nInterpolated", nInterp)
687 convolvedImage = lsst.afw.image.MaskedImageF(convolvedExposure.getBBox())
688 lsst.afw.math.convolve(convolvedImage, convolvedExposure.maskedImage, kernel, convolutionControl)
689 convolvedExposure.setMaskedImage(convolvedImage)
690 if bbox is None:
691 return convolvedExposure
692 else:
693 return convolvedExposure[bbox]
695 def _sourceSelector(self, sources, mask):
696 """Select sources from a catalog that meet the selection criteria.
698 Parameters
699 ----------
700 sources : `lsst.afw.table.SourceCatalog`
701 Input source catalog to select sources from.
702 mask : `lsst.afw.image.Mask`
703 The image mask plane to use to reject sources
704 based on their location on the ccd.
706 Returns
707 -------
708 selectSources : `lsst.afw.table.SourceCatalog`
709 The input source catalog, with flagged and low signal-to-noise
710 sources removed.
712 Raises
713 ------
714 RuntimeError
715 If there are too few sources to compute the PSF matching kernel
716 remaining after source selection.
717 """
718 flags = np.ones(len(sources), dtype=bool)
719 for flag in self.config.badSourceFlags:
720 try:
721 flags *= ~sources[flag]
722 except Exception as e:
723 self.log.warning("Could not apply source flag: %s", e)
724 signalToNoise = sources.getPsfInstFlux()/sources.getPsfInstFluxErr()
725 sToNFlag = signalToNoise > self.config.detectionThreshold
726 flags *= sToNFlag
727 sToNFlagMax = signalToNoise < self.config.detectionThresholdMax
728 flags *= sToNFlagMax
729 flags *= self._checkMask(mask, sources, self.config.excludeMaskPlanes)
730 selectSources = sources[flags].copy(deep=True)
731 if (len(selectSources) > self.config.maxKernelSources) & (self.config.maxKernelSources > 0):
732 signalToNoise = selectSources.getPsfInstFlux()/selectSources.getPsfInstFluxErr()
733 indices = np.argsort(signalToNoise)
734 indices = indices[-self.config.maxKernelSources:]
735 flags = np.zeros(len(selectSources), dtype=bool)
736 flags[indices] = True
737 selectSources = selectSources[flags].copy(deep=True)
739 self.log.info("%i/%i=%.1f%% of sources selected for PSF matching from the input catalog",
740 len(selectSources), len(sources), 100*len(selectSources)/len(sources))
741 if len(selectSources) < self.config.minKernelSources:
742 self.log.error("Too few sources to calculate the PSF matching kernel: "
743 "%i selected but %i needed for the calculation.",
744 len(selectSources), self.config.minKernelSources)
745 raise RuntimeError("Cannot compute PSF matching kernel: too few sources selected.")
746 self.metadata.add("nPsfSources", len(selectSources))
748 return selectSources
750 @staticmethod
751 def _checkMask(mask, sources, excludeMaskPlanes):
752 """Exclude sources that are located on masked pixels.
754 Parameters
755 ----------
756 mask : `lsst.afw.image.Mask`
757 The image mask plane to use to reject sources
758 based on the location of their centroid on the ccd.
759 sources : `lsst.afw.table.SourceCatalog`
760 The source catalog to evaluate.
761 excludeMaskPlanes : `list` of `str`
762 List of the names of the mask planes to exclude.
764 Returns
765 -------
766 flags : `numpy.ndarray` of `bool`
767 Array indicating whether each source in the catalog should be
768 kept (True) or rejected (False) based on the value of the
769 mask plane at its location.
770 """
771 setExcludeMaskPlanes = [
772 maskPlane for maskPlane in excludeMaskPlanes if maskPlane in mask.getMaskPlaneDict()
773 ]
775 excludePixelMask = mask.getPlaneBitMask(setExcludeMaskPlanes)
777 xv = np.rint(sources.getX() - mask.getX0())
778 yv = np.rint(sources.getY() - mask.getY0())
780 mv = mask.array[yv.astype(int), xv.astype(int)]
781 flags = np.bitwise_and(mv, excludePixelMask) == 0
782 return flags
784 def _prepareInputs(self, template, science, visitSummary=None):
785 """Perform preparatory calculations common to all Alard&Lupton Tasks.
787 Parameters
788 ----------
789 template : `lsst.afw.image.ExposureF`
790 Template exposure, warped to match the science exposure. The
791 variance plane of the template image is modified in place.
792 science : `lsst.afw.image.ExposureF`
793 Science exposure to subtract from the template. The variance plane
794 of the science image is modified in place.
795 visitSummary : `lsst.afw.table.ExposureCatalog`, optional
796 Exposure catalog with external calibrations to be applied. Catalog
797 uses the detector id for the catalog id, sorted on id for fast
798 lookup.
799 """
800 self._validateExposures(template, science)
801 if visitSummary is not None:
802 self._applyExternalCalibrations(science, visitSummary=visitSummary)
803 templateCoverageFraction = checkTemplateIsSufficient(
804 template[science.getBBox()], self.log,
805 requiredTemplateFraction=self.config.requiredTemplateFraction,
806 exceptionMessage="Not attempting subtraction. To force subtraction,"
807 " set config requiredTemplateFraction=0"
808 )
809 self.metadata.add("templateCoveragePercent", 100*templateCoverageFraction)
811 if self.config.doScaleVariance:
812 # Scale the variance of the template and science images before
813 # convolution, subtraction, or decorrelation so that they have the
814 # correct ratio.
815 templateVarFactor = self.scaleVariance.run(template.maskedImage)
816 sciVarFactor = self.scaleVariance.run(science.maskedImage)
817 self.log.info("Template variance scaling factor: %.2f", templateVarFactor)
818 self.metadata.add("scaleTemplateVarianceFactor", templateVarFactor)
819 self.log.info("Science variance scaling factor: %.2f", sciVarFactor)
820 self.metadata.add("scaleScienceVarianceFactor", sciVarFactor)
822 # Erase existing detection mask planes.
823 # We don't want the detection mask from the science image
824 self.updateMasks(template, science)
826 def updateMasks(self, template, science):
827 """Update the science and template mask planes before differencing.
829 Parameters
830 ----------
831 template : `lsst.afw.image.Exposure`
832 Template exposure, warped to match the science exposure.
833 The template mask planes will be erased, except for a few specified
834 in the task config.
835 science : `lsst.afw.image.Exposure`
836 Science exposure to subtract from the template.
837 The DETECTED and DETECTED_NEGATIVE mask planes of the science image
838 will be erased.
839 """
840 self._clearMask(science.mask, clearMaskPlanes=["DETECTED", "DETECTED_NEGATIVE"])
842 # We will clear ALL template mask planes, except for those specified
843 # via the `preserveTemplateMask` config. Mask planes specified via
844 # the `renameTemplateMask` config will be copied to new planes with
845 # "_TEMPLATE" appended to their names, and the original mask plane will
846 # be cleared.
847 clearMaskPlanes = [mp for mp in template.mask.getMaskPlaneDict().keys()
848 if mp not in self.config.preserveTemplateMask]
849 renameMaskPlanes = [mp for mp in self.config.renameTemplateMask
850 if mp in template.mask.getMaskPlaneDict().keys()]
852 # propagate the mask plane related to Fake source injection
853 # NOTE: the fake source injection sets FAKE plane, but it should be INJECTED
854 # NOTE: This can be removed in DM-40796
855 if "FAKE" in science.mask.getMaskPlaneDict().keys():
856 self.log.info("Adding injected mask plane to science image")
857 self._renameMaskPlanes(science.mask, "FAKE", "INJECTED")
858 if "FAKE" in template.mask.getMaskPlaneDict().keys():
859 self.log.info("Adding injected mask plane to template image")
860 self._renameMaskPlanes(template.mask, "FAKE", "INJECTED_TEMPLATE")
861 if "INJECTED" in renameMaskPlanes:
862 renameMaskPlanes.remove("INJECTED")
863 if "INJECTED_TEMPLATE" in clearMaskPlanes:
864 clearMaskPlanes.remove("INJECTED_TEMPLATE")
866 for maskPlane in renameMaskPlanes:
867 self._renameMaskPlanes(template.mask, maskPlane, maskPlane + "_TEMPLATE")
868 self._clearMask(template.mask, clearMaskPlanes=clearMaskPlanes)
870 @staticmethod
871 def _renameMaskPlanes(mask, maskPlane, newMaskPlane):
872 """Rename a mask plane by adding the new name and copying the data.
874 Parameters
875 ----------
876 mask : `lsst.afw.image.Mask`
877 The mask image to update in place.
878 maskPlane : `str`
879 The name of the existing mask plane to copy.
880 newMaskPlane : `str`
881 The new name of the mask plane that will be added.
882 If the mask plane already exists, it will be updated in place.
883 """
884 mask.addMaskPlane(newMaskPlane)
885 originBitMask = mask.getPlaneBitMask(maskPlane)
886 destinationBitMask = mask.getPlaneBitMask(newMaskPlane)
887 mask.array |= ((mask.array & originBitMask) > 0)*destinationBitMask
889 def _clearMask(self, mask, clearMaskPlanes=None):
890 """Clear the mask plane of the template.
892 Parameters
893 ----------
894 mask : `lsst.afw.image.Mask`
895 The mask plane to erase, which will be modified in place.
896 clearMaskPlanes : `list` of `str`, optional
897 Erase the specified mask planes.
898 If not supplied, the entire mask will be erased.
899 """
900 if clearMaskPlanes is None:
901 clearMaskPlanes = list(mask.getMaskPlaneDict().keys())
903 bitMaskToClear = mask.getPlaneBitMask(clearMaskPlanes)
904 mask &= ~bitMaskToClear
907class AlardLuptonPreconvolveSubtractConnections(SubtractInputConnections,
908 SubtractScoreOutputConnections):
909 pass
912class AlardLuptonPreconvolveSubtractConfig(AlardLuptonSubtractBaseConfig, lsst.pipe.base.PipelineTaskConfig,
913 pipelineConnections=AlardLuptonPreconvolveSubtractConnections):
914 pass
917class AlardLuptonPreconvolveSubtractTask(AlardLuptonSubtractTask):
918 """Subtract a template from a science image, convolving the science image
919 before computing the kernel, and also convolving the template before
920 subtraction.
921 """
922 ConfigClass = AlardLuptonPreconvolveSubtractConfig
923 _DefaultName = "alardLuptonPreconvolveSubtract"
925 def run(self, template, science, sources, visitSummary=None):
926 """Preconvolve the science image with its own PSF,
927 convolve the template image with a PSF-matching kernel and subtract
928 from the preconvolved science image.
930 Parameters
931 ----------
932 template : `lsst.afw.image.ExposureF`
933 The template image, which has previously been warped to the science
934 image. The template bbox will be padded by a few pixels compared to
935 the science bbox.
936 science : `lsst.afw.image.ExposureF`
937 The science exposure.
938 sources : `lsst.afw.table.SourceCatalog`
939 Identified sources on the science exposure. This catalog is used to
940 select sources in order to perform the AL PSF matching on stamp
941 images around them.
942 visitSummary : `lsst.afw.table.ExposureCatalog`, optional
943 Exposure catalog with complete external calibrations. Catalog uses
944 the detector id for the catalog id, sorted on id for fast lookup.
946 Returns
947 -------
948 results : `lsst.pipe.base.Struct`
949 ``scoreExposure`` : `lsst.afw.image.ExposureF`
950 Result of subtracting the convolved template and science
951 images. Attached PSF is that of the original science image.
952 ``matchedTemplate`` : `lsst.afw.image.ExposureF`
953 Warped and PSF-matched template exposure. Attached PSF is that
954 of the original science image.
955 ``matchedScience`` : `lsst.afw.image.ExposureF`
956 The science exposure after convolving with its own PSF.
957 Attached PSF is that of the original science image.
958 ``backgroundModel`` : `lsst.afw.math.Function2D`
959 Background model that was fit while solving for the
960 PSF-matching kernel
961 ``psfMatchingKernel`` : `lsst.afw.math.Kernel`
962 Final kernel used to PSF-match the template to the science
963 image.
964 """
965 self._prepareInputs(template, science, visitSummary=visitSummary)
967 # TODO: DM-37212 we need to mirror the kernel in order to get correct cross correlation
968 scienceKernel = science.psf.getKernel()
969 matchedScience = self._convolveExposure(science, scienceKernel, self.convolutionControl,
970 interpolateBadMaskPlanes=True)
971 self.metadata.add("convolvedExposure", "Preconvolution")
972 try:
973 selectSources = self._sourceSelector(sources, matchedScience.mask)
974 subtractResults = self.runPreconvolve(template, science, matchedScience,
975 selectSources, scienceKernel)
977 except (RuntimeError, lsst.pex.exceptions.Exception) as e:
978 self.log.warning("Failed to match template. Checking coverage")
979 # Raise NoWorkFound if template fraction is insufficient
980 checkTemplateIsSufficient(template[science.getBBox()], self.log,
981 self.config.minTemplateFractionForExpectedSuccess,
982 exceptionMessage="Template coverage lower than expected to succeed."
983 f" Failure is tolerable: {e}")
984 # checkTemplateIsSufficient did not raise NoWorkFound, so raise original exception
985 raise e
987 return subtractResults
989 def runPreconvolve(self, template, science, matchedScience, selectSources, preConvKernel):
990 """Convolve the science image with its own PSF, then convolve the
991 template with a matching kernel and subtract to form the Score
992 exposure.
994 Parameters
995 ----------
996 template : `lsst.afw.image.ExposureF`
997 Template exposure, warped to match the science exposure.
998 science : `lsst.afw.image.ExposureF`
999 Science exposure to subtract from the template.
1000 matchedScience : `lsst.afw.image.ExposureF`
1001 The science exposure, convolved with the reflection of its own PSF.
1002 selectSources : `lsst.afw.table.SourceCatalog`
1003 Identified sources on the science exposure. This catalog is used to
1004 select sources in order to perform the AL PSF matching on stamp
1005 images around them.
1006 preConvKernel : `lsst.afw.math.Kernel`
1007 The reflection of the kernel that was used to preconvolve the
1008 `science` exposure. Must be normalized to sum to 1.
1010 Returns
1011 -------
1012 results : `lsst.pipe.base.Struct`
1014 ``scoreExposure`` : `lsst.afw.image.ExposureF`
1015 Result of subtracting the convolved template and science
1016 images. Attached PSF is that of the original science image.
1017 ``matchedTemplate`` : `lsst.afw.image.ExposureF`
1018 Warped and PSF-matched template exposure. Attached PSF is that
1019 of the original science image.
1020 ``matchedScience`` : `lsst.afw.image.ExposureF`
1021 The science exposure after convolving with its own PSF.
1022 Attached PSF is that of the original science image.
1023 ``backgroundModel`` : `lsst.afw.math.Function2D`
1024 Background model that was fit while solving for the
1025 PSF-matching kernel
1026 ``psfMatchingKernel`` : `lsst.afw.math.Kernel`
1027 Final kernel used to PSF-match the template to the science
1028 image.
1029 """
1030 bbox = science.getBBox()
1031 innerBBox = preConvKernel.shrinkBBox(bbox)
1033 kernelSources = self.makeKernel.selectKernelSources(template[innerBBox], matchedScience[innerBBox],
1034 candidateList=selectSources,
1035 preconvolved=True)
1036 kernelResult = self.makeKernel.run(template[innerBBox], matchedScience[innerBBox], kernelSources,
1037 preconvolved=True)
1039 matchedTemplate = self._convolveExposure(template, kernelResult.psfMatchingKernel,
1040 self.convolutionControl,
1041 bbox=bbox,
1042 psf=science.psf,
1043 interpolateBadMaskPlanes=True,
1044 photoCalib=science.photoCalib)
1045 score = _subtractImages(matchedScience, matchedTemplate,
1046 backgroundModel=(kernelResult.backgroundModel
1047 if self.config.doSubtractBackground else None))
1048 correctedScore = self.finalize(template[bbox], science, score,
1049 kernelResult.psfMatchingKernel,
1050 templateMatched=True, preConvMode=True,
1051 preConvKernel=preConvKernel)
1053 return lsst.pipe.base.Struct(scoreExposure=correctedScore,
1054 matchedTemplate=matchedTemplate,
1055 matchedScience=matchedScience,
1056 backgroundModel=kernelResult.backgroundModel,
1057 psfMatchingKernel=kernelResult.psfMatchingKernel)
1060def checkTemplateIsSufficient(templateExposure, logger, requiredTemplateFraction=0.,
1061 exceptionMessage=""):
1062 """Raise NoWorkFound if template coverage < requiredTemplateFraction
1064 Parameters
1065 ----------
1066 templateExposure : `lsst.afw.image.ExposureF`
1067 The template exposure to check
1068 logger : `lsst.log.Log`
1069 Logger for printing output.
1070 requiredTemplateFraction : `float`, optional
1071 Fraction of pixels of the science image required to have coverage
1072 in the template.
1073 exceptionMessage : `str`, optional
1074 Message to include in the exception raised if the template coverage
1075 is insufficient.
1077 Returns
1078 -------
1079 templateCoverageFraction: `float`
1080 Fraction of pixels in the template with data.
1082 Raises
1083 ------
1084 lsst.pipe.base.NoWorkFound
1085 Raised if fraction of good pixels, defined as not having NO_DATA
1086 set, is less than the requiredTemplateFraction
1087 """
1088 # Count the number of pixels with the NO_DATA mask bit set
1089 # counting NaN pixels is insufficient because pixels without data are often intepolated over)
1090 pixNoData = np.count_nonzero(templateExposure.mask.array
1091 & templateExposure.mask.getPlaneBitMask('NO_DATA'))
1092 pixGood = templateExposure.getBBox().getArea() - pixNoData
1093 templateCoverageFraction = pixGood/templateExposure.getBBox().getArea()
1094 logger.info("template has %d good pixels (%.1f%%)", pixGood, 100*templateCoverageFraction)
1096 if templateCoverageFraction < requiredTemplateFraction:
1097 message = ("Insufficient Template Coverage. (%.1f%% < %.1f%%)" % (
1098 100*templateCoverageFraction,
1099 100*requiredTemplateFraction))
1100 raise lsst.pipe.base.NoWorkFound(message + " " + exceptionMessage)
1101 return templateCoverageFraction
1104def _subtractImages(science, template, backgroundModel=None):
1105 """Subtract template from science, propagating relevant metadata.
1107 Parameters
1108 ----------
1109 science : `lsst.afw.Exposure`
1110 The input science image.
1111 template : `lsst.afw.Exposure`
1112 The template to subtract from the science image.
1113 backgroundModel : `lsst.afw.MaskedImage`, optional
1114 Differential background model
1116 Returns
1117 -------
1118 difference : `lsst.afw.Exposure`
1119 The subtracted image.
1120 """
1121 difference = science.clone()
1122 if backgroundModel is not None:
1123 difference.maskedImage -= backgroundModel
1124 difference.maskedImage -= template.maskedImage
1125 return difference
1128def _shapeTest(exp1, exp2, fwhmExposureBuffer, fwhmExposureGrid):
1129 """Determine that the PSF of ``exp1`` is not wider than that of ``exp2``.
1131 Parameters
1132 ----------
1133 exp1 : `~lsst.afw.image.Exposure`
1134 Exposure with the reference point spread function (PSF) to evaluate.
1135 exp2 : `~lsst.afw.image.Exposure`
1136 Exposure with a candidate point spread function (PSF) to evaluate.
1137 fwhmExposureBuffer : `float`
1138 Fractional buffer margin to be left out of all sides of the image
1139 during the construction of the grid to compute mean PSF FWHM in an
1140 exposure, if the PSF is not available at its average position.
1141 fwhmExposureGrid : `int`
1142 Grid size to compute the mean FWHM in an exposure, if the PSF is not
1143 available at its average position.
1144 Returns
1145 -------
1146 result : `bool`
1147 True if ``exp1`` has a PSF that is not wider than that of ``exp2`` in
1148 either dimension.
1149 """
1150 try:
1151 shape1 = getPsfFwhm(exp1.psf, average=False)
1152 shape2 = getPsfFwhm(exp2.psf, average=False)
1153 except lsst.pex.exceptions.InvalidParameterError:
1154 shape1 = evaluateMeanPsfFwhm(exp1,
1155 fwhmExposureBuffer=fwhmExposureBuffer,
1156 fwhmExposureGrid=fwhmExposureGrid
1157 )
1158 shape2 = evaluateMeanPsfFwhm(exp2,
1159 fwhmExposureBuffer=fwhmExposureBuffer,
1160 fwhmExposureGrid=fwhmExposureGrid
1161 )
1162 return shape1 <= shape2
1164 # Results from getPsfFwhm is a tuple of two values, one for each dimension.
1165 xTest = shape1[0] <= shape2[0]
1166 yTest = shape1[1] <= shape2[1]
1167 return xTest | yTest
1170def _interpolateImage(maskedImage, badMaskPlanes, fallbackValue=None):
1171 """Replace masked image pixels with interpolated values.
1173 Parameters
1174 ----------
1175 maskedImage : `lsst.afw.image.MaskedImage`
1176 Image on which to perform interpolation.
1177 badMaskPlanes : `list` of `str`
1178 List of mask planes to interpolate over.
1179 fallbackValue : `float`, optional
1180 Value to set when interpolation fails.
1182 Returns
1183 -------
1184 result: `float`
1185 The number of masked pixels that were replaced.
1186 """
1187 imgBadMaskPlanes = [
1188 maskPlane for maskPlane in badMaskPlanes if maskPlane in maskedImage.mask.getMaskPlaneDict()
1189 ]
1191 image = maskedImage.image.array
1192 badPixels = (maskedImage.mask.array & maskedImage.mask.getPlaneBitMask(imgBadMaskPlanes)) > 0
1193 image[badPixels] = np.nan
1194 if fallbackValue is None:
1195 fallbackValue = np.nanmedian(image)
1196 # For this initial implementation, skip the interpolation and just fill with
1197 # the median value.
1198 image[badPixels] = fallbackValue
1199 return np.sum(badPixels)