Coverage for python/lsst/cp/verify/verifyStats.py: 28%
Shortcuts 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
Shortcuts 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 cp_verify.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://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 <http://www.gnu.org/licenses/>.
21import math
23import lsst.afw.geom as afwGeom
24import lsst.afw.math as afwMath
25import lsst.pex.config as pexConfig
26import lsst.pex.exceptions as pexException
27import lsst.pipe.base as pipeBase
28import lsst.pipe.base.connectionTypes as cT
29import lsst.meas.algorithms as measAlg
31from lsst.ip.isr.vignette import maskVignettedRegion
32from lsst.pipe.tasks.repair import RepairTask
33from .utils import mergeStatDict
36__all__ = ['CpVerifyStatsConfig', 'CpVerifyStatsTask']
39class CpVerifyStatsConnections(pipeBase.PipelineTaskConnections,
40 dimensions={"instrument", "exposure", "detector"},
41 defaultTemplates={}):
42 inputExp = cT.Input(
43 name="postISRCCD",
44 doc="Input exposure to calculate statistics for.",
45 storageClass="Exposure",
46 dimensions=["instrument", "exposure", "detector"],
47 )
48 taskMetadata = cT.Input(
49 name="isrTask_metadata",
50 doc="Input task metadata to extract statistics from.",
51 storageClass="PropertySet",
52 dimensions=["instrument", "exposure", "detector"],
53 )
54 camera = cT.PrerequisiteInput(
55 name="camera",
56 storageClass="Camera",
57 doc="Input camera.",
58 dimensions=["instrument", ],
59 isCalibration=True,
60 )
62 outputStats = cT.Output(
63 name="detectorStats",
64 doc="Output statistics from cp_verify.",
65 storageClass="StructuredDataDict",
66 dimensions=["instrument", "exposure", "detector"],
67 )
69 def __init__(self, *, config=None):
70 super().__init__(config=config)
72 if len(config.metadataStatKeywords) < 1:
73 self.inputs.discard('taskMetadata')
76class CpVerifyStatsConfig(pipeBase.PipelineTaskConfig,
77 pipelineConnections=CpVerifyStatsConnections):
78 """Configuration parameters for CpVerifyStatsTask.
79 """
80 maskNameList = pexConfig.ListField(
81 dtype=str,
82 doc="Mask list to exclude from statistics calculations.",
83 default=['DETECTED', 'BAD', 'NO_DATA'],
84 )
85 doVignette = pexConfig.Field(
86 dtype=bool,
87 doc="Mask vignetted regions?",
88 default=False,
89 )
90 doNormalize = pexConfig.Field(
91 dtype=bool,
92 doc="Normalize by exposure time?",
93 default=False,
94 )
96 # Cosmic ray handling options.
97 doCR = pexConfig.Field(
98 dtype=bool,
99 doc="Run CR rejection?",
100 default=False,
101 )
102 repair = pexConfig.ConfigurableField(
103 target=RepairTask,
104 doc="Repair task to use.",
105 )
106 psfFwhm = pexConfig.Field(
107 dtype=float,
108 default=3.0,
109 doc="Repair PSF FWHM (pixels).",
110 )
111 psfSize = pexConfig.Field(
112 dtype=int,
113 default=21,
114 doc="Repair PSF bounding-box size (pixels).",
115 )
116 crGrow = pexConfig.Field(
117 dtype=int,
118 default=0,
119 doc="Grow radius for CR (pixels).",
120 )
122 # Statistics options.
123 useReadNoise = pexConfig.Field(
124 dtype=bool,
125 doc="Compare sigma against read noise?",
126 default=True,
127 )
128 numSigmaClip = pexConfig.Field(
129 dtype=float,
130 doc="Rejection threshold (sigma) for statistics clipping.",
131 default=5.0,
132 )
133 clipMaxIter = pexConfig.Field(
134 dtype=int,
135 doc="Max number of clipping iterations to apply.",
136 default=3,
137 )
139 # Keywords and statistics to measure from different sources.
140 imageStatKeywords = pexConfig.DictField(
141 keytype=str,
142 itemtype=str,
143 doc="Image statistics to run on amplifier segments.",
144 default={},
145 )
146 unmaskedImageStatKeywords = pexConfig.DictField(
147 keytype=str,
148 itemtype=str,
149 doc="Image statistics to run on amplifier segments, ignoring masks.",
150 default={},
151 )
152 crImageStatKeywords = pexConfig.DictField(
153 keytype=str,
154 itemtype=str,
155 doc="Image statistics to run on CR cleaned amplifier segments.",
156 default={},
157 )
158 normImageStatKeywords = pexConfig.DictField(
159 keytype=str,
160 itemtype=str,
161 doc="Image statistics to run on expTime normalized amplifier segments.",
162 default={},
163 )
164 metadataStatKeywords = pexConfig.DictField(
165 keytype=str,
166 itemtype=str,
167 doc="Statistics to measure from the metadata of the exposure.",
168 default={},
169 )
170 catalogStatKeywords = pexConfig.DictField(
171 keytype=str,
172 itemtype=str,
173 doc="Statistics to measure from source catalogs of objects in the exposure.",
174 default={},
175 )
176 detectorStatKeywords = pexConfig.DictField(
177 keytype=str,
178 itemtype=str,
179 doc="Statistics to create for the full detector from the per-amplifier measurements.",
180 default={},
181 )
184class CpVerifyStatsTask(pipeBase.PipelineTask, pipeBase.CmdLineTask):
185 """Main statistic measurement and validation class.
187 This operates on a single (exposure, detector) pair, and is
188 designed to be subclassed so specific calibrations can apply their
189 own validation methods.
190 """
191 ConfigClass = CpVerifyStatsConfig
192 _DefaultName = 'cpVerifyStats'
194 def __init__(self, **kwargs):
195 super().__init__(**kwargs)
196 self.makeSubtask("repair")
198 def run(self, inputExp, camera, taskMetadata=None):
199 """Calculate quality statistics and verify they meet the requirements
200 for a calibration.
202 Parameters
203 ----------
204 inputExp : `lsst.afw.image.Exposure`
205 The ISR processed exposure to be measured.
206 taskMetadata : `lsst.daf.base.PropertySet`, optional
207 Task metadata containing additional statistics.
208 camera : `lsst.afw.cameraGeom.Camera`
209 The camera geometry for ``inputExp``.
211 Returns
212 -------
213 result : `lsst.pipe.base.Struct`
214 Result struct with components:
215 - ``outputStats`` : `dict`
216 The output measured statistics.
218 Notes
219 -----
220 The outputStats should have a yaml representation of the form
222 AMP:
223 Amp1:
224 STAT: value
225 STAT2: value2
226 Amp2:
227 Amp3:
228 DET:
229 STAT: value
230 STAT2: value
231 CATALOG:
232 STAT: value
233 STAT2: value
234 VERIFY:
235 DET:
236 TEST: boolean
237 CATALOG:
238 TEST: boolean
239 AMP:
240 Amp1:
241 TEST: boolean
242 TEST2: boolean
243 Amp2:
244 Amp3:
245 SUCCESS: boolean
247 """
248 outputStats = {}
250 if self.config.doVignette:
251 polygon = inputExp.getInfo().getValidPolygon()
252 maskVignettedRegion(inputExp, polygon, maskPlane='NO_DATA',
253 vignetteValue=None, log=self.log)
255 mask = inputExp.getMask()
256 maskVal = mask.getPlaneBitMask(self.config.maskNameList)
257 statControl = afwMath.StatisticsControl(self.config.numSigmaClip,
258 self.config.clipMaxIter,
259 maskVal)
261 # This is wrapped below to check for config lengths, as we can
262 # make a number of different image stats.
263 outputStats['AMP'] = self.imageStatistics(inputExp, statControl)
265 if len(self.config.metadataStatKeywords):
266 # These are also defined on a amp-by-amp basis.
267 outputStats['METADATA'] = self.metadataStatistics(inputExp, taskMetadata)
268 else:
269 outputStats['METADATA'] = {}
271 if len(self.config.catalogStatKeywords):
272 outputStats['CATALOG'] = self.catalogStatistics(inputExp, statControl)
273 else:
274 outputStats['CATALOG'] = {}
275 if len(self.config.detectorStatKeywords):
276 outputStats['DET'] = self.detectorStatistics(outputStats, statControl)
277 else:
278 outputStats['DET'] = {}
280 outputStats['VERIFY'], outputStats['SUCCESS'] = self.verify(inputExp, outputStats)
282 return pipeBase.Struct(
283 outputStats=outputStats,
284 )
286 @staticmethod
287 def _emptyAmpDict(exposure):
288 """Construct empty dictionary indexed by amplifier names.
290 Parameters
291 ----------
292 exposure : `lsst.afw.image.Exposure`
293 Exposure to extract detector from.
295 Returns
296 -------
297 outputStatistics : `dict` [`str`, `dict`]
298 A skeleton statistics dictionary.
300 Raises
301 ------
302 RuntimeError :
303 Raised if no detector can be found.
304 """
305 outputStatistics = {}
306 detector = exposure.getDetector()
307 if detector is None:
308 raise RuntimeError("No detector found in exposure!")
310 for amp in detector.getAmplifiers():
311 outputStatistics[amp.getName()] = {}
313 return outputStatistics
315 # Image measurement methods.
316 def imageStatistics(self, exposure, statControl):
317 """Measure image statistics for a number of simple image
318 modifications.
320 Parameters
321 ----------
322 exposure : `lsst.afw.image.Exposure`
323 Exposure containing the ISR processed data to measure.
324 statControl : `lsst.afw.math.StatisticsControl`
325 Statistics control object with parameters defined by
326 the config.
328 Returns
329 -------
330 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
331 A dictionary indexed by the amplifier name, containing
332 dictionaries of the statistics measured and their values.
334 """
335 outputStatistics = self._emptyAmpDict(exposure)
337 if len(self.config.imageStatKeywords):
338 outputStatistics = mergeStatDict(outputStatistics,
339 self.amplifierStats(exposure,
340 self.config.imageStatKeywords,
341 statControl))
342 if len(self.config.unmaskedImageStatKeywords):
343 outputStatistics = mergeStatDict(outputStatistics, self.unmaskedImageStats(exposure))
345 if len(self.config.normImageStatKeywords):
346 outputStatistics = mergeStatDict(outputStatistics,
347 self.normalizedImageStats(exposure, statControl))
349 if len(self.config.crImageStatKeywords):
350 outputStatistics = mergeStatDict(outputStatistics,
351 self.crImageStats(exposure, statControl))
353 return outputStatistics
355 @staticmethod
356 def _configHelper(keywordDict):
357 """Helper to convert keyword dictionary to stat value.
359 Convert the string names in the keywordDict to the afwMath values.
360 The statisticToRun is then the bitwise-or of that set.
362 Parameters
363 ----------
364 keywordDict : `dict` [`str`, `str`]
365 A dictionary of keys to use in the output results, with
366 values the string name associated with the
367 `lsst.afw.math.statistics.Property` to measure.
369 Returns
370 -------
371 statisticToRun : `int`
372 The merged `lsst.afw.math` statistics property.
373 statAccessor : `dict` [`str`, `int`]
374 Dictionary containing statistics property indexed by name.
375 """
376 statisticToRun = 0
377 statAccessor = {}
378 for k, v in keywordDict.items():
379 statValue = afwMath.stringToStatisticsProperty(v)
380 statisticToRun |= statValue
381 statAccessor[k] = statValue
383 return statisticToRun, statAccessor
385 def metadataStatistics(self, exposure, taskMetadata):
386 """Extract task metadata information for verification.
388 Parameters
389 ----------
390 exposure : `lsst.afw.image.Exposure`
391 The exposure to measure.
392 taskMetadata : `lsst.daf.base.PropertySet`
393 The metadata to extract values from.
395 Returns
396 -------
397 ampStats : `dict` [`str`, `dict` [`str`, scalar]]
398 A dictionary indexed by the amplifier name, containing
399 dictionaries of the statistics measured and their values.
400 """
401 metadataStats = {}
402 keywordDict = self.config.metadataStatKeywords
404 if taskMetadata:
405 for key, value in keywordDict.items():
406 if value == 'AMP':
407 metadataStats[key] = {}
408 for ampIdx, amp in enumerate(exposure.getDetector()):
409 ampName = amp.getName()
410 expectedKey = f"{key} {ampName}"
411 metadataStats[key][ampName] = None
412 for name in taskMetadata.names():
413 if expectedKey in taskMetadata[name]:
414 metadataStats[key][ampName] = taskMetadata[name][expectedKey]
415 else:
416 # Assume it's detector-wide.
417 expectedKey = key
418 for name in taskMetadata.names():
419 if expectedKey in taskMetadata[name]:
420 metadataStats[key] = taskMetadata[name][expectedKey]
421 return metadataStats
423 def amplifierStats(self, exposure, keywordDict, statControl, failAll=False):
424 """Measure amplifier level statistics from the exposure.
426 Parameters
427 ----------
428 exposure : `lsst.afw.image.Exposure`
429 The exposure to measure.
430 keywordDict : `dict` [`str`, `str`]
431 A dictionary of keys to use in the output results, with
432 values the string name associated with the
433 `lsst.afw.math.statistics.Property` to measure.
434 statControl : `lsst.afw.math.StatisticsControl`
435 Statistics control object with parameters defined by
436 the config.
437 failAll : `bool`, optional
438 If True, all tests will be set as failed.
440 Returns
441 -------
442 ampStats : `dict` [`str`, `dict` [`str`, scalar]]
443 A dictionary indexed by the amplifier name, containing
444 dictionaries of the statistics measured and their values.
445 """
446 ampStats = {}
448 statisticToRun, statAccessor = self._configHelper(keywordDict)
450 # Measure stats on all amplifiers.
451 for ampIdx, amp in enumerate(exposure.getDetector()):
452 ampName = amp.getName()
453 theseStats = {}
454 ampExp = exposure.Factory(exposure, amp.getBBox())
455 stats = afwMath.makeStatistics(ampExp.getMaskedImage(), statisticToRun, statControl)
457 for k, v in statAccessor.items():
458 theseStats[k] = stats.getValue(v)
460 if failAll:
461 theseStats['FORCE_FAILURE'] = failAll
462 ampStats[ampName] = theseStats
464 return ampStats
466 def unmaskedImageStats(self, exposure):
467 """Measure amplifier level statistics on the exposure, including all
468 pixels in the exposure, regardless of any mask planes set.
470 Parameters
471 ----------
472 exposure : `lsst.afw.image.Exposure`
473 The exposure to measure.
475 Returns
476 -------
477 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
478 A dictionary indexed by the amplifier name, containing
479 dictionaries of the statistics measured and their values.
480 """
481 noMaskStatsControl = afwMath.StatisticsControl(self.config.numSigmaClip,
482 self.config.clipMaxIter,
483 0x0)
484 return self.amplifierStats(exposure, self.config.unmaskedImageStatKeywords, noMaskStatsControl)
486 def normalizedImageStats(self, exposure, statControl):
487 """Measure amplifier level statistics on the exposure after dividing
488 by the exposure time.
490 Parameters
491 ----------
492 exposure : `lsst.afw.image.Exposure`
493 The exposure to measure.
494 statControl : `lsst.afw.math.StatisticsControl`
495 Statistics control object with parameters defined by
496 the config.
498 Returns
499 -------
500 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
501 A dictionary indexed by the amplifier name, containing
502 dictionaries of the statistics measured and their values.
504 Raises
505 ------
506 RuntimeError :
507 Raised if the exposure time cannot be used for normalization.
508 """
509 scaledExposure = exposure.clone()
510 exposureTime = scaledExposure.getInfo().getVisitInfo().getExposureTime()
511 if exposureTime <= 0:
512 raise RuntimeError(f"Invalid exposureTime {exposureTime}.")
513 mi = scaledExposure.getMaskedImage()
514 mi /= exposureTime
516 return self.amplifierStats(scaledExposure, self.config.normImageStatKeywords, statControl)
518 def crImageStats(self, exposure, statControl):
519 """Measure amplifier level statistics on the exposure,
520 after running cosmic ray rejection.
522 Parameters
523 ----------
524 exposure : `lsst.afw.image.Exposure`
525 The exposure to measure.
526 statControl : `lsst.afw.math.StatisticsControl`
527 Statistics control object with parameters defined by
528 the config.
530 Returns
531 -------
532 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
533 A dictionary indexed by the amplifier name, containing
534 dictionaries of the statistics measured and their values.
536 """
537 crRejectedExp = exposure.clone()
538 psf = measAlg.SingleGaussianPsf(self.config.psfSize,
539 self.config.psfSize,
540 self.config.psfFwhm/(2*math.sqrt(2*math.log(2))))
541 crRejectedExp.setPsf(psf)
542 try:
543 self.repair.run(crRejectedExp, keepCRs=False)
544 failAll = False
545 except pexException.LengthError:
546 self.log.warning("Failure masking cosmic rays (too many found). Continuing.")
547 failAll = True
549 if self.config.crGrow > 0:
550 crMask = crRejectedExp.getMaskedImage().getMask().getPlaneBitMask("CR")
551 spans = afwGeom.SpanSet.fromMask(crRejectedExp.mask, crMask)
552 spans = spans.dilated(self.config.crGrow)
553 spans = spans.clippedTo(crRejectedExp.getBBox())
554 spans.setMask(crRejectedExp.mask, crMask)
556 return self.amplifierStats(crRejectedExp, self.config.crImageStatKeywords,
557 statControl, failAll=failAll)
559 # Methods that need to be implemented by the calibration-level subclasses.
560 def catalogStatistics(self, exposure, statControl):
561 """Calculate statistics from a catalog.
563 Parameters
564 ----------
565 exposure : `lsst.afw.image.Exposure`
566 The exposure to measure.
567 statControl : `lsst.afw.math.StatisticsControl`
568 Statistics control object with parameters defined by
569 the config.
571 Returns
572 -------
573 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
574 A dictionary indexed by the amplifier name, containing
575 dictionaries of the statistics measured and their values.
576 """
577 raise NotImplementedError("Subclasses must implement catalog statistics method.")
579 def detectorStatistics(self, statisticsDict, statControl):
580 """Calculate detector level statistics based on the existing
581 per-amplifier measurements.
583 Parameters
584 ----------
585 statisticsDictionary : `dict` [`str`, `dict` [`str`, scalar]],
586 Dictionary of measured statistics. The inner dictionary
587 should have keys that are statistic names (`str`) with
588 values that are some sort of scalar (`int` or `float` are
589 the mostly likely types).
591 Returns
592 -------
593 outputStatistics : `dict` [`str`, scalar]
594 A dictionary of the statistics measured and their values.
596 Raises
597 ------
598 NotImplementedError :
599 This method must be implemented by the calibration-type
600 subclass.
601 """
602 raise NotImplementedError("Subclasses must implement detector statistics method.")
604 def verify(self, exposure, statisticsDict):
605 """Verify that the measured statistics meet the verification criteria.
607 Parameters
608 ----------
609 exposure : `lsst.afw.image.Exposure`
610 The exposure the statistics are from.
611 statisticsDictionary : `dict` [`str`, `dict` [`str`, scalar]],
612 Dictionary of measured statistics. The inner dictionary
613 should have keys that are statistic names (`str`) with
614 values that are some sort of scalar (`int` or `float` are
615 the mostly likely types).
617 Returns
618 -------
619 outputStatistics : `dict` [`str`, `dict` [`str`, `bool`]]
620 A dictionary indexed by the amplifier name, containing
621 dictionaries of the verification criteria.
622 success : `bool`
623 A boolean indicating whether all tests have passed.
625 Raises
626 ------
627 NotImplementedError :
628 This method must be implemented by the calibration-type
629 subclass.
630 """
631 raise NotImplementedError("Subclasses must implement verification criteria.")