Coverage for python/lsst/cp/pipe/defects.py: 19%
380 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-30 03:54 -0700
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-30 03:54 -0700
1# This file is part of cp_pipe.
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#
23__all__ = ['MeasureDefectsTaskConfig', 'MeasureDefectsTask',
24 'MergeDefectsTaskConfig', 'MergeDefectsTask',
25 'MeasureDefectsCombinedTaskConfig', 'MeasureDefectsCombinedTask',
26 'MergeDefectsCombinedTaskConfig', 'MergeDefectsCombinedTask', ]
28import numpy as np
30import lsst.pipe.base as pipeBase
31import lsst.pipe.base.connectionTypes as cT
33from lsstDebug import getDebugFrame
34import lsst.pex.config as pexConfig
36import lsst.afw.image as afwImage
37import lsst.afw.math as afwMath
38import lsst.afw.detection as afwDetection
39import lsst.afw.display as afwDisplay
40from lsst.afw import cameraGeom
41from lsst.geom import Box2I, Point2I, Extent2I
42from lsst.meas.algorithms import SourceDetectionTask
43from lsst.ip.isr import Defects, countMaskedPixels
44from lsst.pex.exceptions import InvalidParameterError
47class MeasureDefectsConnections(pipeBase.PipelineTaskConnections,
48 dimensions=("instrument", "exposure", "detector")):
49 inputExp = cT.Input(
50 name="defectExps",
51 doc="Input ISR-processed exposures to measure.",
52 storageClass="Exposure",
53 dimensions=("instrument", "detector", "exposure"),
54 multiple=False
55 )
56 camera = cT.PrerequisiteInput(
57 name='camera',
58 doc="Camera associated with this exposure.",
59 storageClass="Camera",
60 dimensions=("instrument", ),
61 isCalibration=True,
62 )
64 outputDefects = cT.Output(
65 name="singleExpDefects",
66 doc="Output measured defects.",
67 storageClass="Defects",
68 dimensions=("instrument", "detector", "exposure"),
69 )
72class MeasureDefectsTaskConfig(pipeBase.PipelineTaskConfig,
73 pipelineConnections=MeasureDefectsConnections):
74 """Configuration for measuring defects from a list of exposures
75 """
77 thresholdType = pexConfig.ChoiceField(
78 dtype=str,
79 doc=("Defects threshold type: ``STDEV`` or ``VALUE``. If ``VALUE``, cold pixels will be found "
80 "in flats, and hot pixels in darks. If ``STDEV``, cold and hot pixels will be found "
81 "in flats, and hot pixels in darks."),
82 default='STDEV',
83 allowed={'STDEV': "Use a multiple of the image standard deviation to determine detection threshold.",
84 'VALUE': "Use pixel value to determine detection threshold."},
85 )
86 darkCurrentThreshold = pexConfig.Field(
87 dtype=float,
88 doc=("If thresholdType=``VALUE``, dark current threshold (in e-/sec) to define "
89 "hot/bright pixels in dark images. Unused if thresholdType==``STDEV``."),
90 default=5,
91 )
92 biasThreshold = pexConfig.Field(
93 dtype=float,
94 doc=("If thresholdType==``VALUE``, bias threshold (in ADU) to define "
95 "hot/bright pixels in bias frame. Unused if thresholdType==``STDEV``."),
96 default=1000.0,
97 )
98 fracThresholdFlat = pexConfig.Field(
99 dtype=float,
100 doc=("If thresholdType=``VALUE``, fractional threshold to define cold/dark "
101 "pixels in flat images (fraction of the mean value per amplifier)."
102 "Unused if thresholdType==``STDEV``."),
103 default=0.8,
104 )
105 nSigmaBright = pexConfig.Field(
106 dtype=float,
107 doc=("If thresholdType=``STDEV``, number of sigma above mean for bright/hot "
108 "pixel detection. The default value was found to be "
109 "appropriate for some LSST sensors in DM-17490. "
110 "Unused if thresholdType==``VALUE``"),
111 default=4.8,
112 )
113 nSigmaDark = pexConfig.Field(
114 dtype=float,
115 doc=("If thresholdType=``STDEV``, number of sigma below mean for dark/cold pixel "
116 "detection. The default value was found to be "
117 "appropriate for some LSST sensors in DM-17490. "
118 "Unused if thresholdType==``VALUE``"),
119 default=-5.0,
120 )
121 nPixBorderUpDown = pexConfig.Field(
122 dtype=int,
123 doc="Number of pixels to exclude from top & bottom of image when looking for defects.",
124 default=0,
125 )
126 nPixBorderLeftRight = pexConfig.Field(
127 dtype=int,
128 doc="Number of pixels to exclude from left & right of image when looking for defects.",
129 default=0,
130 )
131 badOnAndOffPixelColumnThreshold = pexConfig.Field(
132 dtype=int,
133 doc=("If BPC is the set of all the bad pixels in a given column (not necessarily consecutive) "
134 "and the size of BPC is at least 'badOnAndOffPixelColumnThreshold', all the pixels between the "
135 "pixels that satisfy minY (BPC) and maxY (BPC) will be marked as bad, with 'Y' being the long "
136 "axis of the amplifier (and 'X' the other axis, which for a column is a constant for all "
137 "pixels in the set BPC). If there are more than 'goodPixelColumnGapThreshold' consecutive "
138 "non-bad pixels in BPC, an exception to the above is made and those consecutive "
139 "'goodPixelColumnGapThreshold' are not marked as bad."),
140 default=50,
141 )
142 goodPixelColumnGapThreshold = pexConfig.Field(
143 dtype=int,
144 doc=("Size, in pixels, of usable consecutive pixels in a column with on and off bad pixels (see "
145 "'badOnAndOffPixelColumnThreshold')."),
146 default=30,
147 )
149 def validate(self):
150 super().validate()
151 if self.nSigmaBright < 0.0:
152 raise ValueError("nSigmaBright must be above 0.0.")
153 if self.nSigmaDark > 0.0:
154 raise ValueError("nSigmaDark must be below 0.0.")
157class MeasureDefectsTask(pipeBase.PipelineTask):
158 """Measure the defects from one exposure.
159 """
161 ConfigClass = MeasureDefectsTaskConfig
162 _DefaultName = 'cpDefectMeasure'
164 def run(self, inputExp, camera):
165 """Measure one exposure for defects.
167 Parameters
168 ----------
169 inputExp : `lsst.afw.image.Exposure`
170 Exposure to examine.
171 camera : `lsst.afw.cameraGeom.Camera`
172 Camera to use for metadata.
174 Returns
175 -------
176 results : `lsst.pipe.base.Struct`
177 Results struct containing:
179 ``outputDefects``
180 The defects measured from this exposure
181 (`lsst.ip.isr.Defects`).
182 """
183 detector = inputExp.getDetector()
184 try:
185 filterName = inputExp.getFilter().physicalLabel
186 except AttributeError:
187 filterName = None
189 defects = self._findHotAndColdPixels(inputExp)
191 datasetType = inputExp.getMetadata().get('IMGTYPE', 'UNKNOWN')
192 msg = "Found %s defects containing %s pixels in %s"
193 self.log.info(msg, len(defects), self._nPixFromDefects(defects), datasetType)
195 defects.updateMetadataFromExposures([inputExp])
196 defects.updateMetadata(camera=camera, detector=detector, filterName=filterName,
197 setCalibId=True, setDate=True,
198 cpDefectGenImageType=datasetType)
200 return pipeBase.Struct(
201 outputDefects=defects,
202 )
204 @staticmethod
205 def _nPixFromDefects(defects):
206 """Count pixels in a defect.
208 Parameters
209 ----------
210 defects : `lsst.ip.isr.Defects`
211 Defects to measure.
213 Returns
214 -------
215 nPix : `int`
216 Number of defect pixels.
217 """
218 nPix = 0
219 for defect in defects:
220 nPix += defect.getBBox().getArea()
221 return nPix
223 def _findHotAndColdPixels(self, exp):
224 """Find hot and cold pixels in an image.
226 Using config-defined thresholds on a per-amp basis, mask
227 pixels that are nSigma above threshold in dark frames (hot
228 pixels), or nSigma away from the clipped mean in flats (hot &
229 cold pixels).
231 Parameters
232 ----------
233 exp : `lsst.afw.image.exposure.Exposure`
234 The exposure in which to find defects.
236 Returns
237 -------
238 defects : `lsst.ip.isr.Defects`
239 The defects found in the image.
240 """
241 self._setEdgeBits(exp)
242 maskedIm = exp.maskedImage
244 # the detection polarity for afwDetection, True for positive,
245 # False for negative, and therefore True for darks as they only have
246 # bright pixels, and both for flats, as they have bright and dark pix
247 footprintList = []
249 hotPixelCount = {}
250 coldPixelCount = {}
252 for amp in exp.getDetector():
253 ampName = amp.getName()
255 hotPixelCount[ampName] = 0
256 coldPixelCount[ampName] = 0
258 ampImg = maskedIm[amp.getBBox()].clone()
260 # crop ampImage depending on where the amp lies in the image
261 if self.config.nPixBorderLeftRight:
262 if ampImg.getX0() == 0:
263 ampImg = ampImg[self.config.nPixBorderLeftRight:, :, afwImage.LOCAL]
264 else:
265 ampImg = ampImg[:-self.config.nPixBorderLeftRight, :, afwImage.LOCAL]
266 if self.config.nPixBorderUpDown:
267 if ampImg.getY0() == 0:
268 ampImg = ampImg[:, self.config.nPixBorderUpDown:, afwImage.LOCAL]
269 else:
270 ampImg = ampImg[:, :-self.config.nPixBorderUpDown, afwImage.LOCAL]
272 if self._getNumGoodPixels(ampImg) == 0: # amp contains no usable pixels
273 continue
275 # Remove a background estimate
276 meanClip = afwMath.makeStatistics(ampImg, afwMath.MEANCLIP, ).getValue()
277 ampImg -= meanClip
279 # Determine thresholds
280 stDev = afwMath.makeStatistics(ampImg, afwMath.STDEVCLIP, ).getValue()
281 expTime = exp.getInfo().getVisitInfo().getExposureTime()
282 datasetType = exp.getMetadata().get('IMGTYPE', 'UNKNOWN')
283 if np.isnan(expTime):
284 self.log.warning("expTime=%s for AMP %s in %s. Setting expTime to 1 second",
285 expTime, ampName, datasetType)
286 expTime = 1.
287 thresholdType = self.config.thresholdType
288 if thresholdType == 'VALUE':
289 # LCA-128 and eoTest: bright/hot pixels in dark images are
290 # defined as any pixel with more than 5 e-/s of dark current.
291 # We scale by the exposure time.
292 if datasetType.lower() == 'dark':
293 # hot pixel threshold
294 valueThreshold = self.config.darkCurrentThreshold*expTime/amp.getGain()
295 elif datasetType.lower() == 'bias':
296 # hot pixel threshold, no exposure time.
297 valueThreshold = self.config.biasThreshold
298 else:
299 # LCA-128 and eoTest: dark/cold pixels in flat images as
300 # defined as any pixel with photoresponse <80% of
301 # the mean (at 500nm).
303 # We subtracted the mean above, so the threshold will be
304 # negative cold pixel threshold.
305 valueThreshold = (self.config.fracThresholdFlat-1)*meanClip
306 # Find equivalent sigma values.
307 if stDev == 0.0:
308 self.log.warning("stDev=%s for AMP %s in %s. Setting nSigma to inf.",
309 stDev, ampName, datasetType)
310 nSigmaList = [np.inf]
311 else:
312 nSigmaList = [valueThreshold/stDev]
313 else:
314 hotPixelThreshold = self.config.nSigmaBright
315 coldPixelThreshold = self.config.nSigmaDark
316 if datasetType.lower() == 'dark':
317 nSigmaList = [hotPixelThreshold]
318 valueThreshold = stDev*hotPixelThreshold
319 elif datasetType.lower() == 'bias':
320 self.log.warning(
321 "Bias frame detected, but thresholdType == STDEV; not looking for defects.",
322 )
323 return Defects.fromFootprintList([])
324 else:
325 nSigmaList = [hotPixelThreshold, coldPixelThreshold]
326 valueThreshold = [x*stDev for x in nSigmaList]
328 self.log.info("Image type: %s. Amp: %s. Threshold Type: %s. Sigma values and Pixel"
329 "Values (hot and cold pixels thresholds): %s, %s",
330 datasetType, ampName, thresholdType, nSigmaList, valueThreshold)
331 mergedSet = None
332 for sigma in nSigmaList:
333 nSig = np.abs(sigma)
334 self.debugHistogram('ampFlux', ampImg, nSig, exp)
335 polarity = {-1: False, 1: True}[np.sign(sigma)]
337 threshold = afwDetection.createThreshold(nSig, 'stdev', polarity=polarity)
339 try:
340 footprintSet = afwDetection.FootprintSet(ampImg, threshold)
341 except InvalidParameterError:
342 # This occurs if the image sigma value is 0.0.
343 # Let's mask the whole area.
344 minValue = np.nanmin(ampImg.image.array) - 1.0
345 threshold = afwDetection.createThreshold(minValue, 'value', polarity=True)
346 footprintSet = afwDetection.FootprintSet(ampImg, threshold)
348 footprintSet.setMask(maskedIm.mask, ("DETECTED" if polarity else "DETECTED_NEGATIVE"))
350 if mergedSet is None:
351 mergedSet = footprintSet
352 else:
353 mergedSet.merge(footprintSet)
355 if polarity:
356 # hot pixels
357 for fp in footprintSet.getFootprints():
358 hotPixelCount[ampName] += fp.getArea()
359 else:
360 # cold pixels
361 for fp in footprintSet.getFootprints():
362 coldPixelCount[ampName] += fp.getArea()
364 footprintList += mergedSet.getFootprints()
366 self.debugView('defectMap', ampImg,
367 Defects.fromFootprintList(mergedSet.getFootprints()), exp.getDetector())
369 defects = Defects.fromFootprintList(footprintList)
370 defects, count = self.maskBlocksIfIntermitentBadPixelsInColumn(defects)
371 defects.updateCounters(columns=count, hot=hotPixelCount, cold=coldPixelCount)
373 return defects
375 @staticmethod
376 def _getNumGoodPixels(maskedIm, badMaskString="NO_DATA"):
377 """Return the number of non-bad pixels in the image."""
378 nPixels = maskedIm.mask.array.size
379 nBad = countMaskedPixels(maskedIm, badMaskString)
380 return nPixels - nBad
382 def _setEdgeBits(self, exposureOrMaskedImage, maskplaneToSet='EDGE'):
383 """Set edge bits on an exposure or maskedImage.
385 Raises
386 ------
387 TypeError
388 Raised if parameter ``exposureOrMaskedImage`` is an invalid type.
389 """
390 if isinstance(exposureOrMaskedImage, afwImage.Exposure):
391 mi = exposureOrMaskedImage.maskedImage
392 elif isinstance(exposureOrMaskedImage, afwImage.MaskedImage):
393 mi = exposureOrMaskedImage
394 else:
395 t = type(exposureOrMaskedImage)
396 raise TypeError(f"Function supports exposure or maskedImage but not {t}")
398 MASKBIT = mi.mask.getPlaneBitMask(maskplaneToSet)
399 if self.config.nPixBorderLeftRight:
400 mi.mask[: self.config.nPixBorderLeftRight, :, afwImage.LOCAL] |= MASKBIT
401 mi.mask[-self.config.nPixBorderLeftRight:, :, afwImage.LOCAL] |= MASKBIT
402 if self.config.nPixBorderUpDown:
403 mi.mask[:, : self.config.nPixBorderUpDown, afwImage.LOCAL] |= MASKBIT
404 mi.mask[:, -self.config.nPixBorderUpDown:, afwImage.LOCAL] |= MASKBIT
406 def maskBlocksIfIntermitentBadPixelsInColumn(self, defects):
407 """Mask blocks in a column if there are on-and-off bad pixels
409 If there's a column with on and off bad pixels, mask all the
410 pixels in between, except if there is a large enough gap of
411 consecutive good pixels between two bad pixels in the column.
413 Parameters
414 ----------
415 defects : `lsst.ip.isr.Defects`
416 The defects found in the image so far
418 Returns
419 -------
420 defects : `lsst.ip.isr.Defects`
421 If the number of bad pixels in a column is not larger or
422 equal than self.config.badPixelColumnThreshold, the input
423 list is returned. Otherwise, the defects list returned
424 will include boxes that mask blocks of on-and-of pixels.
425 badColumnCount : `int`
426 Number of bad columns masked.
427 """
428 badColumnCount = 0
429 # Get the (x, y) values of each bad pixel in amp.
430 coordinates = []
431 for defect in defects:
432 bbox = defect.getBBox()
433 x0, y0 = bbox.getMinX(), bbox.getMinY()
434 deltaX0, deltaY0 = bbox.getDimensions()
435 for j in np.arange(y0, y0+deltaY0):
436 for i in np.arange(x0, x0 + deltaX0):
437 coordinates.append((i, j))
439 x, y = [], []
440 for coordinatePair in coordinates:
441 x.append(coordinatePair[0])
442 y.append(coordinatePair[1])
444 x = np.array(x)
445 y = np.array(y)
446 # Find the defects with same "x" (vertical) coordinate (column).
447 unique, counts = np.unique(x, return_counts=True)
448 multipleX = []
449 for (a, b) in zip(unique, counts):
450 if b >= self.config.badOnAndOffPixelColumnThreshold:
451 multipleX.append(a)
452 if len(multipleX) != 0:
453 defects = self._markBlocksInBadColumn(x, y, multipleX, defects)
454 badColumnCount += 1
456 return defects, badColumnCount
458 def _markBlocksInBadColumn(self, x, y, multipleX, defects):
459 """Mask blocks in a column if number of on-and-off bad pixels is above
460 threshold.
462 This function is called if the number of on-and-off bad pixels
463 in a column is larger or equal than
464 self.config.badOnAndOffPixelColumnThreshold.
466 Parameters
467 ---------
468 x : `list`
469 Lower left x coordinate of defect box. x coordinate is
470 along the short axis if amp.
471 y : `list`
472 Lower left y coordinate of defect box. x coordinate is
473 along the long axis if amp.
474 multipleX : list
475 List of x coordinates in amp. with multiple bad pixels
476 (i.e., columns with defects).
477 defects : `lsst.ip.isr.Defects`
478 The defcts found in the image so far
480 Returns
481 -------
482 defects : `lsst.ip.isr.Defects`
483 The defects list returned that will include boxes that
484 mask blocks of on-and-of pixels.
485 """
486 with defects.bulk_update():
487 goodPixelColumnGapThreshold = self.config.goodPixelColumnGapThreshold
488 for x0 in multipleX:
489 index = np.where(x == x0)
490 multipleY = y[index] # multipleY and multipleX are in 1-1 correspondence.
491 multipleY.sort() # Ensure that the y values are sorted to look for gaps.
492 minY, maxY = np.min(multipleY), np.max(multipleY)
493 # Next few lines: don't mask pixels in column if gap
494 # of good pixels between two consecutive bad pixels is
495 # larger or equal than 'goodPixelColumnGapThreshold'.
496 diffIndex = np.where(np.diff(multipleY) >= goodPixelColumnGapThreshold)[0]
497 if len(diffIndex) != 0:
498 limits = [minY] # put the minimum first
499 for gapIndex in diffIndex:
500 limits.append(multipleY[gapIndex])
501 limits.append(multipleY[gapIndex+1])
502 limits.append(maxY) # maximum last
503 for i in np.arange(0, len(limits)-1, 2):
504 s = Box2I(minimum=Point2I(x0, limits[i]), maximum=Point2I(x0, limits[i+1]))
505 defects.append(s)
506 else: # No gap is large enough
507 s = Box2I(minimum=Point2I(x0, minY), maximum=Point2I(x0, maxY))
508 defects.append(s)
509 return defects
511 def debugView(self, stepname, ampImage, defects, detector): # pragma: no cover
512 """Plot the defects found by the task.
514 Parameters
515 ----------
516 stepname : `str`
517 Debug frame to request.
518 ampImage : `lsst.afw.image.MaskedImage`
519 Amplifier image to display.
520 defects : `lsst.ip.isr.Defects`
521 The defects to plot.
522 detector : `lsst.afw.cameraGeom.Detector`
523 Detector holding camera geometry.
524 """
525 frame = getDebugFrame(self._display, stepname)
526 if frame:
527 disp = afwDisplay.Display(frame=frame)
528 disp.scale('asinh', 'zscale')
529 disp.setMaskTransparency(80)
530 disp.setMaskPlaneColor("BAD", afwDisplay.RED)
532 maskedIm = ampImage.clone()
533 defects.maskPixels(maskedIm, "BAD")
535 mpDict = maskedIm.mask.getMaskPlaneDict()
536 for plane in mpDict.keys():
537 if plane in ['BAD']:
538 continue
539 disp.setMaskPlaneColor(plane, afwDisplay.IGNORE)
541 disp.setImageColormap('gray')
542 disp.mtv(maskedIm)
543 cameraGeom.utils.overlayCcdBoxes(detector, isTrimmed=True, display=disp)
544 prompt = "Press Enter to continue [c]... "
545 while True:
546 ans = input(prompt).lower()
547 if ans in ('', 'c', ):
548 break
550 def debugHistogram(self, stepname, ampImage, nSigmaUsed, exp):
551 """Make a histogram of the distribution of pixel values for
552 each amp.
554 The main image data histogram is plotted in blue. Edge
555 pixels, if masked, are in red. Note that masked edge pixels
556 do not contribute to the underflow and overflow numbers.
558 Note that this currently only supports the 16-amp LSST
559 detectors.
561 Parameters
562 ----------
563 stepname : `str`
564 Debug frame to request.
565 ampImage : `lsst.afw.image.MaskedImage`
566 Amplifier image to display.
567 nSigmaUsed : `float`
568 The number of sigma used for detection
569 exp : `lsst.afw.image.exposure.Exposure`
570 The exposure in which the defects were found.
571 """
572 frame = getDebugFrame(self._display, stepname)
573 if frame:
574 import matplotlib.pyplot as plt
576 detector = exp.getDetector()
577 nX = np.floor(np.sqrt(len(detector)))
578 nY = len(detector) // nX
579 fig, ax = plt.subplots(nrows=int(nY), ncols=int(nX), sharex='col', sharey='row', figsize=(13, 10))
581 expTime = exp.getInfo().getVisitInfo().getExposureTime()
583 for (amp, a) in zip(reversed(detector), ax.flatten()):
584 mi = exp.maskedImage[amp.getBBox()]
586 # normalize by expTime as we plot in ADU/s and don't
587 # always work with master calibs
588 mi.image.array /= expTime
589 stats = afwMath.makeStatistics(mi, afwMath.MEANCLIP | afwMath.STDEVCLIP)
590 mean, sigma = stats.getValue(afwMath.MEANCLIP), stats.getValue(afwMath.STDEVCLIP)
591 # Get array of pixels
592 EDGEBIT = exp.maskedImage.mask.getPlaneBitMask("EDGE")
593 imgData = mi.image.array[(mi.mask.array & EDGEBIT) == 0].flatten()
594 edgeData = mi.image.array[(mi.mask.array & EDGEBIT) != 0].flatten()
596 thrUpper = mean + nSigmaUsed*sigma
597 thrLower = mean - nSigmaUsed*sigma
599 nRight = len(imgData[imgData > thrUpper])
600 nLeft = len(imgData[imgData < thrLower])
602 nsig = nSigmaUsed + 1.2 # add something small so the edge of the plot is out from level used
603 leftEdge = mean - nsig * nSigmaUsed*sigma
604 rightEdge = mean + nsig * nSigmaUsed*sigma
605 nbins = np.linspace(leftEdge, rightEdge, 1000)
606 ey, bin_borders, patches = a.hist(edgeData, histtype='step', bins=nbins,
607 lw=1, edgecolor='red')
608 y, bin_borders, patches = a.hist(imgData, histtype='step', bins=nbins,
609 lw=3, edgecolor='blue')
611 # Report number of entries in over- and under-flow
612 # bins, i.e. off the edges of the histogram
613 nOverflow = len(imgData[imgData > rightEdge])
614 nUnderflow = len(imgData[imgData < leftEdge])
616 # Put v-lines and textboxes in
617 a.axvline(thrUpper, c='k')
618 a.axvline(thrLower, c='k')
619 msg = f"{amp.getName()}\nmean:{mean: .2f}\n$\\sigma$:{sigma: .2f}"
620 a.text(0.65, 0.6, msg, transform=a.transAxes, fontsize=11)
621 msg = f"nLeft:{nLeft}\nnRight:{nRight}\nnOverflow:{nOverflow}\nnUnderflow:{nUnderflow}"
622 a.text(0.03, 0.6, msg, transform=a.transAxes, fontsize=11.5)
624 # set axis limits and scales
625 a.set_ylim([1., 1.7*np.max(y)])
626 lPlot, rPlot = a.get_xlim()
627 a.set_xlim(np.array([lPlot, rPlot]))
628 a.set_yscale('log')
629 a.set_xlabel("ADU/s")
630 fig.show()
631 prompt = "Press Enter or c to continue [chp]..."
632 while True:
633 ans = input(prompt).lower()
634 if ans in ("", " ", "c",):
635 break
636 elif ans in ("p", ):
637 import pdb
638 pdb.set_trace()
639 elif ans in ("h", ):
640 print("[h]elp [c]ontinue [p]db")
641 plt.close()
644class MeasureDefectsCombinedConnections(pipeBase.PipelineTaskConnections,
645 dimensions=("instrument", "detector")):
646 inputExp = cT.Input(
647 name="dark",
648 doc="Input ISR-processed combined exposure to measure.",
649 storageClass="ExposureF",
650 dimensions=("instrument", "detector"),
651 multiple=False,
652 isCalibration=True,
653 )
654 camera = cT.PrerequisiteInput(
655 name='camera',
656 doc="Camera associated with this exposure.",
657 storageClass="Camera",
658 dimensions=("instrument", ),
659 isCalibration=True,
660 )
662 outputDefects = cT.Output(
663 name="cpPartialDefectsFromDarkCombined",
664 doc="Output measured defects.",
665 storageClass="Defects",
666 dimensions=("instrument", "detector"),
667 )
670class MeasureDefectsCombinedTaskConfig(MeasureDefectsTaskConfig,
671 pipelineConnections=MeasureDefectsCombinedConnections):
672 """Configuration for measuring defects from combined exposures.
673 """
674 pass
677class MeasureDefectsCombinedTask(MeasureDefectsTask):
678 """Task to measure defects in combined images."""
680 ConfigClass = MeasureDefectsCombinedTaskConfig
681 _DefaultName = "cpDefectMeasureCombined"
684class MeasureDefectsCombinedWithFilterConnections(pipeBase.PipelineTaskConnections,
685 dimensions=("instrument", "detector", "physical_filter")):
686 """Task to measure defects in combined flats under a certain filter."""
687 inputExp = cT.Input(
688 name="flat",
689 doc="Input ISR-processed combined exposure to measure.",
690 storageClass="ExposureF",
691 dimensions=("instrument", "detector", "physical_filter"),
692 multiple=False,
693 isCalibration=True,
694 )
695 camera = cT.PrerequisiteInput(
696 name='camera',
697 doc="Camera associated with this exposure.",
698 storageClass="Camera",
699 dimensions=("instrument", ),
700 isCalibration=True,
701 )
703 outputDefects = cT.Output(
704 name="cpPartialDefectsFromFlatCombinedWithFilter",
705 doc="Output measured defects.",
706 storageClass="Defects",
707 dimensions=("instrument", "detector", "physical_filter"),
708 )
711class MeasureDefectsCombinedWithFilterTaskConfig(
712 MeasureDefectsTaskConfig,
713 pipelineConnections=MeasureDefectsCombinedWithFilterConnections):
714 """Configuration for measuring defects from combined exposures.
715 """
716 pass
719class MeasureDefectsCombinedWithFilterTask(MeasureDefectsTask):
720 """Task to measure defects in combined images."""
722 ConfigClass = MeasureDefectsCombinedWithFilterTaskConfig
723 _DefaultName = "cpDefectMeasureWithFilterCombined"
726class MergeDefectsConnections(pipeBase.PipelineTaskConnections,
727 dimensions=("instrument", "detector")):
728 inputDefects = cT.Input(
729 name="singleExpDefects",
730 doc="Measured defect lists.",
731 storageClass="Defects",
732 dimensions=("instrument", "detector", "exposure",),
733 multiple=True,
734 )
735 camera = cT.PrerequisiteInput(
736 name='camera',
737 doc="Camera associated with these defects.",
738 storageClass="Camera",
739 dimensions=("instrument", ),
740 isCalibration=True,
741 )
743 mergedDefects = cT.Output(
744 name="defects",
745 doc="Final merged defects.",
746 storageClass="Defects",
747 dimensions=("instrument", "detector"),
748 multiple=False,
749 isCalibration=True,
750 )
753class MergeDefectsTaskConfig(pipeBase.PipelineTaskConfig,
754 pipelineConnections=MergeDefectsConnections):
755 """Configuration for merging single exposure defects.
756 """
758 assertSameRun = pexConfig.Field(
759 dtype=bool,
760 doc=("Ensure that all visits are from the same run? Raises if this is not the case, or "
761 "if the run key isn't found."),
762 default=False, # false because most obs_packages don't have runs. obs_lsst/ts8 overrides this.
763 )
764 ignoreFilters = pexConfig.Field(
765 dtype=bool,
766 doc=("Set the filters used in the CALIB_ID to NONE regardless of the filters on the input"
767 " images. Allows mixing of filters in the input flats. Set to False if you think"
768 " your defects might be chromatic and want to have registry support for varying"
769 " defects with respect to filter."),
770 default=True,
771 )
772 nullFilterName = pexConfig.Field(
773 dtype=str,
774 doc=("The name of the null filter if ignoreFilters is True. Usually something like NONE or EMPTY"),
775 default="NONE",
776 )
777 combinationMode = pexConfig.ChoiceField(
778 doc="Which types of defects to identify",
779 dtype=str,
780 default="FRACTION",
781 allowed={
782 "AND": "Logical AND the pixels found in each visit to form set ",
783 "OR": "Logical OR the pixels found in each visit to form set ",
784 "FRACTION": "Use pixels found in more than config.combinationFraction of visits ",
785 }
786 )
787 combinationFraction = pexConfig.RangeField(
788 dtype=float,
789 doc=("The fraction (0..1) of visits in which a pixel was found to be defective across"
790 " the visit list in order to be marked as a defect. Note, upper bound is exclusive, so use"
791 " mode AND to require pixel to appear in all images."),
792 default=0.7,
793 min=0,
794 max=1,
795 )
796 nPixBorderUpDown = pexConfig.Field(
797 dtype=int,
798 doc="Number of pixels on top & bottom of image to mask as defects if edgesAsDefects is True.",
799 default=5,
800 )
801 nPixBorderLeftRight = pexConfig.Field(
802 dtype=int,
803 doc="Number of pixels on left & right of image to mask as defects if edgesAsDefects is True.",
804 default=5,
805 )
806 edgesAsDefects = pexConfig.Field(
807 dtype=bool,
808 doc="Mark all edge pixels, as defined by nPixBorder[UpDown, LeftRight], as defects.",
809 default=False,
810 )
813class MergeDefectsTask(pipeBase.PipelineTask):
814 """Merge the defects from multiple exposures.
815 """
817 ConfigClass = MergeDefectsTaskConfig
818 _DefaultName = 'cpDefectMerge'
820 def run(self, inputDefects, camera):
821 """Merge a list of single defects to find the common defect regions.
823 Parameters
824 ----------
825 inputDefects : `list` [`lsst.ip.isr.Defects`]
826 Partial defects from a single exposure.
827 camera : `lsst.afw.cameraGeom.Camera`
828 Camera to use for metadata.
830 Returns
831 -------
832 results : `lsst.pipe.base.Struct`
833 Results struct containing:
835 ``mergedDefects``
836 The defects merged from the input lists
837 (`lsst.ip.isr.Defects`).
838 """
839 detectorId = inputDefects[0].getMetadata().get('DETECTOR', None)
840 if detectorId is None:
841 raise RuntimeError("Cannot identify detector id.")
842 detector = camera[detectorId]
844 imageTypes = set()
845 for inDefect in inputDefects:
846 imageType = inDefect.getMetadata().get('cpDefectGenImageType', 'UNKNOWN')
847 imageTypes.add(imageType)
849 # Determine common defect pixels separately for each input image type.
850 splitDefects = list()
851 for imageType in imageTypes:
852 sumImage = afwImage.MaskedImageF(detector.getBBox())
853 count = 0
854 for inDefect in inputDefects:
855 if imageType == inDefect.getMetadata().get('cpDefectGenImageType', 'UNKNOWN'):
856 count += 1
857 for defect in inDefect:
858 sumImage.image[defect.getBBox()] += 1.0
859 sumImage /= count
860 nDetected = len(np.where(sumImage.getImage().getArray() > 0)[0])
861 self.log.info("Pre-merge %s pixels with non-zero detections for %s" % (nDetected, imageType))
863 if self.config.combinationMode == 'AND':
864 threshold = 1.0
865 elif self.config.combinationMode == 'OR':
866 threshold = 0.0
867 elif self.config.combinationMode == 'FRACTION':
868 threshold = self.config.combinationFraction
869 else:
870 raise RuntimeError(f"Got unsupported combinationMode {self.config.combinationMode}")
871 indices = np.where(sumImage.getImage().getArray() > threshold)
872 BADBIT = sumImage.getMask().getPlaneBitMask('BAD')
873 sumImage.getMask().getArray()[indices] |= BADBIT
874 self.log.info("Post-merge %s pixels marked as defects for %s" % (len(indices[0]), imageType))
875 partialDefect = Defects.fromMask(sumImage, 'BAD')
876 splitDefects.append(partialDefect)
878 # Do final combination of separate image types
879 finalImage = afwImage.MaskedImageF(detector.getBBox())
880 for inDefect in splitDefects:
881 for defect in inDefect:
882 finalImage.image[defect.getBBox()] += 1
883 finalImage /= len(splitDefects)
884 nDetected = len(np.where(finalImage.getImage().getArray() > 0)[0])
885 self.log.info("Pre-final merge %s pixels with non-zero detections" % (nDetected, ))
887 # This combination is the OR of all image types
888 threshold = 0.0
889 indices = np.where(finalImage.getImage().getArray() > threshold)
890 BADBIT = finalImage.getMask().getPlaneBitMask('BAD')
891 finalImage.getMask().getArray()[indices] |= BADBIT
892 self.log.info("Post-final merge %s pixels marked as defects" % (len(indices[0]), ))
894 if self.config.edgesAsDefects:
895 self.log.info("Masking edge pixels as defects.")
896 # This code follows the pattern from isrTask.maskEdges().
897 if self.config.nPixBorderLeftRight > 0:
898 box = detector.getBBox()
899 subImage = finalImage[box]
900 box.grow(Extent2I(-self.config.nPixBorderLeftRight, 0))
901 SourceDetectionTask.setEdgeBits(subImage, box, BADBIT)
902 if self.config.nPixBorderUpDown > 0:
903 box = detector.getBBox()
904 subImage = finalImage[box]
905 box.grow(Extent2I(0, -self.config.nPixBorderUpDown))
906 SourceDetectionTask.setEdgeBits(subImage, box, BADBIT)
908 merged = Defects.fromMask(finalImage, 'BAD')
909 merged.updateMetadataFromExposures(inputDefects)
910 merged.updateMetadata(camera=camera, detector=detector, filterName=None,
911 setCalibId=True, setDate=True)
913 return pipeBase.Struct(
914 mergedDefects=merged,
915 )
917# Subclass the MergeDefects task to reduce the input dimensions
918# from ("instrument", "detector", "exposure") to
919# ("instrument", "detector").
922class MergeDefectsCombinedConnections(pipeBase.PipelineTaskConnections,
923 dimensions=("instrument", "detector")):
924 inputDarkDefects = cT.Input(
925 name="cpPartialDefectsFromDarkCombined",
926 doc="Measured defect lists.",
927 storageClass="Defects",
928 dimensions=("instrument", "detector",),
929 multiple=True,
930 )
931 inputBiasDefects = cT.Input(
932 name="cpPartialDefectsFromBiasCombined",
933 doc="Additional measured defect lists.",
934 storageClass="Defects",
935 dimensions=("instrument", "detector",),
936 multiple=True,
937 )
938 inputFlatDefects = cT.Input(
939 name="cpPartialDefectsFromFlatCombinedWithFilter",
940 doc="Additional measured defect lists.",
941 storageClass="Defects",
942 dimensions=("instrument", "detector", "physical_filter"),
943 multiple=True,
944 )
945 camera = cT.PrerequisiteInput(
946 name='camera',
947 doc="Camera associated with these defects.",
948 storageClass="Camera",
949 dimensions=("instrument", ),
950 isCalibration=True,
951 )
953 mergedDefects = cT.Output(
954 name="defects",
955 doc="Final merged defects.",
956 storageClass="Defects",
957 dimensions=("instrument", "detector"),
958 multiple=False,
959 isCalibration=True,
960 )
963class MergeDefectsCombinedTaskConfig(MergeDefectsTaskConfig,
964 pipelineConnections=MergeDefectsCombinedConnections):
965 """Configuration for merging defects from combined exposure.
966 """
967 def validate(self):
968 super().validate()
969 if self.combinationMode != 'OR':
970 raise ValueError("combinationMode must be 'OR'")
973class MergeDefectsCombinedTask(MergeDefectsTask):
974 """Task to measure defects in combined images."""
976 ConfigClass = MergeDefectsCombinedTaskConfig
977 _DefaultName = "cpDefectMergeCombined"
979 @staticmethod
980 def chooseBest(inputs):
981 """Select the input with the most exposures used."""
982 best = 0
983 if len(inputs) > 1:
984 nInput = 0
985 for num, exp in enumerate(inputs):
986 # This technically overcounts by a factor of 3.
987 N = len([k for k, v in exp.getMetadata().toDict().items() if "CPP_INPUT_" in k])
988 if N > nInput:
989 best = num
990 nInput = N
991 return inputs[best]
993 def runQuantum(self, butlerQC, inputRefs, outputRefs):
994 inputs = butlerQC.get(inputRefs)
995 # Turn inputFlatDefects and inputDarkDefects into a list which
996 # is what MergeDefectsTask expects. If there are multiple,
997 # use the one with the most inputs.
998 tempList = [self.chooseBest(inputs['inputFlatDefects']),
999 self.chooseBest(inputs['inputDarkDefects']),
1000 self.chooseBest(inputs['inputBiasDefects'])]
1002 # Rename inputDefects
1003 inputsCombined = {'inputDefects': tempList, 'camera': inputs['camera']}
1005 outputs = super().run(**inputsCombined)
1006 butlerQC.put(outputs, outputRefs)