Coverage for python/lsst/pipe/tasks/computeExposureSummaryStats.py: 16%
156 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-12-30 01:39 -0800
« prev ^ index » next coverage.py v6.5.0, created at 2022-12-30 01:39 -0800
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__ = ["ComputeExposureSummaryStatsTask", "ComputeExposureSummaryStatsConfig"]
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
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
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 )
74class ComputeExposureSummaryStatsTask(pipeBase.Task):
75 """Task to compute exposure summary statistics.
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
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"
108 @timeMethod
109 def run(self, exposure, sources, background):
110 """Measure exposure statistics from the exposure, sources, and background.
112 Parameters
113 ----------
114 exposure : `lsst.afw.image.ExposureF`
115 sources : `lsst.afw.table.SourceCatalog`
116 background : `lsst.afw.math.BackgroundList`
118 Returns
119 -------
120 summary : `lsst.afw.image.ExposureSummary`
121 """
122 self.log.info("Measuring exposure statistics")
124 summary = afwImage.ExposureSummaryStats()
126 bbox = exposure.getBBox()
128 psf = exposure.getPsf()
129 self.update_psf_stats(summary, psf, bbox, sources, mask=exposure.mask)
131 wcs = exposure.getWcs()
132 visitInfo = exposure.getInfo().getVisitInfo()
133 self.update_wcs_stats(summary, wcs, bbox, visitInfo)
135 photoCalib = exposure.getPhotoCalib()
136 self.update_photo_calib_stats(summary, photoCalib)
138 self.update_background_stats(summary, background)
140 self.update_masked_image_stats(summary, exposure.getMaskedImage())
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']
147 return summary
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.
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
172 `lsst.afw.table.SourceCatalog`.
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
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.))
203 if sources is None:
204 # No sources are available (as in some tests)
205 return
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
216 mask = sources[self.config.starSelection] & (~sources[self.config.starShape + '_flag'])
217 nPsfStar = mask.sum()
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
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]
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)
236 psfSize = (psfXX*psfYY - psfXY**2)**0.25
237 psfE1 = (psfXX - psfYY)/(psfXX + psfYY)
238 psfE2 = 2*psfXY/(psfXX + psfYY)
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')
245 psfStarDeltaSizeMedian = np.median(starSize - psfSize)
246 psfStarDeltaSizeScatter = sigmaMad(starSize - psfSize, scale='normal')
247 psfStarScaledDeltaSizeScatter = psfStarDeltaSizeScatter/starSizeMedian**2.
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)
258 def update_wcs_stats(self, summary, wcs, bbox, visitInfo):
259 """Compute all summary-statistic fields that depend on the WCS model.
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
282 if wcs is None:
283 return
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]
289 sph_pt = wcs.pixelToSky(bbox.getCenter())
290 summary.ra = sph_pt.getRa().asDegrees()
291 summary.decl = sph_pt.getDec().asDegrees()
293 date = visitInfo.getDate()
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)
315 summary.zenithDistance = float(90.0 - altaz.alt.degree)
317 def update_photo_calib_stats(self, summary, photo_calib):
318 """Compute all summary-statistic fields that depend on the photometric
319 calibration model.
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")
334 def update_background_stats(self, summary, background):
335 """Compute summary-statistic fields that depend only on the
336 background model.
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).
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")
360 def update_masked_image_stats(self, summary, masked_image):
361 """Compute summary-statistic fields that depend on the masked image
362 itself.
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)
383 statObj = afwMath.makeStatistics(masked_image, afwMath.STDEVCLIP, statsCtrl)
384 skyNoise, _ = statObj.getResult(afwMath.STDEVCLIP)
385 summary.skyNoise = skyNoise
387 statObj = afwMath.makeStatistics(masked_image.variance, masked_image.mask, afwMath.MEANCLIP,
388 statsCtrl)
389 meanVar, _ = statObj.getResult(afwMath.MEANCLIP)
390 summary.meanVar = meanVar