Coverage for python/lsst/pipe/tasks/snapCombine.py: 27%
140 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-07 10:59 +0000
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-07 10:59 +0000
1# This file is part of pipe_tasks.
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/>.
22__all__ = ["InitialPsfConfig", "SnapCombineConfig", "SnapCombineTask"]
24import numpy as num
25import lsst.pex.config as pexConfig
26import lsst.daf.base as dafBase
27import lsst.afw.image as afwImage
28import lsst.afw.table as afwTable
29import lsst.pipe.base as pipeBase
30from lsstDebug import getDebugFrame
31from lsst.afw.display import getDisplay
32from lsst.coadd.utils import addToCoadd, setCoaddEdgeBits
33from lsst.meas.algorithms import SourceDetectionTask
34from lsst.meas.base import SingleFrameMeasurementTask
35import lsst.meas.algorithms as measAlg
36from lsst.utils.timer import timeMethod
38from .repair import RepairTask
41class InitialPsfConfig(pexConfig.Config):
42 """Describes the initial PSF used for detection and measurement before we do PSF determination."""
44 model = pexConfig.ChoiceField(
45 dtype=str,
46 doc="PSF model type",
47 default="SingleGaussian",
48 allowed={
49 "SingleGaussian": "Single Gaussian model",
50 "DoubleGaussian": "Double Gaussian model",
51 },
52 )
53 pixelScale = pexConfig.Field(
54 dtype=float,
55 doc="Pixel size (arcsec). Only needed if no Wcs is provided",
56 default=0.25,
57 )
58 fwhm = pexConfig.Field(
59 dtype=float,
60 doc="FWHM of PSF model (arcsec)",
61 default=1.0,
62 )
63 size = pexConfig.Field(
64 dtype=int,
65 doc="Size of PSF model (pixels)",
66 default=15,
67 )
70class SnapCombineConfig(pexConfig.Config):
71 doRepair = pexConfig.Field(
72 dtype=bool,
73 doc="Repair images (CR reject and interpolate) before combining",
74 default=True,
75 )
76 repairPsfFwhm = pexConfig.Field(
77 dtype=float,
78 doc="Psf FWHM (pixels) used to detect CRs",
79 default=2.5,
80 )
81 doDiffIm = pexConfig.Field(
82 dtype=bool,
83 doc="Perform difference imaging before combining",
84 default=False,
85 )
86 doPsfMatch = pexConfig.Field(
87 dtype=bool,
88 doc="Perform PSF matching for difference imaging (ignored if doDiffIm false)",
89 default=True,
90 )
91 doMeasurement = pexConfig.Field(
92 dtype=bool,
93 doc="Measure difference sources (ignored if doDiffIm false)",
94 default=True,
95 )
96 badMaskPlanes = pexConfig.ListField(
97 dtype=str,
98 doc="Mask planes that, if set, the associated pixels are not included in the combined exposure; "
99 "DETECTED excludes cosmic rays",
100 default=("DETECTED",),
101 )
102 averageKeys = pexConfig.ListField(
103 dtype=str,
104 doc="List of float metadata keys to average when combining snaps, e.g. float positions and dates; "
105 "non-float data must be handled by overriding the fixMetadata method",
106 optional=True,
107 )
108 sumKeys = pexConfig.ListField(
109 dtype=str,
110 doc="List of float or int metadata keys to sum when combining snaps, e.g. exposure time; "
111 "non-float, non-int data must be handled by overriding the fixMetadata method",
112 optional=True,
113 )
115 repair = pexConfig.ConfigurableField(target=RepairTask, doc="RepairTask configuration")
116 # Target `SnapPsfMatchTask` removed in DM-38846
117 # diffim = pexConfig.ConfigurableField(target=SnapPsfMatchTask, doc="")
118 detection = pexConfig.ConfigurableField(
119 target=SourceDetectionTask, doc="SourceDetectionTask configuration"
120 )
121 initialPsf = pexConfig.ConfigField(dtype=InitialPsfConfig, doc="InitialPsfConfig configuration")
122 measurement = pexConfig.ConfigurableField(
123 target=SingleFrameMeasurementTask, doc="SingleFrameMeasurementTask configuration"
124 )
126 def setDefaults(self):
127 self.detection.thresholdPolarity = "both"
129 def validate(self):
130 if self.detection.thresholdPolarity != "both":
131 raise ValueError("detection.thresholdPolarity must be 'both' for SnapCombineTask")
134class SnapCombineTask(pipeBase.Task):
135 """Combine two snaps into a single visit image.
137 Notes
138 -----
139 Debugging:
140 The `~lsst.base.lsstDebug` variables in SnapCombineTask are:
142 display
143 A dictionary containing debug point names as keys with frame number as value. Valid keys are:
145 .. code-block:: none
147 repair0
148 Display the first snap after repairing.
149 repair1
150 Display the second snap after repairing.
151 """
153 ConfigClass = SnapCombineConfig
154 _DefaultName = "snapCombine"
156 def __init__(self, *args, **kwargs):
157 pipeBase.Task.__init__(self, *args, **kwargs)
158 self.makeSubtask("repair")
159 self.schema = afwTable.SourceTable.makeMinimalSchema()
160 self.algMetadata = dafBase.PropertyList()
161 self.makeSubtask("detection", schema=self.schema)
162 if self.config.doMeasurement:
163 self.makeSubtask("measurement", schema=self.schema, algMetadata=self.algMetadata)
165 @timeMethod
166 def run(self, snap0, snap1, defects=None):
167 """Combine two snaps.
169 Parameters
170 ----------
171 snap0 : `Unknown`
172 Snapshot exposure 0.
173 snap1 : `Unknown`
174 Snapshot exposure 1.
175 defects : `list` or `None`, optional
176 Defect list (for repair task).
178 Returns
179 -------
180 result : `lsst.pipe.base.Struct`
181 Results as a struct with attributes:
183 ``exposure``
184 Snap-combined exposure.
185 ``sources``
186 Detected sources, or `None` if detection not performed.
187 """
188 # initialize optional outputs
189 sources = None
191 if self.config.doRepair:
192 self.log.info("snapCombine repair")
193 psf = self.makeInitialPsf(snap0, fwhmPix=self.config.repairPsfFwhm)
194 snap0.setPsf(psf)
195 snap1.setPsf(psf)
196 self.repair.run(snap0, defects=defects, keepCRs=False)
197 self.repair.run(snap1, defects=defects, keepCRs=False)
199 repair0frame = getDebugFrame(self._display, "repair0")
200 if repair0frame:
201 getDisplay(repair0frame).mtv(snap0)
202 repair1frame = getDebugFrame(self._display, "repair1")
203 if repair1frame:
204 getDisplay(repair1frame).mtv(snap1)
206 if self.config.doDiffIm:
207 if self.config.doPsfMatch:
208 raise NotImplementedError("PSF-matching of snaps is not yet supported.")
210 else:
211 diffExp = afwImage.ExposureF(snap0, True)
212 diffMi = diffExp.getMaskedImage()
213 diffMi -= snap1.getMaskedImage()
215 psf = self.makeInitialPsf(snap0)
216 diffExp.setPsf(psf)
217 table = afwTable.SourceTable.make(self.schema)
218 table.setMetadata(self.algMetadata)
219 detRet = self.detection.run(table, diffExp)
220 sources = detRet.sources
221 if self.config.doMeasurement:
222 self.measurement.measure(diffExp, sources)
224 mask0 = snap0.getMaskedImage().getMask()
225 mask1 = snap1.getMaskedImage().getMask()
226 detRet.positive.setMask(mask0, "DETECTED")
227 detRet.negative.setMask(mask1, "DETECTED")
229 maskD = diffExp.getMaskedImage().getMask()
230 detRet.positive.setMask(maskD, "DETECTED")
231 detRet.negative.setMask(maskD, "DETECTED_NEGATIVE")
233 combinedExp = self.addSnaps(snap0, snap1)
235 return pipeBase.Struct(
236 exposure=combinedExp,
237 sources=sources,
238 )
240 def addSnaps(self, snap0, snap1):
241 """Add two snap exposures together, returning a new exposure.
243 Parameters
244 ----------
245 snap0 : `Unknown`
246 Snap exposure 0.
247 snap1 : `Unknown`
248 Snap exposure 1.
250 Returns
251 -------
252 combinedExp : `Unknown`
253 Combined exposure.
254 """
255 self.log.info("snapCombine addSnaps")
257 combinedExp = snap0.Factory(snap0, True)
258 combinedMi = combinedExp.getMaskedImage()
259 combinedMi.set(0)
261 weightMap = combinedMi.getImage().Factory(combinedMi.getBBox())
262 weight = 1.0
263 badPixelMask = afwImage.Mask.getPlaneBitMask(self.config.badMaskPlanes)
264 addToCoadd(combinedMi, weightMap, snap0.getMaskedImage(), badPixelMask, weight)
265 addToCoadd(combinedMi, weightMap, snap1.getMaskedImage(), badPixelMask, weight)
267 # pre-scaling the weight map instead of post-scaling the combinedMi saves a bit of time
268 # because the weight map is a simple Image instead of a MaskedImage
269 weightMap *= 0.5 # so result is sum of both images, instead of average
270 combinedMi /= weightMap
271 setCoaddEdgeBits(combinedMi.getMask(), weightMap)
273 # note: none of the inputs has a valid PhotoCalib object, so that is not touched
274 # Filter was already copied
276 combinedMetadata = combinedExp.getMetadata()
277 metadata0 = snap0.getMetadata()
278 metadata1 = snap1.getMetadata()
279 self.fixMetadata(combinedMetadata, metadata0, metadata1)
281 return combinedExp
283 def fixMetadata(self, combinedMetadata, metadata0, metadata1):
284 """Fix the metadata of the combined exposure (in place).
286 This implementation handles items specified by config.averageKeys and config.sumKeys,
287 which have data type restrictions. To handle other data types (such as sexagesimal
288 positions and ISO dates) you must supplement this method with your own code.
290 Parameters
291 ----------
292 combinedMetadata : `lsst.daf.base.PropertySet`
293 Metadata of combined exposure;
294 on input this is a deep copy of metadata0 (a PropertySet).
295 metadata0 : `lsst.daf.base.PropertySet`
296 Metadata of snap0 (a PropertySet).
297 metadata1 : `lsst.daf.base.PropertySet`
298 Metadata of snap1 (a PropertySet).
300 Notes
301 -----
302 The inputs are presently PropertySets due to ticket #2542. However, in some sense
303 they are just PropertyLists that are missing some methods. In particular: comments and order
304 are preserved if you alter an existing value with set(key, value).
305 """
306 keyDoAvgList = []
307 if self.config.averageKeys:
308 keyDoAvgList += [(key, 1) for key in self.config.averageKeys]
309 if self.config.sumKeys:
310 keyDoAvgList += [(key, 0) for key in self.config.sumKeys]
311 for key, doAvg in keyDoAvgList:
312 opStr = "average" if doAvg else "sum"
313 try:
314 val0 = metadata0.getScalar(key)
315 val1 = metadata1.getScalar(key)
316 except Exception:
317 self.log.warning("Could not %s metadata %r: missing from one or both exposures", opStr, key)
318 continue
320 try:
321 combinedVal = val0 + val1
322 if doAvg:
323 combinedVal /= 2.0
324 except Exception:
325 self.log.warning("Could not %s metadata %r: value %r and/or %r not numeric",
326 opStr, key, val0, val1)
327 continue
329 combinedMetadata.set(key, combinedVal)
331 def makeInitialPsf(self, exposure, fwhmPix=None):
332 """Initialise the detection procedure by setting the PSF and WCS.
334 exposure : `lsst.afw.image.Exposure`
335 Exposure to process.
337 Returns
338 -------
339 psf : `Unknown`
340 PSF, WCS
342 AssertionError
343 Raised if any of the following occur:
344 - No exposure provided.
345 - No wcs in exposure.
346 """
347 assert exposure, "No exposure provided"
348 wcs = exposure.getWcs()
349 assert wcs, "No wcs in exposure"
351 if fwhmPix is None:
352 fwhmPix = self.config.initialPsf.fwhm / wcs.getPixelScale().asArcseconds()
354 size = self.config.initialPsf.size
355 model = self.config.initialPsf.model
356 self.log.info("installInitialPsf fwhm=%s pixels; size=%s pixels", fwhmPix, size)
357 psfCls = getattr(measAlg, model + "Psf")
358 psf = psfCls(size, size, fwhmPix/(2.0*num.sqrt(2*num.log(2.0))))
359 return psf