Coverage for python/lsst/cp/verify/verifyStats.py: 27%
179 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-19 02:48 -0700
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-19 02:48 -0700
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(
40 pipeBase.PipelineTaskConnections,
41 dimensions={"instrument", "exposure", "detector"},
42 defaultTemplates={},
43):
44 inputExp = cT.Input(
45 name="postISRCCD",
46 doc="Input exposure to calculate statistics for.",
47 storageClass="Exposure",
48 dimensions=["instrument", "exposure", "detector"],
49 )
50 uncorrectedExp = cT.Input(
51 name="uncorrectedExp",
52 doc="Uncorrected input exposure to calculate statistics for.",
53 storageClass="ExposureF",
54 dimensions=["instrument", "visit", "detector"],
55 )
56 taskMetadata = cT.Input(
57 name="isrTask_metadata",
58 doc="Input task metadata to extract statistics from.",
59 storageClass="TaskMetadata",
60 dimensions=["instrument", "exposure", "detector"],
61 )
62 inputCatalog = cT.Input(
63 name="src",
64 doc="Input catalog to calculate statistics for.",
65 storageClass="SourceCatalog",
66 dimensions=["instrument", "visit", "detector"],
67 )
68 uncorrectedCatalog = cT.Input(
69 name="uncorrectedSrc",
70 doc="Input catalog without correction applied.",
71 storageClass="SourceCatalog",
72 dimensions=["instrument", "visit", "detector"],
73 )
74 camera = cT.PrerequisiteInput(
75 name="camera",
76 storageClass="Camera",
77 doc="Input camera.",
78 dimensions=["instrument", ],
79 isCalibration=True,
80 )
81 isrStatistics = cT.Input(
82 name="isrStatistics",
83 storageClass="StructuredDataDict",
84 doc="Pre-calculated statistics from IsrTask.",
85 dimensions=["instrument", "exposure", "detector"],
86 )
88 outputStats = cT.Output(
89 name="detectorStats",
90 doc="Output statistics from cp_verify.",
91 storageClass="StructuredDataDict",
92 dimensions=["instrument", "exposure", "detector"],
93 )
95 def __init__(self, *, config=None):
96 super().__init__(config=config)
98 if len(config.metadataStatKeywords) < 1:
99 self.inputs.discard("taskMetadata")
101 if len(config.catalogStatKeywords) < 1:
102 self.inputs.discard("inputCatalog")
103 self.inputs.discard("uncorrectedCatalog")
105 if len(config.uncorrectedImageStatKeywords) < 1:
106 self.inputs.discard("uncorrectedExp")
108 if config.useIsrStatistics is not True:
109 self.inputs.discard("isrStatistics")
112class CpVerifyStatsConfig(
113 pipeBase.PipelineTaskConfig, pipelineConnections=CpVerifyStatsConnections
114):
115 """Configuration parameters for CpVerifyStatsTask."""
117 maskNameList = pexConfig.ListField(
118 dtype=str,
119 doc="Mask list to exclude from statistics calculations.",
120 default=["DETECTED", "BAD", "NO_DATA"],
121 )
122 doVignette = pexConfig.Field(
123 dtype=bool,
124 doc="Mask vignetted regions?",
125 default=False,
126 )
127 doNormalize = pexConfig.Field(
128 dtype=bool,
129 doc="Normalize by exposure time?",
130 default=False,
131 )
133 # Cosmic ray handling options.
134 doCR = pexConfig.Field(
135 dtype=bool,
136 doc="Run CR rejection?",
137 default=False,
138 )
139 repair = pexConfig.ConfigurableField(
140 target=RepairTask,
141 doc="Repair task to use.",
142 )
143 psfFwhm = pexConfig.Field(
144 dtype=float,
145 default=3.0,
146 doc="Repair PSF FWHM (pixels).",
147 )
148 psfSize = pexConfig.Field(
149 dtype=int,
150 default=21,
151 doc="Repair PSF bounding-box size (pixels).",
152 )
153 crGrow = pexConfig.Field(
154 dtype=int,
155 default=0,
156 doc="Grow radius for CR (pixels).",
157 )
159 # Statistics options.
160 useReadNoise = pexConfig.Field(
161 dtype=bool,
162 doc="Compare sigma against read noise?",
163 default=True,
164 )
165 numSigmaClip = pexConfig.Field(
166 dtype=float,
167 doc="Rejection threshold (sigma) for statistics clipping.",
168 default=5.0,
169 )
170 clipMaxIter = pexConfig.Field(
171 dtype=int,
172 doc="Max number of clipping iterations to apply.",
173 default=3,
174 )
176 # Keywords and statistics to measure from different sources.
177 imageStatKeywords = pexConfig.DictField(
178 keytype=str,
179 itemtype=str,
180 doc="Image statistics to run on amplifier segments.",
181 default={},
182 )
183 unmaskedImageStatKeywords = pexConfig.DictField(
184 keytype=str,
185 itemtype=str,
186 doc="Image statistics to run on amplifier segments, ignoring masks.",
187 default={},
188 )
189 uncorrectedImageStatKeywords = pexConfig.DictField(
190 keytype=str,
191 itemtype=str,
192 doc="Uncorrected image statistics to run on amplifier segments.",
193 default={},
194 )
195 crImageStatKeywords = pexConfig.DictField(
196 keytype=str,
197 itemtype=str,
198 doc="Image statistics to run on CR cleaned amplifier segments.",
199 default={},
200 )
201 normImageStatKeywords = pexConfig.DictField(
202 keytype=str,
203 itemtype=str,
204 doc="Image statistics to run on expTime normalized amplifier segments.",
205 default={},
206 )
207 metadataStatKeywords = pexConfig.DictField(
208 keytype=str,
209 itemtype=str,
210 doc="Statistics to measure from the metadata of the exposure.",
211 default={},
212 )
213 catalogStatKeywords = pexConfig.DictField(
214 keytype=str,
215 itemtype=str,
216 doc="Statistics to measure from source catalogs of objects in the exposure.",
217 default={},
218 )
219 detectorStatKeywords = pexConfig.DictField(
220 keytype=str,
221 itemtype=str,
222 doc="Statistics to create for the full detector from the per-amplifier measurements.",
223 default={},
224 )
225 useIsrStatistics = pexConfig.Field(
226 dtype=bool,
227 doc="Use statistics calculated by IsrTask?",
228 default=False,
229 )
232class CpVerifyStatsTask(pipeBase.PipelineTask):
233 """Main statistic measurement and validation class.
235 This operates on a single (exposure, detector) pair, and is
236 designed to be subclassed so specific calibrations can apply their
237 own validation methods.
238 """
240 ConfigClass = CpVerifyStatsConfig
241 _DefaultName = "cpVerifyStats"
243 def __init__(self, **kwargs):
244 super().__init__(**kwargs)
245 self.makeSubtask("repair")
247 def run(
248 self,
249 inputExp,
250 camera,
251 isrStatistics=None,
252 uncorrectedExp=None,
253 taskMetadata=None,
254 inputCatalog=None,
255 uncorrectedCatalog=None,
256 ):
257 """Calculate quality statistics and verify they meet the requirements
258 for a calibration.
260 Parameters
261 ----------
262 inputExp : `lsst.afw.image.Exposure`
263 The ISR processed exposure to be measured.
264 camera : `lsst.afw.cameraGeom.Camera`
265 The camera geometry for ``inputExp``.
266 uncorrectedExp : `lsst.afw.image.Exposure`
267 The alternate exposure to measure.
268 taskMetadata : `lsst.pipe.base.TaskMetadata`, optional
269 Task metadata containing additional statistics.
270 inputCatalog : `lsst.afw.image.Table`
271 The source catalog to measure.
272 uncorrectedCatalog : `lsst.afw.image.Table`
273 The alternate source catalog to measure.
275 Returns
276 -------
277 result : `lsst.pipe.base.Struct`
278 Result struct with components:
279 - ``outputStats`` : `dict`
280 The output measured statistics.
282 Notes
283 -----
284 The outputStats should have a yaml representation of the form
286 AMP:
287 Amp1:
288 STAT: value
289 STAT2: value2
290 Amp2:
291 Amp3:
292 DET:
293 STAT: value
294 STAT2: value
295 CATALOG:
296 STAT: value
297 STAT2: value
298 VERIFY:
299 DET:
300 TEST: boolean
301 CATALOG:
302 TEST: boolean
303 AMP:
304 Amp1:
305 TEST: boolean
306 TEST2: boolean
307 Amp2:
308 Amp3:
309 SUCCESS: boolean
311 """
312 outputStats = {}
314 if self.config.doVignette:
315 polygon = inputExp.getInfo().getValidPolygon()
316 maskVignettedRegion(
317 inputExp, polygon, maskPlane="NO_DATA", vignetteValue=None, log=self.log
318 )
320 mask = inputExp.getMask()
321 maskVal = mask.getPlaneBitMask(self.config.maskNameList)
322 statControl = afwMath.StatisticsControl(
323 self.config.numSigmaClip, self.config.clipMaxIter, maskVal
324 )
326 # This is wrapped below to check for config lengths, as we can
327 # make a number of different image stats.
328 outputStats["AMP"] = self.imageStatistics(inputExp, uncorrectedExp, statControl)
330 if len(self.config.metadataStatKeywords):
331 # These are also defined on a amp-by-amp basis.
332 outputStats["METADATA"] = self.metadataStatistics(inputExp, taskMetadata)
333 else:
334 outputStats["METADATA"] = {}
336 if len(self.config.catalogStatKeywords):
337 outputStats["CATALOG"] = self.catalogStatistics(
338 inputExp, inputCatalog, uncorrectedCatalog, statControl
339 )
340 else:
341 outputStats["CATALOG"] = {}
342 if len(self.config.detectorStatKeywords):
343 outputStats["DET"] = self.detectorStatistics(
344 outputStats, statControl, inputExp, uncorrectedExp
345 )
346 else:
347 outputStats["DET"] = {}
349 if self.config.useIsrStatistics:
350 outputStats["ISR"] = isrStatistics
352 outputStats["VERIFY"], outputStats["SUCCESS"] = self.verify(
353 inputExp, outputStats
354 )
356 return pipeBase.Struct(
357 outputStats=outputStats,
358 )
360 @staticmethod
361 def _emptyAmpDict(exposure):
362 """Construct empty dictionary indexed by amplifier names.
364 Parameters
365 ----------
366 exposure : `lsst.afw.image.Exposure`
367 Exposure to extract detector from.
369 Returns
370 -------
371 outputStatistics : `dict` [`str`, `dict`]
372 A skeleton statistics dictionary.
374 Raises
375 ------
376 RuntimeError :
377 Raised if no detector can be found.
378 """
379 outputStatistics = {}
380 detector = exposure.getDetector()
381 if detector is None:
382 raise RuntimeError("No detector found in exposure!")
384 for amp in detector.getAmplifiers():
385 outputStatistics[amp.getName()] = {}
387 return outputStatistics
389 # Image measurement methods.
390 def imageStatistics(self, exposure, uncorrectedExposure, statControl):
391 """Measure image statistics for a number of simple image
392 modifications.
394 Parameters
395 ----------
396 exposure : `lsst.afw.image.Exposure`
397 Exposure containing the ISR processed data to measure.
398 uncorrectedExposure: `lsst.afw.image.Exposure`
399 Uncorrected exposure containing the ISR processed data to measure.
400 statControl : `lsst.afw.math.StatisticsControl`
401 Statistics control object with parameters defined by
402 the config.
404 Returns
405 -------
406 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
407 A dictionary indexed by the amplifier name, containing
408 dictionaries of the statistics measured and their values.
410 """
411 outputStatistics = self._emptyAmpDict(exposure)
413 if len(self.config.imageStatKeywords):
414 outputStatistics = mergeStatDict(
415 outputStatistics,
416 self.amplifierStats(
417 exposure, self.config.imageStatKeywords, statControl
418 ),
419 )
420 if len(self.config.uncorrectedImageStatKeywords):
421 outputStatistics = mergeStatDict(
422 outputStatistics,
423 self.amplifierStats(
424 uncorrectedExposure,
425 self.config.uncorrectedImageStatKeywords,
426 statControl,
427 ),
428 )
429 if len(self.config.unmaskedImageStatKeywords):
430 outputStatistics = mergeStatDict(
431 outputStatistics, self.unmaskedImageStats(exposure)
432 )
434 if len(self.config.normImageStatKeywords):
435 outputStatistics = mergeStatDict(
436 outputStatistics, self.normalizedImageStats(exposure, statControl)
437 )
439 if len(self.config.crImageStatKeywords):
440 outputStatistics = mergeStatDict(
441 outputStatistics, self.crImageStats(exposure, statControl)
442 )
444 return outputStatistics
446 @staticmethod
447 def _configHelper(keywordDict):
448 """Helper to convert keyword dictionary to stat value.
450 Convert the string names in the keywordDict to the afwMath values.
451 The statisticToRun is then the bitwise-or of that set.
453 Parameters
454 ----------
455 keywordDict : `dict` [`str`, `str`]
456 A dictionary of keys to use in the output results, with
457 values the string name associated with the
458 `lsst.afw.math.statistics.Property` to measure.
460 Returns
461 -------
462 statisticToRun : `int`
463 The merged `lsst.afw.math` statistics property.
464 statAccessor : `dict` [`str`, `int`]
465 Dictionary containing statistics property indexed by name.
466 """
467 statisticToRun = 0
468 statAccessor = {}
469 for k, v in keywordDict.items():
470 statValue = afwMath.stringToStatisticsProperty(v)
471 statisticToRun |= statValue
472 statAccessor[k] = statValue
474 return statisticToRun, statAccessor
476 def metadataStatistics(self, exposure, taskMetadata):
477 """Extract task metadata information for verification.
479 Parameters
480 ----------
481 exposure : `lsst.afw.image.Exposure`
482 The exposure to measure.
483 taskMetadata : `lsst.pipe.base.TaskMetadata`
484 The metadata to extract values from.
486 Returns
487 -------
488 ampStats : `dict` [`str`, `dict` [`str`, scalar]]
489 A dictionary indexed by the amplifier name, containing
490 dictionaries of the statistics measured and their values.
491 """
492 metadataStats = {}
493 keywordDict = self.config.metadataStatKeywords
495 if taskMetadata:
496 for key, value in keywordDict.items():
497 if value == "AMP":
498 metadataStats[key] = {}
499 for ampIdx, amp in enumerate(exposure.getDetector()):
500 ampName = amp.getName()
501 expectedKey = f"{key} {ampName}"
502 metadataStats[key][ampName] = None
503 for name in taskMetadata:
504 if expectedKey in taskMetadata[name]:
505 metadataStats[key][ampName] = taskMetadata[name][
506 expectedKey
507 ]
508 else:
509 # Assume it's detector-wide.
510 expectedKey = key
511 for name in taskMetadata:
512 if expectedKey in taskMetadata[name]:
513 metadataStats[key] = taskMetadata[name][expectedKey]
514 return metadataStats
516 def amplifierStats(self, exposure, keywordDict, statControl, failAll=False):
517 """Measure amplifier level statistics from the exposure.
519 Parameters
520 ----------
521 exposure : `lsst.afw.image.Exposure`
522 The exposure to measure.
523 keywordDict : `dict` [`str`, `str`]
524 A dictionary of keys to use in the output results, with
525 values the string name associated with the
526 `lsst.afw.math.statistics.Property` to measure.
527 statControl : `lsst.afw.math.StatisticsControl`
528 Statistics control object with parameters defined by
529 the config.
530 failAll : `bool`, optional
531 If True, all tests will be set as failed.
533 Returns
534 -------
535 ampStats : `dict` [`str`, `dict` [`str`, scalar]]
536 A dictionary indexed by the amplifier name, containing
537 dictionaries of the statistics measured and their values.
538 """
539 ampStats = {}
540 statisticToRun, statAccessor = self._configHelper(keywordDict)
541 # Measure stats on all amplifiers.
542 for ampIdx, amp in enumerate(exposure.getDetector()):
543 ampName = amp.getName()
544 theseStats = {}
545 ampExp = exposure.Factory(exposure, amp.getBBox())
546 stats = afwMath.makeStatistics(
547 ampExp.getMaskedImage(), statisticToRun, statControl
548 )
550 for k, v in statAccessor.items():
551 theseStats[k] = stats.getValue(v)
553 if failAll:
554 theseStats["FORCE_FAILURE"] = failAll
555 ampStats[ampName] = theseStats
557 return ampStats
559 def unmaskedImageStats(self, exposure):
560 """Measure amplifier level statistics on the exposure, including all
561 pixels in the exposure, regardless of any mask planes set.
563 Parameters
564 ----------
565 exposure : `lsst.afw.image.Exposure`
566 The exposure to measure.
568 Returns
569 -------
570 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
571 A dictionary indexed by the amplifier name, containing
572 dictionaries of the statistics measured and their values.
573 """
574 noMaskStatsControl = afwMath.StatisticsControl(
575 self.config.numSigmaClip, self.config.clipMaxIter, 0x0
576 )
577 return self.amplifierStats(
578 exposure, self.config.unmaskedImageStatKeywords, noMaskStatsControl
579 )
581 def normalizedImageStats(self, exposure, statControl):
582 """Measure amplifier level statistics on the exposure after dividing
583 by the exposure time.
585 Parameters
586 ----------
587 exposure : `lsst.afw.image.Exposure`
588 The exposure to measure.
589 statControl : `lsst.afw.math.StatisticsControl`
590 Statistics control object with parameters defined by
591 the config.
593 Returns
594 -------
595 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
596 A dictionary indexed by the amplifier name, containing
597 dictionaries of the statistics measured and their values.
599 Raises
600 ------
601 RuntimeError :
602 Raised if the exposure time cannot be used for normalization.
603 """
604 scaledExposure = exposure.clone()
605 exposureTime = scaledExposure.getInfo().getVisitInfo().getExposureTime()
606 if exposureTime <= 0:
607 raise RuntimeError(f"Invalid exposureTime {exposureTime}.")
608 mi = scaledExposure.getMaskedImage()
609 mi /= exposureTime
611 return self.amplifierStats(
612 scaledExposure, self.config.normImageStatKeywords, statControl
613 )
615 def crImageStats(self, exposure, statControl):
616 """Measure amplifier level statistics on the exposure,
617 after running cosmic ray rejection.
619 Parameters
620 ----------
621 exposure : `lsst.afw.image.Exposure`
622 The exposure to measure.
623 statControl : `lsst.afw.math.StatisticsControl`
624 Statistics control object with parameters defined by
625 the config.
627 Returns
628 -------
629 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
630 A dictionary indexed by the amplifier name, containing
631 dictionaries of the statistics measured and their values.
633 """
634 crRejectedExp = exposure.clone()
635 psf = measAlg.SingleGaussianPsf(
636 self.config.psfSize,
637 self.config.psfSize,
638 self.config.psfFwhm / (2 * math.sqrt(2 * math.log(2))),
639 )
640 crRejectedExp.setPsf(psf)
641 try:
642 self.repair.run(crRejectedExp, keepCRs=False)
643 failAll = False
644 except pexException.LengthError:
645 self.log.warning(
646 "Failure masking cosmic rays (too many found). Continuing."
647 )
648 failAll = True
650 if self.config.crGrow > 0:
651 crMask = crRejectedExp.getMaskedImage().getMask().getPlaneBitMask("CR")
652 spans = afwGeom.SpanSet.fromMask(crRejectedExp.mask, crMask)
653 spans = spans.dilated(self.config.crGrow)
654 spans = spans.clippedTo(crRejectedExp.getBBox())
655 spans.setMask(crRejectedExp.mask, crMask)
657 return self.amplifierStats(
658 crRejectedExp, self.config.crImageStatKeywords, statControl, failAll=failAll
659 )
661 # Methods that need to be implemented by the calibration-level subclasses.
662 def catalogStatistics(self, exposure, catalog, uncorrectedCatalog, statControl):
663 """Calculate statistics from a catalog.
665 Parameters
666 ----------
667 exposure : `lsst.afw.image.Exposure`
668 The exposure to measure.
669 catalog : `lsst.afw.table.Table`
670 The catalog to measure.
671 uncorrectedCatalog : `lsst.afw.table.Table`
672 The alternate catalog to measure.
673 statControl : `lsst.afw.math.StatisticsControl`
674 Statistics control object with parameters defined by
675 the config.
677 Returns
678 -------
679 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
680 A dictionary indexed by the amplifier name, containing
681 dictionaries of the statistics measured and their values.
682 """
683 raise NotImplementedError(
684 "Subclasses must implement catalog statistics method."
685 )
687 def detectorStatistics(
688 self, statisticsDict, statControl, exposure=None, uncorrectedExposure=None
689 ):
690 """Calculate detector level statistics based on the existing
691 per-amplifier measurements.
693 Parameters
694 ----------
695 statisticsDict : `dict` [`str`, scalar]
696 Dictionary of measured statistics. The inner dictionary
697 should have keys that are statistic names (`str`) with
698 values that are some sort of scalar (`int` or `float` are
699 the mostly likely types).
700 statControl : `lsst.afw.math.StatControl`
701 Statistics control object with parameters defined by
702 the config.
703 exposure : `lsst.afw.image.Exposure`, optional
704 Exposure containing the ISR-processed data to measure.
705 uncorrectedExposure : `lsst.afw.image.Exposure`, optional
706 uncorrected esposure (no defects) containing the
707 ISR-processed data to measure.
709 Returns
710 -------
711 outputStatistics : `dict` [`str`, scalar]
712 A dictionary of the statistics measured and their values.
714 Raises
715 ------
716 NotImplementedError :
717 This method must be implemented by the calibration-type
718 subclass.
719 """
720 raise NotImplementedError(
721 "Subclasses must implement detector statistics method."
722 )
724 def verify(self, exposure, statisticsDict):
725 """Verify that the measured statistics meet the verification criteria.
727 Parameters
728 ----------
729 exposure : `lsst.afw.image.Exposure`
730 The exposure the statistics are from.
731 statisticsDictionary : `dict` [`str`, `dict` [`str`, scalar]],
732 Dictionary of measured statistics. The inner dictionary
733 should have keys that are statistic names (`str`) with
734 values that are some sort of scalar (`int` or `float` are
735 the mostly likely types).
737 Returns
738 -------
739 outputStatistics : `dict` [`str`, `dict` [`str`, `bool`]]
740 A dictionary indexed by the amplifier name, containing
741 dictionaries of the verification criteria.
742 success : `bool`
743 A boolean indicating whether all tests have passed.
745 Raises
746 ------
747 NotImplementedError :
748 This method must be implemented by the calibration-type
749 subclass.
750 """
751 raise NotImplementedError("Subclasses must implement verification criteria.")