Coverage for python/lsst/pipe/tasks/dcrAssembleCoadd.py : 13%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This file is part of pipe_tasks.
2#
3# LSST Data Management System
4# This product includes software developed by the
5# LSST Project (http://www.lsst.org/).
6# See COPYRIGHT file at the top of the source tree.
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the LSST License Statement and
19# the GNU General Public License along with this program. If not,
20# see <https://www.lsstcorp.org/LegalNotices/>.
21#
23from math import ceil
24import numpy as np
25from scipy import ndimage
26import lsst.geom as geom
27import lsst.afw.image as afwImage
28import lsst.afw.table as afwTable
29import lsst.coadd.utils as coaddUtils
30from lsst.daf.butler import DeferredDatasetHandle
31from lsst.ip.diffim.dcrModel import applyDcr, calculateDcr, DcrModel
32import lsst.meas.algorithms as measAlg
33from lsst.meas.base import SingleFrameMeasurementTask
34import lsst.pex.config as pexConfig
35import lsst.pipe.base as pipeBase
36import lsst.utils as utils
37from .assembleCoadd import (AssembleCoaddTask,
38 CompareWarpAssembleCoaddConfig,
39 CompareWarpAssembleCoaddTask)
40from .coaddBase import makeSkyInfo
41from .measurePsf import MeasurePsfTask
43__all__ = ["DcrAssembleCoaddConnections", "DcrAssembleCoaddTask", "DcrAssembleCoaddConfig"]
46class DcrAssembleCoaddConnections(pipeBase.PipelineTaskConnections,
47 dimensions=("tract", "patch", "abstract_filter", "skymap"),
48 defaultTemplates={"inputCoaddName": "deep",
49 "outputCoaddName": "dcr",
50 "warpType": "direct",
51 "warpTypeSuffix": "",
52 "fakesType": ""}):
53 inputWarps = pipeBase.connectionTypes.Input(
54 doc=("Input list of warps to be assembled i.e. stacked."
55 "WarpType (e.g. direct, psfMatched) is controlled by the warpType config parameter"),
56 name="{inputCoaddName}Coadd_{warpType}Warp",
57 storageClass="ExposureF",
58 dimensions=("tract", "patch", "skymap", "visit", "instrument"),
59 deferLoad=True,
60 multiple=True
61 )
62 skyMap = pipeBase.connectionTypes.Input(
63 doc="Input definition of geometry/bbox and projection/wcs for coadded exposures",
64 name="{inputCoaddName}Coadd_skyMap",
65 storageClass="SkyMap",
66 dimensions=("skymap", ),
67 )
68 brightObjectMask = pipeBase.connectionTypes.PrerequisiteInput(
69 doc=("Input Bright Object Mask mask produced with external catalogs to be applied to the mask plane"
70 " BRIGHT_OBJECT."),
71 name="brightObjectMask",
72 storageClass="ObjectMaskCatalog",
73 dimensions=("tract", "patch", "skymap", "abstract_filter"),
74 )
75 templateExposure = pipeBase.connectionTypes.Input(
76 doc="Input coadded exposure, produced by previous call to AssembleCoadd",
77 name="{fakesType}{inputCoaddName}Coadd{warpTypeSuffix}",
78 storageClass="ExposureF",
79 dimensions=("tract", "patch", "skymap", "abstract_filter"),
80 )
81 dcrCoadds = pipeBase.connectionTypes.Output(
82 doc="Output coadded exposure, produced by stacking input warps",
83 name="{fakesType}{outputCoaddName}Coadd{warpTypeSuffix}",
84 storageClass="ExposureF",
85 dimensions=("tract", "patch", "skymap", "abstract_filter", "subfilter"),
86 multiple=True,
87 )
88 dcrNImages = pipeBase.connectionTypes.Output(
89 doc="Output image of number of input images per pixel",
90 name="{outputCoaddName}Coadd_nImage",
91 storageClass="ImageU",
92 dimensions=("tract", "patch", "skymap", "abstract_filter", "subfilter"),
93 multiple=True,
94 )
96 def __init__(self, *, config=None):
97 super().__init__(config=config)
98 if not config.doWrite:
99 self.outputs.remove("dcrCoadds")
102class DcrAssembleCoaddConfig(CompareWarpAssembleCoaddConfig,
103 pipelineConnections=DcrAssembleCoaddConnections):
104 dcrNumSubfilters = pexConfig.Field(
105 dtype=int,
106 doc="Number of sub-filters to forward model chromatic effects to fit the supplied exposures.",
107 default=3,
108 )
109 maxNumIter = pexConfig.Field(
110 dtype=int,
111 optional=True,
112 doc="Maximum number of iterations of forward modeling.",
113 default=None,
114 )
115 minNumIter = pexConfig.Field(
116 dtype=int,
117 optional=True,
118 doc="Minimum number of iterations of forward modeling.",
119 default=None,
120 )
121 convergenceThreshold = pexConfig.Field(
122 dtype=float,
123 doc="Target relative change in convergence between iterations of forward modeling.",
124 default=0.001,
125 )
126 useConvergence = pexConfig.Field(
127 dtype=bool,
128 doc="Use convergence test as a forward modeling end condition?"
129 "If not set, skips calculating convergence and runs for ``maxNumIter`` iterations",
130 default=True,
131 )
132 baseGain = pexConfig.Field(
133 dtype=float,
134 optional=True,
135 doc="Relative weight to give the new solution vs. the last solution when updating the model."
136 "A value of 1.0 gives equal weight to both solutions."
137 "Small values imply slower convergence of the solution, but can "
138 "help prevent overshooting and failures in the fit."
139 "If ``baseGain`` is None, a conservative gain "
140 "will be calculated from the number of subfilters. ",
141 default=None,
142 )
143 useProgressiveGain = pexConfig.Field(
144 dtype=bool,
145 doc="Use a gain that slowly increases above ``baseGain`` to accelerate convergence? "
146 "When calculating the next gain, we use up to 5 previous gains and convergence values."
147 "Can be set to False to force the model to change at the rate of ``baseGain``. ",
148 default=True,
149 )
150 doAirmassWeight = pexConfig.Field(
151 dtype=bool,
152 doc="Weight exposures by airmass? Useful if there are relatively few high-airmass observations.",
153 default=False,
154 )
155 modelWeightsWidth = pexConfig.Field(
156 dtype=float,
157 doc="Width of the region around detected sources to include in the DcrModel.",
158 default=3,
159 )
160 useModelWeights = pexConfig.Field(
161 dtype=bool,
162 doc="Width of the region around detected sources to include in the DcrModel.",
163 default=True,
164 )
165 splitSubfilters = pexConfig.Field(
166 dtype=bool,
167 doc="Calculate DCR for two evenly-spaced wavelengths in each subfilter."
168 "Instead of at the midpoint",
169 default=True,
170 )
171 splitThreshold = pexConfig.Field(
172 dtype=float,
173 doc="Minimum DCR difference within a subfilter to use ``splitSubfilters``, in pixels."
174 "Set to 0 to always split the subfilters.",
175 default=0.1,
176 )
177 regularizeModelIterations = pexConfig.Field(
178 dtype=float,
179 doc="Maximum relative change of the model allowed between iterations."
180 "Set to zero to disable.",
181 default=2.,
182 )
183 regularizeModelFrequency = pexConfig.Field(
184 dtype=float,
185 doc="Maximum relative change of the model allowed between subfilters."
186 "Set to zero to disable.",
187 default=4.,
188 )
189 convergenceMaskPlanes = pexConfig.ListField(
190 dtype=str,
191 default=["DETECTED"],
192 doc="Mask planes to use to calculate convergence."
193 )
194 regularizationWidth = pexConfig.Field(
195 dtype=int,
196 default=2,
197 doc="Minimum radius of a region to include in regularization, in pixels."
198 )
199 imageInterpOrder = pexConfig.Field(
200 dtype=int,
201 doc="The order of the spline interpolation used to shift the image plane.",
202 default=3,
203 )
204 accelerateModel = pexConfig.Field(
205 dtype=float,
206 doc="Factor to amplify the differences between model planes by to speed convergence.",
207 default=3,
208 )
209 doCalculatePsf = pexConfig.Field(
210 dtype=bool,
211 doc="Set to detect stars and recalculate the PSF from the final coadd."
212 "Otherwise the PSF is estimated from a selection of the best input exposures",
213 default=False,
214 )
215 detectPsfSources = pexConfig.ConfigurableField(
216 target=measAlg.SourceDetectionTask,
217 doc="Task to detect sources for PSF measurement, if ``doCalculatePsf`` is set.",
218 )
219 measurePsfSources = pexConfig.ConfigurableField(
220 target=SingleFrameMeasurementTask,
221 doc="Task to measure sources for PSF measurement, if ``doCalculatePsf`` is set."
222 )
223 measurePsf = pexConfig.ConfigurableField(
224 target=MeasurePsfTask,
225 doc="Task to measure the PSF of the coadd, if ``doCalculatePsf`` is set.",
226 )
228 def setDefaults(self):
229 CompareWarpAssembleCoaddConfig.setDefaults(self)
230 self.assembleStaticSkyModel.retarget(CompareWarpAssembleCoaddTask)
231 self.doNImage = True
232 self.assembleStaticSkyModel.warpType = self.warpType
233 # The deepCoadd and nImage files will be overwritten by this Task, so don't write them the first time
234 self.assembleStaticSkyModel.doNImage = False
235 self.assembleStaticSkyModel.doWrite = False
236 self.detectPsfSources.returnOriginalFootprints = False
237 self.detectPsfSources.thresholdPolarity = "positive"
238 # Only use bright sources for PSF measurement
239 self.detectPsfSources.thresholdValue = 50
240 self.detectPsfSources.nSigmaToGrow = 2
241 # A valid star for PSF measurement should at least fill 5x5 pixels
242 self.detectPsfSources.minPixels = 25
243 # Use the variance plane to calculate signal to noise
244 self.detectPsfSources.thresholdType = "pixel_stdev"
245 # The signal to noise limit is good enough, while the flux limit is set
246 # in dimensionless units and may not be appropriate for all data sets.
247 self.measurePsf.starSelector["objectSize"].doFluxLimit = False
250class DcrAssembleCoaddTask(CompareWarpAssembleCoaddTask):
251 """Assemble DCR coadded images from a set of warps.
253 Attributes
254 ----------
255 bufferSize : `int`
256 The number of pixels to grow each subregion by to allow for DCR.
258 Notes
259 -----
260 As with AssembleCoaddTask, we want to assemble a coadded image from a set of
261 Warps (also called coadded temporary exposures), including the effects of
262 Differential Chromatic Refraction (DCR).
263 For full details of the mathematics and algorithm, please see
264 DMTN-037: DCR-matched template generation (https://dmtn-037.lsst.io).
266 This Task produces a DCR-corrected deepCoadd, as well as a dcrCoadd for
267 each subfilter used in the iterative calculation.
268 It begins by dividing the bandpass-defining filter into N equal bandwidth
269 "subfilters", and divides the flux in each pixel from an initial coadd
270 equally into each as a "dcrModel". Because the airmass and parallactic
271 angle of each individual exposure is known, we can calculate the shift
272 relative to the center of the band in each subfilter due to DCR. For each
273 exposure we apply this shift as a linear transformation to the dcrModels
274 and stack the results to produce a DCR-matched exposure. The matched
275 exposures are subtracted from the input exposures to produce a set of
276 residual images, and these residuals are reverse shifted for each
277 exposures' subfilters and stacked. The shifted and stacked residuals are
278 added to the dcrModels to produce a new estimate of the flux in each pixel
279 within each subfilter. The dcrModels are solved for iteratively, which
280 continues until the solution from a new iteration improves by less than
281 a set percentage, or a maximum number of iterations is reached.
282 Two forms of regularization are employed to reduce unphysical results.
283 First, the new solution is averaged with the solution from the previous
284 iteration, which mitigates oscillating solutions where the model
285 overshoots with alternating very high and low values.
286 Second, a common degeneracy when the data have a limited range of airmass or
287 parallactic angle values is for one subfilter to be fit with very low or
288 negative values, while another subfilter is fit with very high values. This
289 typically appears in the form of holes next to sources in one subfilter,
290 and corresponding extended wings in another. Because each subfilter has
291 a narrow bandwidth we assume that physical sources that are above the noise
292 level will not vary in flux by more than a factor of `frequencyClampFactor`
293 between subfilters, and pixels that have flux deviations larger than that
294 factor will have the excess flux distributed evenly among all subfilters.
295 If `splitSubfilters` is set, then each subfilter will be further sub-
296 divided during the forward modeling step (only). This approximates using
297 a higher number of subfilters that may be necessary for high airmass
298 observations, but does not increase the number of free parameters in the
299 fit. This is needed when there are high airmass observations which would
300 otherwise have significant DCR even within a subfilter. Because calculating
301 the shifted images takes most of the time, splitting the subfilters is
302 turned off by way of the `splitThreshold` option for low-airmass
303 observations that do not suffer from DCR within a subfilter.
304 """
306 ConfigClass = DcrAssembleCoaddConfig
307 _DefaultName = "dcrAssembleCoadd"
309 def __init__(self, *args, **kwargs):
310 super().__init__(*args, **kwargs)
311 if self.config.doCalculatePsf:
312 self.schema = afwTable.SourceTable.makeMinimalSchema()
313 self.makeSubtask("detectPsfSources", schema=self.schema)
314 self.makeSubtask("measurePsfSources", schema=self.schema)
315 self.makeSubtask("measurePsf", schema=self.schema)
317 @utils.inheritDoc(pipeBase.PipelineTask)
318 def runQuantum(self, butlerQC, inputRefs, outputRefs):
319 # Docstring to be formatted with info from PipelineTask.runQuantum
320 """
321 Notes
322 -----
323 Assemble a coadd from a set of Warps.
325 PipelineTask (Gen3) entry point to Coadd a set of Warps.
326 Analogous to `runDataRef`, it prepares all the data products to be
327 passed to `run`, and processes the results before returning a struct
328 of results to be written out. AssembleCoadd cannot fit all Warps in memory.
329 Therefore, its inputs are accessed subregion by subregion
330 by the Gen3 `DeferredDatasetHandle` that is analagous to the Gen2
331 `lsst.daf.persistence.ButlerDataRef`. Any updates to this method should
332 correspond to an update in `runDataRef` while both entry points
333 are used.
334 """
335 inputData = butlerQC.get(inputRefs)
337 # Construct skyInfo expected by run
338 # Do not remove skyMap from inputData in case makeSupplementaryDataGen3 needs it
339 skyMap = inputData["skyMap"]
340 outputDataId = butlerQC.quantum.dataId
342 inputData['skyInfo'] = makeSkyInfo(skyMap,
343 tractId=outputDataId['tract'],
344 patchId=outputDataId['patch'])
346 # Construct list of input Deferred Datasets
347 # These quack a bit like like Gen2 DataRefs
348 warpRefList = inputData['inputWarps']
349 # Perform same middle steps as `runDataRef` does
350 inputs = self.prepareInputs(warpRefList)
351 self.log.info("Found %d %s", len(inputs.tempExpRefList),
352 self.getTempExpDatasetName(self.warpType))
353 if len(inputs.tempExpRefList) == 0:
354 self.log.warn("No coadd temporary exposures found")
355 return
357 supplementaryData = self.makeSupplementaryDataGen3(butlerQC, inputRefs, outputRefs)
358 retStruct = self.run(inputData['skyInfo'], inputs.tempExpRefList, inputs.imageScalerList,
359 inputs.weightList, supplementaryData=supplementaryData)
361 inputData.setdefault('brightObjectMask', None)
362 for subfilter in range(self.config.dcrNumSubfilters):
363 # Use the PSF of the stacked dcrModel, and do not recalculate the PSF for each subfilter
364 retStruct.dcrCoadds[subfilter].setPsf(retStruct.coaddExposure.getPsf())
365 self.processResults(retStruct.dcrCoadds[subfilter], inputData['brightObjectMask'], outputDataId)
367 if self.config.doWrite:
368 butlerQC.put(retStruct, outputRefs)
369 return retStruct
371 @pipeBase.timeMethod
372 def runDataRef(self, dataRef, selectDataList=None, warpRefList=None):
373 """Assemble a coadd from a set of warps.
375 Coadd a set of Warps. Compute weights to be applied to each Warp and
376 find scalings to match the photometric zeropoint to a reference Warp.
377 Assemble the Warps using run method.
378 Forward model chromatic effects across multiple subfilters,
379 and subtract from the input Warps to build sets of residuals.
380 Use the residuals to construct a new ``DcrModel`` for each subfilter,
381 and iterate until the model converges.
382 Interpolate over NaNs and optionally write the coadd to disk.
383 Return the coadded exposure.
385 Parameters
386 ----------
387 dataRef : `lsst.daf.persistence.ButlerDataRef`
388 Data reference defining the patch for coaddition and the
389 reference Warp
390 selectDataList : `list` of `lsst.daf.persistence.ButlerDataRef`
391 List of data references to warps. Data to be coadded will be
392 selected from this list based on overlap with the patch defined by
393 the data reference.
395 Returns
396 -------
397 results : `lsst.pipe.base.Struct`
398 The Struct contains the following fields:
400 - ``coaddExposure``: coadded exposure (`lsst.afw.image.Exposure`)
401 - ``nImage``: exposure count image (`lsst.afw.image.ImageU`)
402 - ``dcrCoadds``: `list` of coadded exposures for each subfilter
403 - ``dcrNImages``: `list` of exposure count images for each subfilter
404 """
405 if (selectDataList is None and warpRefList is None) or (selectDataList and warpRefList):
406 raise RuntimeError("runDataRef must be supplied either a selectDataList or warpRefList")
408 skyInfo = self.getSkyInfo(dataRef)
409 if warpRefList is None:
410 calExpRefList = self.selectExposures(dataRef, skyInfo, selectDataList=selectDataList)
411 if len(calExpRefList) == 0:
412 self.log.warn("No exposures to coadd")
413 return
414 self.log.info("Coadding %d exposures", len(calExpRefList))
416 warpRefList = self.getTempExpRefList(dataRef, calExpRefList)
418 inputData = self.prepareInputs(warpRefList)
419 self.log.info("Found %d %s", len(inputData.tempExpRefList),
420 self.getTempExpDatasetName(self.warpType))
421 if len(inputData.tempExpRefList) == 0:
422 self.log.warn("No coadd temporary exposures found")
423 return
425 supplementaryData = self.makeSupplementaryData(dataRef, warpRefList=inputData.tempExpRefList)
427 results = self.run(skyInfo, inputData.tempExpRefList, inputData.imageScalerList,
428 inputData.weightList, supplementaryData=supplementaryData)
429 if results is None:
430 self.log.warn("Could not construct DcrModel for patch %s: no data to coadd.",
431 skyInfo.patchInfo.getIndex())
432 return
434 if self.config.doCalculatePsf:
435 self.measureCoaddPsf(results.coaddExposure)
436 brightObjects = self.readBrightObjectMasks(dataRef) if self.config.doMaskBrightObjects else None
437 for subfilter in range(self.config.dcrNumSubfilters):
438 # Use the PSF of the stacked dcrModel, and do not recalculate the PSF for each subfilter
439 results.dcrCoadds[subfilter].setPsf(results.coaddExposure.getPsf())
440 self.processResults(results.dcrCoadds[subfilter],
441 brightObjectMasks=brightObjects, dataId=dataRef.dataId)
442 if self.config.doWrite:
443 self.log.info("Persisting dcrCoadd")
444 dataRef.put(results.dcrCoadds[subfilter], "dcrCoadd", subfilter=subfilter,
445 numSubfilters=self.config.dcrNumSubfilters)
446 if self.config.doNImage and results.dcrNImages is not None:
447 dataRef.put(results.dcrNImages[subfilter], "dcrCoadd_nImage", subfilter=subfilter,
448 numSubfilters=self.config.dcrNumSubfilters)
450 return results
452 @utils.inheritDoc(AssembleCoaddTask)
453 def makeSupplementaryDataGen3(self, butlerQC, inputRefs, outputRefs):
454 """Load the previously-generated template coadd.
456 This can be removed entirely once we no longer support the Gen 2 butler.
458 Returns
459 -------
460 templateCoadd : `lsst.pipe.base.Struct`
461 Result struct with components:
463 - ``templateCoadd``: coadded exposure (`lsst.afw.image.ExposureF`)
464 """
465 templateCoadd = butlerQC.get(inputRefs.templateExposure)
467 return pipeBase.Struct(templateCoadd=templateCoadd)
469 def measureCoaddPsf(self, coaddExposure):
470 """Detect sources on the coadd exposure and measure the final PSF.
472 Parameters
473 ----------
474 coaddExposure : `lsst.afw.image.Exposure`
475 The final coadded exposure.
476 """
477 table = afwTable.SourceTable.make(self.schema)
478 detResults = self.detectPsfSources.run(table, coaddExposure, clearMask=False)
479 coaddSources = detResults.sources
480 self.measurePsfSources.run(
481 measCat=coaddSources,
482 exposure=coaddExposure
483 )
484 # Measure the PSF on the stacked subfilter coadds if possible.
485 # We should already have a decent estimate of the coadd PSF, however,
486 # so in case of any errors simply log them as a warning and use the
487 # default PSF.
488 try:
489 psfResults = self.measurePsf.run(coaddExposure, coaddSources)
490 except Exception as e:
491 self.log.warn("Unable to calculate PSF, using default coadd PSF: %s" % e)
492 else:
493 coaddExposure.setPsf(psfResults.psf)
495 def prepareDcrInputs(self, templateCoadd, warpRefList, weightList):
496 """Prepare the DCR coadd by iterating through the visitInfo of the input warps.
498 Sets the property ``bufferSize``.
500 Parameters
501 ----------
502 templateCoadd : `lsst.afw.image.ExposureF`
503 The initial coadd exposure before accounting for DCR.
504 warpRefList : `list` of `lsst.daf.butler.DeferredDatasetHandle` or
505 `lsst.daf.persistence.ButlerDataRef`
506 The data references to the input warped exposures.
507 weightList : `list` of `float`
508 The weight to give each input exposure in the coadd
509 Will be modified in place if ``doAirmassWeight`` is set.
511 Returns
512 -------
513 dcrModels : `lsst.pipe.tasks.DcrModel`
514 Best fit model of the true sky after correcting chromatic effects.
516 Raises
517 ------
518 NotImplementedError
519 If ``lambdaMin`` is missing from the Mapper class of the obs package being used.
520 """
521 sigma2fwhm = 2.*np.sqrt(2.*np.log(2.))
522 filterInfo = templateCoadd.getFilter()
523 if np.isnan(filterInfo.getFilterProperty().getLambdaMin()):
524 raise NotImplementedError("No minimum/maximum wavelength information found"
525 " in the filter definition! Please add lambdaMin and lambdaMax"
526 " to the Mapper class in your obs package.")
527 tempExpName = self.getTempExpDatasetName(self.warpType)
528 dcrShifts = []
529 airmassDict = {}
530 angleDict = {}
531 psfSizeDict = {}
532 for visitNum, warpExpRef in enumerate(warpRefList):
533 if isinstance(warpExpRef, DeferredDatasetHandle):
534 # Gen 3 API
535 visitInfo = warpExpRef.get(component="visitInfo")
536 psf = warpExpRef.get(component="psf")
537 else:
538 # Gen 2 API. Delete this when Gen 2 retired
539 visitInfo = warpExpRef.get(tempExpName + "_visitInfo")
540 psf = warpExpRef.get(tempExpName).getPsf()
541 visit = warpExpRef.dataId["visit"]
542 psfSize = psf.computeShape().getDeterminantRadius()*sigma2fwhm
543 airmass = visitInfo.getBoresightAirmass()
544 parallacticAngle = visitInfo.getBoresightParAngle().asDegrees()
545 airmassDict[visit] = airmass
546 angleDict[visit] = parallacticAngle
547 psfSizeDict[visit] = psfSize
548 if self.config.doAirmassWeight:
549 weightList[visitNum] *= airmass
550 dcrShifts.append(np.max(np.abs(calculateDcr(visitInfo, templateCoadd.getWcs(),
551 filterInfo, self.config.dcrNumSubfilters))))
552 self.log.info("Selected airmasses:\n%s", airmassDict)
553 self.log.info("Selected parallactic angles:\n%s", angleDict)
554 self.log.info("Selected PSF sizes:\n%s", psfSizeDict)
555 self.bufferSize = int(np.ceil(np.max(dcrShifts)) + 1)
556 psf = self.selectCoaddPsf(templateCoadd, warpRefList)
557 dcrModels = DcrModel.fromImage(templateCoadd.maskedImage,
558 self.config.dcrNumSubfilters,
559 filterInfo=filterInfo,
560 psf=psf)
561 return dcrModels
563 def run(self, skyInfo, warpRefList, imageScalerList, weightList,
564 supplementaryData=None):
565 """Assemble the coadd.
567 Requires additional inputs Struct ``supplementaryData`` to contain a
568 ``templateCoadd`` that serves as the model of the static sky.
570 Find artifacts and apply them to the warps' masks creating a list of
571 alternative masks with a new "CLIPPED" plane and updated "NO_DATA" plane
572 Then pass these alternative masks to the base class's assemble method.
574 Divide the ``templateCoadd`` evenly between each subfilter of a
575 ``DcrModel`` as the starting best estimate of the true wavelength-
576 dependent sky. Forward model the ``DcrModel`` using the known
577 chromatic effects in each subfilter and calculate a convergence metric
578 based on how well the modeled template matches the input warps. If
579 the convergence has not yet reached the desired threshold, then shift
580 and stack the residual images to build a new ``DcrModel``. Apply
581 conditioning to prevent oscillating solutions between iterations or
582 between subfilters.
584 Once the ``DcrModel`` reaches convergence or the maximum number of
585 iterations has been reached, fill the metadata for each subfilter
586 image and make them proper ``coaddExposure``s.
588 Parameters
589 ----------
590 skyInfo : `lsst.pipe.base.Struct`
591 Patch geometry information, from getSkyInfo
592 warpRefList : `list` of `lsst.daf.butler.DeferredDatasetHandle` or
593 `lsst.daf.persistence.ButlerDataRef`
594 The data references to the input warped exposures.
595 imageScalerList : `list` of `lsst.pipe.task.ImageScaler`
596 The image scalars correct for the zero point of the exposures.
597 weightList : `list` of `float`
598 The weight to give each input exposure in the coadd
599 supplementaryData : `lsst.pipe.base.Struct`
600 Result struct returned by ``makeSupplementaryData`` with components:
602 - ``templateCoadd``: coadded exposure (`lsst.afw.image.Exposure`)
604 Returns
605 -------
606 result : `lsst.pipe.base.Struct`
607 Result struct with components:
609 - ``coaddExposure``: coadded exposure (`lsst.afw.image.Exposure`)
610 - ``nImage``: exposure count image (`lsst.afw.image.ImageU`)
611 - ``dcrCoadds``: `list` of coadded exposures for each subfilter
612 - ``dcrNImages``: `list` of exposure count images for each subfilter
613 """
614 minNumIter = self.config.minNumIter or self.config.dcrNumSubfilters
615 maxNumIter = self.config.maxNumIter or self.config.dcrNumSubfilters*3
616 templateCoadd = supplementaryData.templateCoadd
617 baseMask = templateCoadd.mask.clone()
618 # The variance plane is for each subfilter
619 # and should be proportionately lower than the full-band image
620 baseVariance = templateCoadd.variance.clone()
621 baseVariance /= self.config.dcrNumSubfilters
622 spanSetMaskList = self.findArtifacts(templateCoadd, warpRefList, imageScalerList)
623 # Note that the mask gets cleared in ``findArtifacts``, but we want to preserve the mask.
624 templateCoadd.setMask(baseMask)
625 badMaskPlanes = self.config.badMaskPlanes[:]
626 # Note that is important that we do not add "CLIPPED" to ``badMaskPlanes``
627 # This is because pixels in observations that are significantly affect by DCR
628 # are likely to have many pixels that are both "DETECTED" and "CLIPPED",
629 # but those are necessary to constrain the DCR model.
630 badPixelMask = templateCoadd.mask.getPlaneBitMask(badMaskPlanes)
632 stats = self.prepareStats(mask=badPixelMask)
633 dcrModels = self.prepareDcrInputs(templateCoadd, warpRefList, weightList)
634 if self.config.doNImage:
635 dcrNImages, dcrWeights = self.calculateNImage(dcrModels, skyInfo.bbox, warpRefList,
636 spanSetMaskList, stats.ctrl)
637 nImage = afwImage.ImageU(skyInfo.bbox)
638 # Note that this nImage will be a factor of dcrNumSubfilters higher than
639 # the nImage returned by assembleCoadd for most pixels. This is because each
640 # subfilter may have a different nImage, and fractional values are not allowed.
641 for dcrNImage in dcrNImages:
642 nImage += dcrNImage
643 else:
644 dcrNImages = None
646 subregionSize = geom.Extent2I(*self.config.subregionSize)
647 nSubregions = (ceil(skyInfo.bbox.getHeight()/subregionSize[1]) *
648 ceil(skyInfo.bbox.getWidth()/subregionSize[0]))
649 subIter = 0
650 for subBBox in self._subBBoxIter(skyInfo.bbox, subregionSize):
651 modelIter = 0
652 subIter += 1
653 self.log.info("Computing coadd over patch %s subregion %s of %s: %s",
654 skyInfo.patchInfo.getIndex(), subIter, nSubregions, subBBox)
655 dcrBBox = geom.Box2I(subBBox)
656 dcrBBox.grow(self.bufferSize)
657 dcrBBox.clip(dcrModels.bbox)
658 modelWeights = self.calculateModelWeights(dcrModels, dcrBBox)
659 subExposures = self.loadSubExposures(dcrBBox, stats.ctrl, warpRefList,
660 imageScalerList, spanSetMaskList)
661 convergenceMetric = self.calculateConvergence(dcrModels, subExposures, subBBox,
662 warpRefList, weightList, stats.ctrl)
663 self.log.info("Initial convergence : %s", convergenceMetric)
664 convergenceList = [convergenceMetric]
665 gainList = []
666 convergenceCheck = 1.
667 refImage = templateCoadd.image
668 while (convergenceCheck > self.config.convergenceThreshold or modelIter <= minNumIter):
669 gain = self.calculateGain(convergenceList, gainList)
670 self.dcrAssembleSubregion(dcrModels, subExposures, subBBox, dcrBBox, warpRefList,
671 stats.ctrl, convergenceMetric, gain,
672 modelWeights, refImage, dcrWeights)
673 if self.config.useConvergence:
674 convergenceMetric = self.calculateConvergence(dcrModels, subExposures, subBBox,
675 warpRefList, weightList, stats.ctrl)
676 if convergenceMetric == 0:
677 self.log.warn("Coadd patch %s subregion %s had convergence metric of 0.0 which is "
678 "most likely due to there being no valid data in the region.",
679 skyInfo.patchInfo.getIndex(), subIter)
680 break
681 convergenceCheck = (convergenceList[-1] - convergenceMetric)/convergenceMetric
682 if (convergenceCheck < 0) & (modelIter > minNumIter):
683 self.log.warn("Coadd patch %s subregion %s diverged before reaching maximum "
684 "iterations or desired convergence improvement of %s."
685 " Divergence: %s",
686 skyInfo.patchInfo.getIndex(), subIter,
687 self.config.convergenceThreshold, convergenceCheck)
688 break
689 convergenceList.append(convergenceMetric)
690 if modelIter > maxNumIter:
691 if self.config.useConvergence:
692 self.log.warn("Coadd patch %s subregion %s reached maximum iterations "
693 "before reaching desired convergence improvement of %s."
694 " Final convergence improvement: %s",
695 skyInfo.patchInfo.getIndex(), subIter,
696 self.config.convergenceThreshold, convergenceCheck)
697 break
699 if self.config.useConvergence:
700 self.log.info("Iteration %s with convergence metric %s, %.4f%% improvement (gain: %.2f)",
701 modelIter, convergenceMetric, 100.*convergenceCheck, gain)
702 modelIter += 1
703 else:
704 if self.config.useConvergence:
705 self.log.info("Coadd patch %s subregion %s finished with "
706 "convergence metric %s after %s iterations",
707 skyInfo.patchInfo.getIndex(), subIter, convergenceMetric, modelIter)
708 else:
709 self.log.info("Coadd patch %s subregion %s finished after %s iterations",
710 skyInfo.patchInfo.getIndex(), subIter, modelIter)
711 if self.config.useConvergence and convergenceMetric > 0:
712 self.log.info("Final convergence improvement was %.4f%% overall",
713 100*(convergenceList[0] - convergenceMetric)/convergenceMetric)
715 dcrCoadds = self.fillCoadd(dcrModels, skyInfo, warpRefList, weightList,
716 calibration=self.scaleZeroPoint.getPhotoCalib(),
717 coaddInputs=templateCoadd.getInfo().getCoaddInputs(),
718 mask=baseMask,
719 variance=baseVariance)
720 coaddExposure = self.stackCoadd(dcrCoadds)
721 return pipeBase.Struct(coaddExposure=coaddExposure, nImage=nImage,
722 dcrCoadds=dcrCoadds, dcrNImages=dcrNImages)
724 def calculateNImage(self, dcrModels, bbox, warpRefList, spanSetMaskList, statsCtrl):
725 """Calculate the number of exposures contributing to each subfilter.
727 Parameters
728 ----------
729 dcrModels : `lsst.pipe.tasks.DcrModel`
730 Best fit model of the true sky after correcting chromatic effects.
731 bbox : `lsst.geom.box.Box2I`
732 Bounding box of the patch to coadd.
733 warpRefList : `list` of `lsst.daf.butler.DeferredDatasetHandle` or
734 `lsst.daf.persistence.ButlerDataRef`
735 The data references to the input warped exposures.
736 spanSetMaskList : `list` of `dict` containing spanSet lists, or None
737 Each element of the `dict` contains the new mask plane name
738 (e.g. "CLIPPED and/or "NO_DATA") as the key,
739 and the list of SpanSets to apply to the mask.
740 statsCtrl : `lsst.afw.math.StatisticsControl`
741 Statistics control object for coadd
743 Returns
744 -------
745 dcrNImages : `list` of `lsst.afw.image.ImageU`
746 List of exposure count images for each subfilter
747 dcrWeights : `list` of `lsst.afw.image.ImageF`
748 Per-pixel weights for each subfilter.
749 Equal to 1/(number of unmasked images contributing to each pixel).
750 """
751 dcrNImages = [afwImage.ImageU(bbox) for subfilter in range(self.config.dcrNumSubfilters)]
752 dcrWeights = [afwImage.ImageF(bbox) for subfilter in range(self.config.dcrNumSubfilters)]
753 tempExpName = self.getTempExpDatasetName(self.warpType)
754 for warpExpRef, altMaskSpans in zip(warpRefList, spanSetMaskList):
755 if isinstance(warpExpRef, DeferredDatasetHandle):
756 # Gen 3 API
757 exposure = warpExpRef.get(parameters={'bbox': bbox})
758 else:
759 # Gen 2 API. Delete this when Gen 2 retired
760 exposure = warpExpRef.get(tempExpName + "_sub", bbox=bbox)
761 visitInfo = exposure.getInfo().getVisitInfo()
762 wcs = exposure.getInfo().getWcs()
763 mask = exposure.mask
764 if altMaskSpans is not None:
765 self.applyAltMaskPlanes(mask, altMaskSpans)
766 weightImage = np.zeros_like(exposure.image.array)
767 weightImage[(mask.array & statsCtrl.getAndMask()) == 0] = 1.
768 # The weights must be shifted in exactly the same way as the residuals,
769 # because they will be used as the denominator in the weighted average of residuals.
770 weightsGenerator = self.dcrResiduals(weightImage, visitInfo, wcs, dcrModels.filter)
771 for shiftedWeights, dcrNImage, dcrWeight in zip(weightsGenerator, dcrNImages, dcrWeights):
772 dcrNImage.array += np.rint(shiftedWeights).astype(dcrNImage.array.dtype)
773 dcrWeight.array += shiftedWeights
774 # Exclude any pixels that don't have at least one exposure contributing in all subfilters
775 weightsThreshold = 1.
776 goodPix = dcrWeights[0].array > weightsThreshold
777 for weights in dcrWeights[1:]:
778 goodPix = (weights.array > weightsThreshold) & goodPix
779 for subfilter in range(self.config.dcrNumSubfilters):
780 dcrWeights[subfilter].array[goodPix] = 1./dcrWeights[subfilter].array[goodPix]
781 dcrWeights[subfilter].array[~goodPix] = 0.
782 dcrNImages[subfilter].array[~goodPix] = 0
783 return (dcrNImages, dcrWeights)
785 def dcrAssembleSubregion(self, dcrModels, subExposures, bbox, dcrBBox, warpRefList,
786 statsCtrl, convergenceMetric,
787 gain, modelWeights, refImage, dcrWeights):
788 """Assemble the DCR coadd for a sub-region.
790 Build a DCR-matched template for each input exposure, then shift the
791 residuals according to the DCR in each subfilter.
792 Stack the shifted residuals and apply them as a correction to the
793 solution from the previous iteration.
794 Restrict the new model solutions from varying by more than a factor of
795 `modelClampFactor` from the last solution, and additionally restrict the
796 individual subfilter models from varying by more than a factor of
797 `frequencyClampFactor` from their average.
798 Finally, mitigate potentially oscillating solutions by averaging the new
799 solution with the solution from the previous iteration, weighted by
800 their convergence metric.
802 Parameters
803 ----------
804 dcrModels : `lsst.pipe.tasks.DcrModel`
805 Best fit model of the true sky after correcting chromatic effects.
806 subExposures : `dict` of `lsst.afw.image.ExposureF`
807 The pre-loaded exposures for the current subregion.
808 bbox : `lsst.geom.box.Box2I`
809 Bounding box of the subregion to coadd.
810 dcrBBox : `lsst.geom.box.Box2I`
811 Sub-region of the coadd which includes a buffer to allow for DCR.
812 warpRefList : `list` of `lsst.daf.butler.DeferredDatasetHandle` or
813 `lsst.daf.persistence.ButlerDataRef`
814 The data references to the input warped exposures.
815 statsCtrl : `lsst.afw.math.StatisticsControl`
816 Statistics control object for coadd
817 convergenceMetric : `float`
818 Quality of fit metric for the matched templates of the input images.
819 gain : `float`, optional
820 Relative weight to give the new solution when updating the model.
821 modelWeights : `numpy.ndarray` or `float`
822 A 2D array of weight values that tapers smoothly to zero away from detected sources.
823 Set to a placeholder value of 1.0 if ``self.config.useModelWeights`` is False.
824 refImage : `lsst.afw.image.Image`
825 A reference image used to supply the default pixel values.
826 dcrWeights : `list` of `lsst.afw.image.Image`
827 Per-pixel weights for each subfilter.
828 Equal to 1/(number of unmasked images contributing to each pixel).
829 """
830 residualGeneratorList = []
832 for warpExpRef in warpRefList:
833 visit = warpExpRef.dataId["visit"]
834 exposure = subExposures[visit]
835 visitInfo = exposure.getInfo().getVisitInfo()
836 wcs = exposure.getInfo().getWcs()
837 templateImage = dcrModels.buildMatchedTemplate(exposure=exposure,
838 order=self.config.imageInterpOrder,
839 splitSubfilters=self.config.splitSubfilters,
840 splitThreshold=self.config.splitThreshold,
841 amplifyModel=self.config.accelerateModel)
842 residual = exposure.image.array - templateImage.array
843 # Note that the variance plane here is used to store weights, not the actual variance
844 residual *= exposure.variance.array
845 # The residuals are stored as a list of generators.
846 # This allows the residual for a given subfilter and exposure to be created
847 # on the fly, instead of needing to store them all in memory.
848 residualGeneratorList.append(self.dcrResiduals(residual, visitInfo, wcs, dcrModels.filter))
850 dcrSubModelOut = self.newModelFromResidual(dcrModels, residualGeneratorList, dcrBBox, statsCtrl,
851 gain=gain,
852 modelWeights=modelWeights,
853 refImage=refImage,
854 dcrWeights=dcrWeights)
855 dcrModels.assign(dcrSubModelOut, bbox)
857 def dcrResiduals(self, residual, visitInfo, wcs, filterInfo):
858 """Prepare a residual image for stacking in each subfilter by applying the reverse DCR shifts.
860 Parameters
861 ----------
862 residual : `numpy.ndarray`
863 The residual masked image for one exposure,
864 after subtracting the matched template
865 visitInfo : `lsst.afw.image.VisitInfo`
866 Metadata for the exposure.
867 wcs : `lsst.afw.geom.SkyWcs`
868 Coordinate system definition (wcs) for the exposure.
869 filterInfo : `lsst.afw.image.Filter`
870 The filter definition, set in the current instruments' obs package.
871 Required for any calculation of DCR, including making matched templates.
873 Yields
874 ------
875 residualImage : `numpy.ndarray`
876 The residual image for the next subfilter, shifted for DCR.
877 """
878 # Pre-calculate the spline-filtered residual image, so that step can be
879 # skipped in the shift calculation in `applyDcr`.
880 filteredResidual = ndimage.spline_filter(residual, order=self.config.imageInterpOrder)
881 # Note that `splitSubfilters` is always turned off in the reverse direction.
882 # This option introduces additional blurring if applied to the residuals.
883 dcrShift = calculateDcr(visitInfo, wcs, filterInfo, self.config.dcrNumSubfilters,
884 splitSubfilters=False)
885 for dcr in dcrShift:
886 yield applyDcr(filteredResidual, dcr, useInverse=True, splitSubfilters=False,
887 doPrefilter=False, order=self.config.imageInterpOrder)
889 def newModelFromResidual(self, dcrModels, residualGeneratorList, dcrBBox, statsCtrl,
890 gain, modelWeights, refImage, dcrWeights):
891 """Calculate a new DcrModel from a set of image residuals.
893 Parameters
894 ----------
895 dcrModels : `lsst.pipe.tasks.DcrModel`
896 Current model of the true sky after correcting chromatic effects.
897 residualGeneratorList : `generator` of `numpy.ndarray`
898 The residual image for the next subfilter, shifted for DCR.
899 dcrBBox : `lsst.geom.box.Box2I`
900 Sub-region of the coadd which includes a buffer to allow for DCR.
901 statsCtrl : `lsst.afw.math.StatisticsControl`
902 Statistics control object for coadd
903 gain : `float`
904 Relative weight to give the new solution when updating the model.
905 modelWeights : `numpy.ndarray` or `float`
906 A 2D array of weight values that tapers smoothly to zero away from detected sources.
907 Set to a placeholder value of 1.0 if ``self.config.useModelWeights`` is False.
908 refImage : `lsst.afw.image.Image`
909 A reference image used to supply the default pixel values.
910 dcrWeights : `list` of `lsst.afw.image.Image`
911 Per-pixel weights for each subfilter.
912 Equal to 1/(number of unmasked images contributing to each pixel).
914 Returns
915 -------
916 dcrModel : `lsst.pipe.tasks.DcrModel`
917 New model of the true sky after correcting chromatic effects.
918 """
919 newModelImages = []
920 for subfilter, model in enumerate(dcrModels):
921 residualsList = [next(residualGenerator) for residualGenerator in residualGeneratorList]
922 residual = np.sum(residualsList, axis=0)
923 residual *= dcrWeights[subfilter][dcrBBox].array
924 # `MaskedImage`s only support in-place addition, so rename for readability
925 newModel = model[dcrBBox].clone()
926 newModel.array += residual
927 # Catch any invalid values
928 badPixels = ~np.isfinite(newModel.array)
929 newModel.array[badPixels] = model[dcrBBox].array[badPixels]
930 if self.config.regularizeModelIterations > 0:
931 dcrModels.regularizeModelIter(subfilter, newModel, dcrBBox,
932 self.config.regularizeModelIterations,
933 self.config.regularizationWidth)
934 newModelImages.append(newModel)
935 if self.config.regularizeModelFrequency > 0:
936 dcrModels.regularizeModelFreq(newModelImages, dcrBBox, statsCtrl,
937 self.config.regularizeModelFrequency,
938 self.config.regularizationWidth)
939 dcrModels.conditionDcrModel(newModelImages, dcrBBox, gain=gain)
940 self.applyModelWeights(newModelImages, refImage[dcrBBox], modelWeights)
941 return DcrModel(newModelImages, dcrModels.filter, dcrModels.psf,
942 dcrModels.mask, dcrModels.variance)
944 def calculateConvergence(self, dcrModels, subExposures, bbox, warpRefList, weightList, statsCtrl):
945 """Calculate a quality of fit metric for the matched templates.
947 Parameters
948 ----------
949 dcrModels : `lsst.pipe.tasks.DcrModel`
950 Best fit model of the true sky after correcting chromatic effects.
951 subExposures : `dict` of `lsst.afw.image.ExposureF`
952 The pre-loaded exposures for the current subregion.
953 bbox : `lsst.geom.box.Box2I`
954 Sub-region to coadd
955 warpRefList : `list` of `lsst.daf.butler.DeferredDatasetHandle` or
956 `lsst.daf.persistence.ButlerDataRef`
957 The data references to the input warped exposures.
958 weightList : `list` of `float`
959 The weight to give each input exposure in the coadd
960 statsCtrl : `lsst.afw.math.StatisticsControl`
961 Statistics control object for coadd
963 Returns
964 -------
965 convergenceMetric : `float`
966 Quality of fit metric for all input exposures, within the sub-region
967 """
968 significanceImage = np.abs(dcrModels.getReferenceImage(bbox))
969 nSigma = 3.
970 significanceImage += nSigma*dcrModels.calculateNoiseCutoff(dcrModels[1], statsCtrl,
971 bufferSize=self.bufferSize)
972 if np.max(significanceImage) == 0:
973 significanceImage += 1.
974 weight = 0
975 metric = 0.
976 metricList = {}
977 for warpExpRef, expWeight in zip(warpRefList, weightList):
978 visit = warpExpRef.dataId["visit"]
979 exposure = subExposures[visit][bbox]
980 singleMetric = self.calculateSingleConvergence(dcrModels, exposure, significanceImage, statsCtrl)
981 metric += singleMetric
982 metricList[visit] = singleMetric
983 weight += 1.
984 self.log.info("Individual metrics:\n%s", metricList)
985 return 1.0 if weight == 0.0 else metric/weight
987 def calculateSingleConvergence(self, dcrModels, exposure, significanceImage, statsCtrl):
988 """Calculate a quality of fit metric for a single matched template.
990 Parameters
991 ----------
992 dcrModels : `lsst.pipe.tasks.DcrModel`
993 Best fit model of the true sky after correcting chromatic effects.
994 exposure : `lsst.afw.image.ExposureF`
995 The input warped exposure to evaluate.
996 significanceImage : `numpy.ndarray`
997 Array of weights for each pixel corresponding to its significance
998 for the convergence calculation.
999 statsCtrl : `lsst.afw.math.StatisticsControl`
1000 Statistics control object for coadd
1002 Returns
1003 -------
1004 convergenceMetric : `float`
1005 Quality of fit metric for one exposure, within the sub-region.
1006 """
1007 convergeMask = exposure.mask.getPlaneBitMask(self.config.convergenceMaskPlanes)
1008 templateImage = dcrModels.buildMatchedTemplate(exposure=exposure,
1009 order=self.config.imageInterpOrder,
1010 splitSubfilters=self.config.splitSubfilters,
1011 splitThreshold=self.config.splitThreshold,
1012 amplifyModel=self.config.accelerateModel)
1013 diffVals = np.abs(exposure.image.array - templateImage.array)*significanceImage
1014 refVals = np.abs(exposure.image.array + templateImage.array)*significanceImage/2.
1016 finitePixels = np.isfinite(diffVals)
1017 goodMaskPixels = (exposure.mask.array & statsCtrl.getAndMask()) == 0
1018 convergeMaskPixels = exposure.mask.array & convergeMask > 0
1019 usePixels = finitePixels & goodMaskPixels & convergeMaskPixels
1020 if np.sum(usePixels) == 0:
1021 metric = 0.
1022 else:
1023 diffUse = diffVals[usePixels]
1024 refUse = refVals[usePixels]
1025 metric = np.sum(diffUse/np.median(diffUse))/np.sum(refUse/np.median(diffUse))
1026 return metric
1028 def stackCoadd(self, dcrCoadds):
1029 """Add a list of sub-band coadds together.
1031 Parameters
1032 ----------
1033 dcrCoadds : `list` of `lsst.afw.image.ExposureF`
1034 A list of coadd exposures, each exposure containing
1035 the model for one subfilter.
1037 Returns
1038 -------
1039 coaddExposure : `lsst.afw.image.ExposureF`
1040 A single coadd exposure that is the sum of the sub-bands.
1041 """
1042 coaddExposure = dcrCoadds[0].clone()
1043 for coadd in dcrCoadds[1:]:
1044 coaddExposure.maskedImage += coadd.maskedImage
1045 return coaddExposure
1047 def fillCoadd(self, dcrModels, skyInfo, warpRefList, weightList, calibration=None, coaddInputs=None,
1048 mask=None, variance=None):
1049 """Create a list of coadd exposures from a list of masked images.
1051 Parameters
1052 ----------
1053 dcrModels : `lsst.pipe.tasks.DcrModel`
1054 Best fit model of the true sky after correcting chromatic effects.
1055 skyInfo : `lsst.pipe.base.Struct`
1056 Patch geometry information, from getSkyInfo
1057 warpRefList : `list` of `lsst.daf.butler.DeferredDatasetHandle` or
1058 `lsst.daf.persistence.ButlerDataRef`
1059 The data references to the input warped exposures.
1060 weightList : `list` of `float`
1061 The weight to give each input exposure in the coadd
1062 calibration : `lsst.afw.Image.PhotoCalib`, optional
1063 Scale factor to set the photometric calibration of an exposure.
1064 coaddInputs : `lsst.afw.Image.CoaddInputs`, optional
1065 A record of the observations that are included in the coadd.
1066 mask : `lsst.afw.image.Mask`, optional
1067 Optional mask to override the values in the final coadd.
1068 variance : `lsst.afw.image.Image`, optional
1069 Optional variance plane to override the values in the final coadd.
1071 Returns
1072 -------
1073 dcrCoadds : `list` of `lsst.afw.image.ExposureF`
1074 A list of coadd exposures, each exposure containing
1075 the model for one subfilter.
1076 """
1077 dcrCoadds = []
1078 refModel = dcrModels.getReferenceImage()
1079 for model in dcrModels:
1080 if self.config.accelerateModel > 1:
1081 model.array = (model.array - refModel)*self.config.accelerateModel + refModel
1082 coaddExposure = afwImage.ExposureF(skyInfo.bbox, skyInfo.wcs)
1083 if calibration is not None:
1084 coaddExposure.setPhotoCalib(calibration)
1085 if coaddInputs is not None:
1086 coaddExposure.getInfo().setCoaddInputs(coaddInputs)
1087 # Set the metadata for the coadd, including PSF and aperture corrections.
1088 self.assembleMetadata(coaddExposure, warpRefList, weightList)
1089 # Overwrite the PSF
1090 coaddExposure.setPsf(dcrModels.psf)
1091 coaddUtils.setCoaddEdgeBits(dcrModels.mask[skyInfo.bbox], dcrModels.variance[skyInfo.bbox])
1092 maskedImage = afwImage.MaskedImageF(dcrModels.bbox)
1093 maskedImage.image = model
1094 maskedImage.mask = dcrModels.mask
1095 maskedImage.variance = dcrModels.variance
1096 coaddExposure.setMaskedImage(maskedImage[skyInfo.bbox])
1097 coaddExposure.setPhotoCalib(self.scaleZeroPoint.getPhotoCalib())
1098 if mask is not None:
1099 coaddExposure.setMask(mask)
1100 if variance is not None:
1101 coaddExposure.setVariance(variance)
1102 dcrCoadds.append(coaddExposure)
1103 return dcrCoadds
1105 def calculateGain(self, convergenceList, gainList):
1106 """Calculate the gain to use for the current iteration.
1108 After calculating a new DcrModel, each value is averaged with the
1109 value in the corresponding pixel from the previous iteration. This
1110 reduces oscillating solutions that iterative techniques are plagued by,
1111 and speeds convergence. By far the biggest changes to the model
1112 happen in the first couple iterations, so we can also use a more
1113 aggressive gain later when the model is changing slowly.
1115 Parameters
1116 ----------
1117 convergenceList : `list` of `float`
1118 The quality of fit metric from each previous iteration.
1119 gainList : `list` of `float`
1120 The gains used in each previous iteration: appended with the new
1121 gain value.
1122 Gains are numbers between ``self.config.baseGain`` and 1.
1124 Returns
1125 -------
1126 gain : `float`
1127 Relative weight to give the new solution when updating the model.
1128 A value of 1.0 gives equal weight to both solutions.
1130 Raises
1131 ------
1132 ValueError
1133 If ``len(convergenceList) != len(gainList)+1``.
1134 """
1135 nIter = len(convergenceList)
1136 if nIter != len(gainList) + 1:
1137 raise ValueError("convergenceList (%d) must be one element longer than gainList (%d)."
1138 % (len(convergenceList), len(gainList)))
1140 if self.config.baseGain is None:
1141 # If ``baseGain`` is not set, calculate it from the number of DCR subfilters
1142 # The more subfilters being modeled, the lower the gain should be.
1143 baseGain = 1./(self.config.dcrNumSubfilters - 1)
1144 else:
1145 baseGain = self.config.baseGain
1147 if self.config.useProgressiveGain and nIter > 2:
1148 # To calculate the best gain to use, compare the past gains that have been used
1149 # with the resulting convergences to estimate the best gain to use.
1150 # Algorithmically, this is a Kalman filter.
1151 # If forward modeling proceeds perfectly, the convergence metric should
1152 # asymptotically approach a final value.
1153 # We can estimate that value from the measured changes in convergence
1154 # weighted by the gains used in each previous iteration.
1155 estFinalConv = [((1 + gainList[i])*convergenceList[i + 1] - convergenceList[i])/gainList[i]
1156 for i in range(nIter - 1)]
1157 # The convergence metric is strictly positive, so if the estimated final convergence is
1158 # less than zero, force it to zero.
1159 estFinalConv = np.array(estFinalConv)
1160 estFinalConv[estFinalConv < 0] = 0
1161 # Because the estimate may slowly change over time, only use the most recent measurements.
1162 estFinalConv = np.median(estFinalConv[max(nIter - 5, 0):])
1163 lastGain = gainList[-1]
1164 lastConv = convergenceList[-2]
1165 newConv = convergenceList[-1]
1166 # The predicted convergence is the value we would get if the new model calculated
1167 # in the previous iteration was perfect. Recall that the updated model that is
1168 # actually used is the gain-weighted average of the new and old model,
1169 # so the convergence would be similarly weighted.
1170 predictedConv = (estFinalConv*lastGain + lastConv)/(1. + lastGain)
1171 # If the measured and predicted convergence are very close, that indicates
1172 # that our forward model is accurate and we can use a more aggressive gain
1173 # If the measured convergence is significantly worse (or better!) than predicted,
1174 # that indicates that the model is not converging as expected and
1175 # we should use a more conservative gain.
1176 delta = (predictedConv - newConv)/((lastConv - estFinalConv)/(1 + lastGain))
1177 newGain = 1 - abs(delta)
1178 # Average the gains to prevent oscillating solutions.
1179 newGain = (newGain + lastGain)/2.
1180 gain = max(baseGain, newGain)
1181 else:
1182 gain = baseGain
1183 gainList.append(gain)
1184 return gain
1186 def calculateModelWeights(self, dcrModels, dcrBBox):
1187 """Build an array that smoothly tapers to 0 away from detected sources.
1189 Parameters
1190 ----------
1191 dcrModels : `lsst.pipe.tasks.DcrModel`
1192 Best fit model of the true sky after correcting chromatic effects.
1193 dcrBBox : `lsst.geom.box.Box2I`
1194 Sub-region of the coadd which includes a buffer to allow for DCR.
1196 Returns
1197 -------
1198 weights : `numpy.ndarray` or `float`
1199 A 2D array of weight values that tapers smoothly to zero away from detected sources.
1200 Set to a placeholder value of 1.0 if ``self.config.useModelWeights`` is False.
1202 Raises
1203 ------
1204 ValueError
1205 If ``useModelWeights`` is set and ``modelWeightsWidth`` is negative.
1206 """
1207 if not self.config.useModelWeights:
1208 return 1.0
1209 if self.config.modelWeightsWidth < 0:
1210 raise ValueError("modelWeightsWidth must not be negative if useModelWeights is set")
1211 convergeMask = dcrModels.mask.getPlaneBitMask(self.config.convergenceMaskPlanes)
1212 convergeMaskPixels = dcrModels.mask[dcrBBox].array & convergeMask > 0
1213 weights = np.zeros_like(dcrModels[0][dcrBBox].array)
1214 weights[convergeMaskPixels] = 1.
1215 weights = ndimage.filters.gaussian_filter(weights, self.config.modelWeightsWidth)
1216 weights /= np.max(weights)
1217 return weights
1219 def applyModelWeights(self, modelImages, refImage, modelWeights):
1220 """Smoothly replace model pixel values with those from a
1221 reference at locations away from detected sources.
1223 Parameters
1224 ----------
1225 modelImages : `list` of `lsst.afw.image.Image`
1226 The new DCR model images from the current iteration.
1227 The values will be modified in place.
1228 refImage : `lsst.afw.image.MaskedImage`
1229 A reference image used to supply the default pixel values.
1230 modelWeights : `numpy.ndarray` or `float`
1231 A 2D array of weight values that tapers smoothly to zero away from detected sources.
1232 Set to a placeholder value of 1.0 if ``self.config.useModelWeights`` is False.
1233 """
1234 if self.config.useModelWeights:
1235 for model in modelImages:
1236 model.array *= modelWeights
1237 model.array += refImage.array*(1. - modelWeights)/self.config.dcrNumSubfilters
1239 def loadSubExposures(self, bbox, statsCtrl, warpRefList, imageScalerList, spanSetMaskList):
1240 """Pre-load sub-regions of a list of exposures.
1242 Parameters
1243 ----------
1244 bbox : `lsst.geom.box.Box2I`
1245 Sub-region to coadd
1246 statsCtrl : `lsst.afw.math.StatisticsControl`
1247 Statistics control object for coadd
1248 warpRefList : `list` of `lsst.daf.butler.DeferredDatasetHandle` or
1249 `lsst.daf.persistence.ButlerDataRef`
1250 The data references to the input warped exposures.
1251 imageScalerList : `list` of `lsst.pipe.task.ImageScaler`
1252 The image scalars correct for the zero point of the exposures.
1253 spanSetMaskList : `list` of `dict` containing spanSet lists, or None
1254 Each element is dict with keys = mask plane name to add the spans to
1256 Returns
1257 -------
1258 subExposures : `dict`
1259 The `dict` keys are the visit IDs,
1260 and the values are `lsst.afw.image.ExposureF`
1261 The pre-loaded exposures for the current subregion.
1262 The variance plane contains weights, and not the variance
1263 """
1264 tempExpName = self.getTempExpDatasetName(self.warpType)
1265 zipIterables = zip(warpRefList, imageScalerList, spanSetMaskList)
1266 subExposures = {}
1267 for warpExpRef, imageScaler, altMaskSpans in zipIterables:
1268 if isinstance(warpExpRef, DeferredDatasetHandle):
1269 exposure = warpExpRef.get(parameters={'bbox': bbox})
1270 else:
1271 exposure = warpExpRef.get(tempExpName + "_sub", bbox=bbox)
1272 visit = warpExpRef.dataId["visit"]
1273 if altMaskSpans is not None:
1274 self.applyAltMaskPlanes(exposure.mask, altMaskSpans)
1275 imageScaler.scaleMaskedImage(exposure.maskedImage)
1276 # Note that the variance plane here is used to store weights, not the actual variance
1277 exposure.variance.array[:, :] = 0.
1278 # Set the weight of unmasked pixels to 1.
1279 exposure.variance.array[(exposure.mask.array & statsCtrl.getAndMask()) == 0] = 1.
1280 # Set the image value of masked pixels to zero.
1281 # This eliminates needing the mask plane when stacking images in ``newModelFromResidual``
1282 exposure.image.array[(exposure.mask.array & statsCtrl.getAndMask()) > 0] = 0.
1283 subExposures[visit] = exposure
1284 return subExposures
1286 def selectCoaddPsf(self, templateCoadd, warpRefList):
1287 """Compute the PSF of the coadd from the exposures with the best seeing.
1289 Parameters
1290 ----------
1291 templateCoadd : `lsst.afw.image.ExposureF`
1292 The initial coadd exposure before accounting for DCR.
1293 warpRefList : `list` of `lsst.daf.butler.DeferredDatasetHandle` or
1294 `lsst.daf.persistence.ButlerDataRef`
1295 The data references to the input warped exposures.
1297 Returns
1298 -------
1299 psf : `lsst.meas.algorithms.CoaddPsf`
1300 The average PSF of the input exposures with the best seeing.
1301 """
1302 sigma2fwhm = 2.*np.sqrt(2.*np.log(2.))
1303 tempExpName = self.getTempExpDatasetName(self.warpType)
1304 # Note: ``ccds`` is a `lsst.afw.table.ExposureCatalog` with one entry per ccd and per visit
1305 # If there are multiple ccds, it will have that many times more elements than ``warpExpRef``
1306 ccds = templateCoadd.getInfo().getCoaddInputs().ccds
1307 psfRefSize = templateCoadd.getPsf().computeShape().getDeterminantRadius()*sigma2fwhm
1308 psfSizes = np.zeros(len(ccds))
1309 ccdVisits = np.array(ccds["visit"])
1310 for warpExpRef in warpRefList:
1311 if isinstance(warpExpRef, DeferredDatasetHandle):
1312 # Gen 3 API
1313 psf = warpExpRef.get(component="psf")
1314 else:
1315 # Gen 2 API. Delete this when Gen 2 retired
1316 psf = warpExpRef.get(tempExpName).getPsf()
1317 visit = warpExpRef.dataId["visit"]
1318 psfSize = psf.computeShape().getDeterminantRadius()*sigma2fwhm
1319 psfSizes[ccdVisits == visit] = psfSize
1320 # Note that the input PSFs include DCR, which should be absent from the DcrCoadd
1321 # The selected PSFs are those that have a FWHM less than or equal to the smaller
1322 # of the mean or median FWHM of the input exposures.
1323 sizeThreshold = min(np.median(psfSizes), psfRefSize)
1324 goodPsfs = psfSizes <= sizeThreshold
1325 psf = measAlg.CoaddPsf(ccds[goodPsfs], templateCoadd.getWcs(),
1326 self.config.coaddPsf.makeControl())
1327 return psf