lsst.pipe.tasks g7ab1c79000+4bbf02a586
Loading...
Searching...
No Matches
computeExposureSummaryStats.py
Go to the documentation of this file.
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/>.
21
22__all__ = ["ComputeExposureSummaryStatsTask", "ComputeExposureSummaryStatsConfig"]
23
24import warnings
25import numpy as np
26from scipy.stats import median_abs_deviation as sigmaMad
27import astropy.units as units
28from astropy.time import Time
29from astropy.coordinates import AltAz, SkyCoord, EarthLocation
30from lsst.daf.base import DateTime
31
32import lsst.pipe.base as pipeBase
33import lsst.pex.config as pexConfig
34import lsst.afw.math as afwMath
35import lsst.afw.image as afwImage
36import lsst.geom
37from lsst.utils.timer import timeMethod
38
39
40class ComputeExposureSummaryStatsConfig(pexConfig.Config):
41 """Config for ComputeExposureSummaryTask"""
42 sigmaClip = pexConfig.Field(
43 dtype=float,
44 doc="Sigma for outlier rejection for sky noise.",
45 default=3.0,
46 )
47 clipIter = pexConfig.Field(
48 dtype=int,
49 doc="Number of iterations of outlier rejection for sky noise.",
50 default=2,
51 )
52 badMaskPlanes = pexConfig.ListField(
53 dtype=str,
54 doc="Mask planes that, if set, the associated pixel should not be included sky noise calculation.",
55 default=("NO_DATA", "SUSPECT"),
56 )
57 starSelection = pexConfig.Field(
58 doc="Field to select sources to be used in the PSF statistics computation.",
59 dtype=str,
60 default="calib_psf_used"
61 )
62 starShape = pexConfig.Field(
63 doc="Base name of columns to use for the source shape in the PSF statistics computation.",
64 dtype=str,
65 default="base_SdssShape"
66 )
67 psfShape = pexConfig.Field(
68 doc="Base name of columns to use for the PSF shape in the PSF statistics computation.",
69 dtype=str,
70 default="base_SdssShape_psf"
71 )
72
73
75 """Task to compute exposure summary statistics.
76
77 This task computes various quantities suitable for DPDD and other
78 downstream processing at the detector centers, including:
79 - psfSigma
80 - psfArea
81 - psfIxx
82 - psfIyy
83 - psfIxy
84 - ra
85 - decl
86 - zenithDistance
87 - zeroPoint
88 - skyBg
89 - skyNoise
90 - meanVar
91 - raCorners
92 - decCorners
93 - astromOffsetMean
94 - astromOffsetStd
95
96 These additional quantities are computed from the stars in the detector:
97 - psfStarDeltaE1Median
98 - psfStarDeltaE2Median
99 - psfStarDeltaE1Scatter
100 - psfStarDeltaE2Scatter
101 - psfStarDeltaSizeMedian
102 - psfStarDeltaSizeScatter
103 - psfStarScaledDeltaSizeScatter
104 """
105 ConfigClass = ComputeExposureSummaryStatsConfig
106 _DefaultName = "computeExposureSummaryStats"
107
108 @timeMethod
109 def run(self, exposure, sources, background):
110 """Measure exposure statistics from the exposure, sources, and background.
111
112 Parameters
113 ----------
114 exposure : `lsst.afw.image.ExposureF`
116 background : `lsst.afw.math.BackgroundList`
117
118 Returns
119 -------
120 summary : `lsst.afw.image.ExposureSummary`
121 """
122 self.log.info("Measuring exposure statistics")
123
124 summary = afwImage.ExposureSummaryStats()
125
126 bbox = exposure.getBBox()
127
128 psf = exposure.getPsf()
129 self.update_psf_stats(summary, psf, bbox, sources, mask=exposure.mask)
130
131 wcs = exposure.getWcs()
132 visitInfo = exposure.getInfo().getVisitInfo()
133 self.update_wcs_stats(summary, wcs, bbox, visitInfo)
134
135 photoCalib = exposure.getPhotoCalib()
136 self.update_photo_calib_stats(summary, photoCalib)
137
138 self.update_background_stats(summary, background)
139
140 self.update_masked_image_stats(summary, exposure.getMaskedImage())
141
142 md = exposure.getMetadata()
143 if 'SFM_ASTROM_OFFSET_MEAN' in md:
144 summary.astromOffsetMean = md['SFM_ASTROM_OFFSET_MEAN']
145 summary.astromOffsetStd = md['SFM_ASTROM_OFFSET_STD']
146
147 return summary
148
149 def update_psf_stats(self, summary, psf, bbox, sources=None, mask=None, sources_columns=None):
150 """Compute all summary-statistic fields that depend on the PSF model.
151
152 Parameters
153 ----------
154 summary : `lsst.afw.image.ExposureSummaryStats`
155 Summary object to update in-place.
156 psf : `lsst.afw.detection.Psf` or `None`
157 Point spread function model. If `None`, all fields that depend on
158 the PSF will be reset (generally to NaN).
159 bbox : `lsst.geom.Box2I`
160 Bounding box of the image for which summary stats are being
161 computed.
162 sources : `lsst.afw.table.SourceCatalog`, optional
163 Catalog for quantities that are computed from source table columns.
164 If `None`, these quantities will be reset (generally to NaN).
165 mask : `lsst.afw.image.Mask`, optional
166 Mask image that may be used to compute distance-to-nearest-star
167 metrics.
168 sources_columns : `collections.abc.Set` [ `str` ], optional
169 Set of all column names in ``sources``. If provided, ``sources``
170 may be any table type for which string indexes yield column arrays.
171 If not provided, ``sources`` is assumed to be an
173 """
174 nan = float("nan")
175 summary.psfSigma = nan
176 summary.psfIxx = nan
177 summary.psfIyy = nan
178 summary.psfIxy = nan
179 summary.psfArea = nan
180 summary.nPsfStar = 0
181 summary.psfStarDeltaE1Median = nan
182 summary.psfStarDeltaE2Median = nan
183 summary.psfStarDeltaE1Scatter = nan
184 summary.psfStarDeltaE2Scatter = nan
185 summary.psfStarDeltaSizeMedian = nan
186 summary.psfStarDeltaSizeScatter = nan
187 summary.psfStarScaledDeltaSizeScatter = nan
188
189 if psf is None:
190 return
191 shape = psf.computeShape(bbox.getCenter())
192 summary.psfSigma = shape.getDeterminantRadius()
193 summary.psfIxx = shape.getIxx()
194 summary.psfIyy = shape.getIyy()
195 summary.psfIxy = shape.getIxy()
196 im = psf.computeKernelImage(bbox.getCenter())
197 # The calculation of effective psf area is taken from
198 # meas_base/src/PsfFlux.cc#L112. See
199 # https://github.com/lsst/meas_base/blob/
200 # 750bffe6620e565bda731add1509507f5c40c8bb/src/PsfFlux.cc#L112
201 summary.psfArea = float(np.sum(im.array)/np.sum(im.array**2.))
202
203 if sources is None:
204 # No sources are available (as in some tests)
205 return
206
207 if sources_columns is None:
208 sources_columns = sources.schema.getNames()
209 if (
210 self.config.starSelection not in sources_columns
211 or self.config.starShape + '_flag' not in sources_columns
212 ):
213 # The source catalog does not have the necessary fields (as in some tests)
214 return
215
216 mask = sources[self.config.starSelection] & (~sources[self.config.starShape + '_flag'])
217 nPsfStar = mask.sum()
218
219 if nPsfStar == 0:
220 # No stars to measure statistics, so we must return the defaults
221 # of 0 stars and NaN values.
222 return
223
224 starXX = sources[self.config.starShape + '_xx'][mask]
225 starYY = sources[self.config.starShape + '_yy'][mask]
226 starXY = sources[self.config.starShape + '_xy'][mask]
227 psfXX = sources[self.config.psfShape + '_xx'][mask]
228 psfYY = sources[self.config.psfShape + '_yy'][mask]
229 psfXY = sources[self.config.psfShape + '_xy'][mask]
230
231 starSize = (starXX*starYY - starXY**2.)**0.25
232 starE1 = (starXX - starYY)/(starXX + starYY)
233 starE2 = 2*starXY/(starXX + starYY)
234 starSizeMedian = np.median(starSize)
235
236 psfSize = (psfXX*psfYY - psfXY**2)**0.25
237 psfE1 = (psfXX - psfYY)/(psfXX + psfYY)
238 psfE2 = 2*psfXY/(psfXX + psfYY)
239
240 psfStarDeltaE1Median = np.median(starE1 - psfE1)
241 psfStarDeltaE1Scatter = sigmaMad(starE1 - psfE1, scale='normal')
242 psfStarDeltaE2Median = np.median(starE2 - psfE2)
243 psfStarDeltaE2Scatter = sigmaMad(starE2 - psfE2, scale='normal')
244
245 psfStarDeltaSizeMedian = np.median(starSize - psfSize)
246 psfStarDeltaSizeScatter = sigmaMad(starSize - psfSize, scale='normal')
247 psfStarScaledDeltaSizeScatter = psfStarDeltaSizeScatter/starSizeMedian**2.
248
249 summary.nPsfStar = int(nPsfStar)
250 summary.psfStarDeltaE1Median = float(psfStarDeltaE1Median)
251 summary.psfStarDeltaE2Median = float(psfStarDeltaE2Median)
252 summary.psfStarDeltaE1Scatter = float(psfStarDeltaE1Scatter)
253 summary.psfStarDeltaE2Scatter = float(psfStarDeltaE2Scatter)
254 summary.psfStarDeltaSizeMedian = float(psfStarDeltaSizeMedian)
255 summary.psfStarDeltaSizeScatter = float(psfStarDeltaSizeScatter)
256 summary.psfStarScaledDeltaSizeScatter = float(psfStarScaledDeltaSizeScatter)
257
258 def update_wcs_stats(self, summary, wcs, bbox, visitInfo):
259 """Compute all summary-statistic fields that depend on the WCS model.
260
261 Parameters
262 ----------
263 summary : `lsst.afw.image.ExposureSummaryStats`
264 Summary object to update in-place.
265 wcs : `lsst.afw.geom.SkyWcs` or `None`
266 Astrometric calibration model. If `None`, all fields that depend
267 on the WCS will be reset (generally to NaN).
268 bbox : `lsst.geom.Box2I`
269 Bounding box of the image for which summary stats are being
270 computed.
271 visitInfo : `lsst.afw.image.VisitInfo`
272 Observation information used in together with ``wcs`` to compute
273 the zenith distance.
274 """
275 nan = float("nan")
276 summary.raCorners = [nan]*4
277 summary.decCorners = [nan]*4
278 summary.ra = nan
279 summary.decl = nan
280 summary.zenithDistance = nan
281
282 if wcs is None:
283 return
284
285 sph_pts = wcs.pixelToSky(lsst.geom.Box2D(bbox).getCorners())
286 summary.raCorners = [float(sph.getRa().asDegrees()) for sph in sph_pts]
287 summary.decCorners = [float(sph.getDec().asDegrees()) for sph in sph_pts]
288
289 sph_pt = wcs.pixelToSky(bbox.getCenter())
290 summary.ra = sph_pt.getRa().asDegrees()
291 summary.decl = sph_pt.getDec().asDegrees()
292
293 date = visitInfo.getDate()
294
295 if date.isValid():
296 # We compute the zenithDistance at the center of the detector rather
297 # than use the boresight value available via the visitInfo, because
298 # the zenithDistance may vary significantly over a large field of view.
299 observatory = visitInfo.getObservatory()
300 loc = EarthLocation(lat=observatory.getLatitude().asDegrees()*units.deg,
301 lon=observatory.getLongitude().asDegrees()*units.deg,
302 height=observatory.getElevation()*units.m)
303 obstime = Time(visitInfo.getDate().get(system=DateTime.MJD),
304 location=loc, format='mjd')
305 coord = SkyCoord(
306 summary.ra*units.degree,
307 summary.decl*units.degree,
308 obstime=obstime,
309 location=loc,
310 )
311 with warnings.catch_warnings():
312 warnings.simplefilter('ignore')
313 altaz = coord.transform_to(AltAz)
314
315 summary.zenithDistance = float(90.0 - altaz.alt.degree)
316
317 def update_photo_calib_stats(self, summary, photo_calib):
318 """Compute all summary-statistic fields that depend on the photometric
319 calibration model.
320
321 Parameters
322 ----------
323 summary : `lsst.afw.image.ExposureSummaryStats`
324 Summary object to update in-place.
325 photo_calib : `lsst.afw.image.PhotoCalib` or `None`
326 Photometric calibration model. If `None`, all fields that depend
327 on the photometric calibration will be reset (generally to NaN).
328 """
329 if photo_calib is not None:
330 summary.zeroPoint = float(2.5*np.log10(photo_calib.getInstFluxAtZeroMagnitude()))
331 else:
332 summary.zeroPoint = float("nan")
333
334 def update_background_stats(self, summary, background):
335 """Compute summary-statistic fields that depend only on the
336 background model.
337
338 Parameters
339 ----------
340 summary : `lsst.afw.image.ExposureSummaryStats`
341 Summary object to update in-place.
342 background : `lsst.afw.math.BackgroundList` or `None`
343 Background model. If `None`, all fields that depend on the
344 background will be reset (generally to NaN).
345
346 Notes
347 -----
348 This does not include fields that depend on the background-subtracted
349 masked image; when the background changes, it should generally be
350 applied to the image and `update_masked_image_stats` should be called
351 as well.
352 """
353 if background is not None:
354 bgStats = (bg[0].getStatsImage().getImage().array
355 for bg in background)
356 summary.skyBg = float(sum(np.median(bg[np.isfinite(bg)]) for bg in bgStats))
357 else:
358 summary.skyBg = float("nan")
359
360 def update_masked_image_stats(self, summary, masked_image):
361 """Compute summary-statistic fields that depend on the masked image
362 itself.
363
364 Parameters
365 ----------
366 summary : `lsst.afw.image.ExposureSummaryStats`
367 Summary object to update in-place.
368 masked_image : `lsst.afw.image.MaskedImage` or `None`
369 Masked image. If `None`, all fields that depend
370 on the masked image will be reset (generally to NaN).
371 """
372 nan = float("nan")
373 if masked_image is None:
374 summary.skyNoise = nan
375 summary.meanVar = nan
376 return
377 statsCtrl = afwMath.StatisticsControl()
378 statsCtrl.setNumSigmaClip(self.config.sigmaClip)
379 statsCtrl.setNumIter(self.config.clipIter)
380 statsCtrl.setAndMask(afwImage.Mask.getPlaneBitMask(self.config.badMaskPlanes))
381 statsCtrl.setNanSafe(True)
382
383 statObj = afwMath.makeStatistics(masked_image, afwMath.STDEVCLIP, statsCtrl)
384 skyNoise, _ = statObj.getResult(afwMath.STDEVCLIP)
385 summary.skyNoise = skyNoise
386
387 statObj = afwMath.makeStatistics(masked_image.variance, masked_image.mask, afwMath.MEANCLIP,
388 statsCtrl)
389 meanVar, _ = statObj.getResult(afwMath.MEANCLIP)
390 summary.meanVar = meanVar
def update_psf_stats(self, summary, psf, bbox, sources=None, mask=None, sources_columns=None)