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