Coverage for python / lsst / cp / pipe / cpDefects.py: 17%
506 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 09:35 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 09:35 +0000
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 'MeasureDefectsCombinedWithFilterTaskConfig', 'MeasureDefectsCombinedWithFilterTask',
27 'MergeDefectsCombinedTaskConfig', 'MergeDefectsCombinedTask', ]
29import numpy as np
31import lsst.pipe.base as pipeBase
32import lsst.pipe.base.connectionTypes as cT
34from lsstDebug import getDebugFrame
35import lsst.pex.config as pexConfig
37import lsst.afw.image as afwImage
38import lsst.afw.math as afwMath
39import lsst.afw.detection as afwDetection
40import lsst.afw.display as afwDisplay
41from lsst.afw import cameraGeom
42from lsst.geom import Box2I, Point2I, Extent2I
43from lsst.meas.algorithms import SourceDetectionTask
44from lsst.ip.isr import Defects, countMaskedPixels, PhotonTransferCurveDataset
45from lsst.pex.exceptions import InvalidParameterError
47from .utils import bin_flat, FlatGradientFitter
48from lsst.ip.isr import FlatGradient
51class MeasureDefectsConnections(pipeBase.PipelineTaskConnections,
52 dimensions=("instrument", "exposure", "detector")):
53 inputExp = cT.Input(
54 name="defectExps",
55 doc="Input ISR-processed exposures to measure.",
56 storageClass="Exposure",
57 dimensions=("instrument", "detector", "exposure"),
58 multiple=False
59 )
60 camera = cT.PrerequisiteInput(
61 name='camera',
62 doc="Camera associated with this exposure.",
63 storageClass="Camera",
64 dimensions=("instrument", ),
65 isCalibration=True,
66 )
68 outputDefects = cT.Output(
69 name="singleExpDefects",
70 doc="Output measured defects.",
71 storageClass="Defects",
72 dimensions=("instrument", "detector", "exposure"),
73 )
76class MeasureDefectsTaskConfig(pipeBase.PipelineTaskConfig,
77 pipelineConnections=MeasureDefectsConnections):
78 """Configuration for measuring defects from a list of exposures
79 """
81 thresholdType = pexConfig.ChoiceField(
82 dtype=str,
83 doc=("Defects threshold type: ``STDEV`` or ``VALUE``. If ``VALUE``, cold pixels will be found "
84 "in flats, and hot pixels in darks. If ``STDEV``, cold and hot pixels will be found "
85 "in flats, and hot pixels in darks."),
86 default='STDEV',
87 allowed={'STDEV': "Use a multiple of the image standard deviation to determine detection threshold.",
88 'VALUE': "Use pixel value to determine detection threshold."},
89 )
90 fitAmpGradient = pexConfig.Field(
91 dtype=bool,
92 doc="Fit out the focal plane radial gradient per amplifier.",
93 default=False,
94 )
95 doVampirePixels = pexConfig.Field(
96 dtype=bool,
97 doc=("Search for vampire pixels (bright pixels surrounded by ring of low flux) in ComCam "
98 "flatBootstrap and mask the area arount them."),
99 default=False,
100 )
101 thresholdVampirePixels = pexConfig.Field(
102 dtype=float,
103 doc=("Pixel value threshold to find bright pixels in ComCam flatBootstrap."),
104 default=1.9,
105 )
106 radiusVampirePixels = pexConfig.Field(
107 dtype=int,
108 doc=("Radius (in pixels) of the area to mask around ComCam flatBootstrap bright pixels."),
109 default=8,
110 )
111 darkCurrentThreshold = pexConfig.Field(
112 dtype=float,
113 doc=("If thresholdType=``VALUE``, dark current threshold (in e-/sec) to define "
114 "hot/bright pixels in dark images. Unused if thresholdType==``STDEV``."),
115 default=5,
116 )
117 biasThreshold = pexConfig.Field(
118 dtype=float,
119 doc=("If thresholdType==``VALUE``, bias threshold (in ADU) to define "
120 "hot/bright pixels in bias frame. Unused if thresholdType==``STDEV``."),
121 default=1000.0,
122 )
123 fracThresholdFlat = pexConfig.Field(
124 dtype=float,
125 doc=("If thresholdType=``VALUE``, fractional threshold to define cold/dark "
126 "pixels in flat images (fraction of the mean value per amplifier)."
127 "Unused if thresholdType==``STDEV``."),
128 default=0.8,
129 )
130 nSigmaBright = pexConfig.Field(
131 dtype=float,
132 doc=("If thresholdType=``STDEV``, number of sigma above mean for bright/hot "
133 "pixel detection. The default value was found to be "
134 "appropriate for some LSST sensors in DM-17490. "
135 "Unused if thresholdType==``VALUE``"),
136 default=4.8,
137 )
138 nSigmaDark = pexConfig.Field(
139 dtype=float,
140 doc=("If thresholdType=``STDEV``, number of sigma below mean for dark/cold pixel "
141 "detection. The default value was found to be "
142 "appropriate for some LSST sensors in DM-17490. "
143 "Unused if thresholdType==``VALUE``"),
144 default=-5.0,
145 )
146 nPixBorderUpDown = pexConfig.Field(
147 dtype=int,
148 doc="Number of pixels to exclude from top & bottom of image when looking for defects.",
149 default=0,
150 )
151 nPixBorderLeftRight = pexConfig.Field(
152 dtype=int,
153 doc="Number of pixels to exclude from left & right of image when looking for defects.",
154 default=0,
155 )
156 badOnAndOffPixelColumnThreshold = pexConfig.Field(
157 dtype=int,
158 doc=("If BPC is the set of all the bad pixels in a given column (not necessarily consecutive) "
159 "and the size of BPC is at least 'badOnAndOffPixelColumnThreshold', all the pixels between the "
160 "pixels that satisfy minY (BPC) and maxY (BPC) will be marked as bad, with 'Y' being the long "
161 "axis of the amplifier (and 'X' the other axis, which for a column is a constant for all "
162 "pixels in the set BPC). If there are more than 'goodPixelColumnGapThreshold' consecutive "
163 "non-bad pixels in BPC, an exception to the above is made and those consecutive "
164 "'goodPixelColumnGapThreshold' are not marked as bad."),
165 default=50,
166 )
167 goodPixelColumnGapThreshold = pexConfig.Field(
168 dtype=int,
169 doc=("Size, in pixels, of usable consecutive pixels in a column with on and off bad pixels (see "
170 "'badOnAndOffPixelColumnThreshold')."),
171 default=30,
172 )
173 badPixelsToFillColumnThreshold = pexConfig.Field(
174 dtype=float,
175 doc=("If the number of bad pixels in an amplifier column is above this threshold "
176 "then the full amplifier column will be marked bad. This operation is performed after "
177 "any merging of blinking columns performed with badOnAndOffPixelColumnThreshold. If this"
178 "value is less than 0 then no bad column filling will be performed."),
179 default=-1,
180 )
181 saturatedColumnMask = pexConfig.Field(
182 dtype=str,
183 default="SAT",
184 doc="Saturated mask plane for dilation.",
185 )
186 saturatedColumnDilationRadius = pexConfig.Field(
187 dtype=int,
188 doc=("Dilation radius (along rows) to use to expand saturated columns "
189 "to mitigate glow."),
190 default=0,
191 )
192 saturatedPixelsToFillColumnThreshold = pexConfig.Field(
193 dtype=int,
194 doc=("If the number of saturated pixels in an amplifier column is above this threshold "
195 "then the full amplifier column will be marked bad. If this value is less than 0"
196 "then no saturated column filling will be performed."),
197 default=-1,
198 )
199 e2vMidlineBreakNRow = pexConfig.Field(
200 dtype=int,
201 doc="E2V midline break number of midline break rows at the bottom of the top amps "
202 "or the top of the bottom amps to always mask or ignore. Number of rows will "
203 "be twice this config value. Only used if detector is E2V type. Set to <=0 "
204 "to treat E2V midline break rows like any other flat-field pixel.",
205 default=1,
206 )
207 e2vMidlineBreakOption = pexConfig.ChoiceField(
208 dtype=str,
209 doc="How should the E2V midline break be treated? Only used if e2vMidlineBreakNRow > 0.",
210 default="NEVERMASK",
211 allowed={
212 "NEVERMASK": "Never mask the E2V midline break, no matter the flat values.",
213 "MASK": "Always mask the E2V midline break.",
214 },
215 )
216 ampGradientBinFactor = pexConfig.Field(
217 dtype=int,
218 doc="Binning factor used for fitting per-amp focal plane gradient.",
219 default=8,
220 )
221 ampGradientBoundarySize = pexConfig.Field(
222 dtype=int,
223 doc="Amp boundary exclusion size for fitting per-amp focal plane gradient.",
224 default=20,
225 )
226 ampGradientNodes = pexConfig.Field(
227 dtype=int,
228 doc="Number of spline nodes for per-amp focal plane gradient.",
229 default=4,
230 )
232 def validate(self):
233 super().validate()
234 if self.nSigmaBright < 0.0:
235 raise ValueError("nSigmaBright must be above 0.0.")
236 if self.nSigmaDark > 0.0:
237 raise ValueError("nSigmaDark must be below 0.0.")
240class MeasureDefectsTask(pipeBase.PipelineTask):
241 """Measure the defects from one exposure.
242 """
244 ConfigClass = MeasureDefectsTaskConfig
245 _DefaultName = 'cpDefectMeasure'
247 def run(self, inputExp, camera):
248 """Measure one exposure for defects.
250 Parameters
251 ----------
252 inputExp : `lsst.afw.image.Exposure`
253 Exposure to examine.
254 camera : `lsst.afw.cameraGeom.Camera`
255 Camera to use for metadata.
257 Returns
258 -------
259 results : `lsst.pipe.base.Struct`
260 Results struct containing:
262 ``outputDefects``
263 The defects measured from this exposure
264 (`lsst.ip.isr.Defects`).
265 """
266 detector = inputExp.getDetector()
267 try:
268 filterName = inputExp.getFilter().physicalLabel
269 except AttributeError:
270 filterName = None
272 defects = self._findHotAndColdPixels(inputExp)
274 datasetType = inputExp.getMetadata().get('IMGTYPE', 'UNKNOWN')
275 msg = "Found %s defects containing %s pixels in %s"
276 self.log.info(msg, len(defects), self._nPixFromDefects(defects), datasetType)
278 defects.updateMetadataFromExposures([inputExp])
279 defects.updateMetadata(camera=camera, detector=detector, filterName=filterName,
280 setCalibId=True, setDate=True,
281 cpDefectGenImageType=datasetType)
283 return pipeBase.Struct(
284 outputDefects=defects,
285 )
287 @staticmethod
288 def _nPixFromDefects(defects):
289 """Count pixels in a defect.
291 Parameters
292 ----------
293 defects : `lsst.ip.isr.Defects`
294 Defects to measure.
296 Returns
297 -------
298 nPix : `int`
299 Number of defect pixels.
300 """
301 nPix = 0
302 for defect in defects:
303 nPix += defect.getBBox().getArea()
304 return nPix
306 def getVampirePixels(self, ampImg):
307 """Find vampire pixels (bright pixels in flats) and get footprint of
308 extended area around them,
310 Parameters
311 ----------
312 ampImg : `lsst.afw.image._maskedImage.MaskedImageF`
313 The amplifier masked image to do the vampire pixels search on.
315 Returns
316 -------
317 fs_grow : `lsst.afw.detection._detection.FootprintSet`
318 The footprint set of areas around vampire pixels in the amplifier.
319 """
321 # Find bright pixels
322 thresh = afwDetection.Threshold(self.config.thresholdVampirePixels)
323 # Bright pixels footprint grown by a radius of radiusVampire pixels
324 fs = afwDetection.FootprintSet(ampImg, thresh)
325 fs_grow = afwDetection.FootprintSet(fs, rGrow=self.config.radiusVampirePixels, isotropic=True)
327 return fs_grow
329 def _setE2VMidline(self, amp, ampImg):
330 """Set the E2V midline pixels on this amplifier.
332 If the task is configure to always mask it will set the midline rows to
333 an illegal value; if it is to never mask it will set the midline rows
334 to the median value.
336 Parameters
337 ----------
338 amp : `lsst.afw.cameraGeom.Amplifier`
339 Amplifier object.
340 ampImg : `lsst.afw.image.ImageF`
341 Image data for the amplifier.
342 """
343 if self.config.e2vMidlineBreakNRow <= 0:
344 return
346 if self.config.e2vMidlineBreakOption == "NEVERMASK":
347 # Setting the midline values to the median value ensures
348 # they will never trigger the defect finding threshold.
349 value = np.nanmedian(ampImg.image.array)
350 else:
351 # Setting the midline values to a large negative value
352 # ensures they will always trigger the defect finding
353 # threshold.
354 value = -1e30
356 if amp.getName().startswith("C0"):
357 # This is a bottom amplifier, so change the top row(s).
358 ampImg.image.array[-self.config.e2vMidlineBreakNRow:, :] = value
359 else:
360 # This is a top amplifier, so change the bottom row(s).
361 ampImg.image.array[:self.config.e2vMidlineBreakNRow, :] = value
363 def _findHotAndColdPixels(self, exp):
364 """Find hot and cold pixels in an image.
366 Using config-defined thresholds on a per-amp basis, mask
367 pixels that are nSigma above threshold in dark frames (hot
368 pixels), or nSigma away from the clipped mean in flats (hot &
369 cold pixels).
371 Parameters
372 ----------
373 exp : `lsst.afw.image.exposure.Exposure`
374 The exposure in which to find defects.
376 Returns
377 -------
378 defects : `lsst.ip.isr.Defects`
379 The defects found in the image.
380 """
381 self._setEdgeBits(exp)
383 # the detection polarity for afwDetection, True for positive,
384 # False for negative, and therefore True for darks as they only have
385 # bright pixels, and both for flats, as they have bright and dark pix
386 footprintList = []
388 hotPixelCount = {}
389 coldPixelCount = {}
391 detector = exp.getDetector()
393 if self.config.fitAmpGradient:
394 # 1. Bin flat.
395 binnedExp = bin_flat(
396 PhotonTransferCurveDataset(),
397 exp,
398 bin_factor=self.config.ampGradientBinFactor,
399 amp_boundary=self.config.ampGradientBoundarySize,
400 apply_gains=False,
401 )
403 # 2. Prepare transformations.
404 transform = detector.getTransform(
405 cameraGeom.PIXELS,
406 cameraGeom.FOCAL_PLANE,
407 )
409 # 2a. Do transformation for binned coordinates.
410 binnedXy = np.vstack((binnedExp["xd"], binnedExp["yd"]))
411 binnedXf, binnedYf = np.vsplit(
412 transform.getMapping().applyForward(binnedXy.astype(np.float64)),
413 2,
414 )
415 binnedXf = binnedXf.ravel()
416 binnedYf = binnedYf.ravel()
417 binnedRadius = np.sqrt(binnedXf**2. + binnedYf**2.)
419 # 2b. Do transformation for full detector coordinates.
420 xx = np.arange(exp.image.array.shape[1], dtype=np.int64)
421 yy = np.arange(exp.image.array.shape[0], dtype=np.int64)
422 x, y = np.meshgrid(xx, yy)
423 x = x.ravel()
424 y = y.ravel()
426 xy = np.vstack((x, y))
427 xf, yf = np.vsplit(transform.getMapping().applyForward(xy.astype(np.float64)), 2)
428 xf = xf.ravel()
429 yf = yf.ravel()
431 # Measure the focal plane gradient on the binned image and remove
432 # on the full image, amp by amp.
433 for i, amp in enumerate(detector):
434 # Fit the focal plane radial gradient in the binned image.
435 binnedInAmp = (binnedExp["amp_index"] == i)
437 nodes = np.linspace(
438 np.min(binnedRadius[binnedInAmp]),
439 np.max(binnedRadius[binnedInAmp]),
440 self.config.ampGradientNodes,
441 )
443 norm = np.nanpercentile(binnedExp["value"][binnedInAmp], 95.0)
445 fitter = FlatGradientFitter(
446 nodes,
447 binnedXf[binnedInAmp],
448 binnedYf[binnedInAmp],
449 binnedExp["value"][binnedInAmp]/norm,
450 np.array([]),
451 constrain_zero=False,
452 )
453 p0 = fitter.compute_p0()
454 pars = fitter.fit(p0)
456 gradient = FlatGradient()
457 gradient.setParameters(
458 radialSplineNodes=nodes,
459 radialSplineValues=pars[fitter.indices["spline"]],
460 )
462 # Apply the focal plane gradient to the full image.
463 pixelsInAmp = amp.getBBox().contains(x, y)
465 model = gradient.computeRadialSplineModelXY(xf[pixelsInAmp], yf[pixelsInAmp])
467 exp.image.array[y[pixelsInAmp], x[pixelsInAmp]] /= model
469 maskedIm = exp.maskedImage
471 for amp in exp.getDetector():
472 ampName = amp.getName()
474 hotPixelCount[ampName] = 0
475 coldPixelCount[ampName] = 0
477 ampImg = maskedIm[amp.getBBox()].clone()
479 # crop ampImage depending on where the amp lies in the image
480 if self.config.nPixBorderLeftRight:
481 if ampImg.getX0() == 0:
482 ampImg = ampImg[self.config.nPixBorderLeftRight:, :, afwImage.LOCAL]
483 else:
484 ampImg = ampImg[:-self.config.nPixBorderLeftRight, :, afwImage.LOCAL]
485 if self.config.nPixBorderUpDown:
486 if ampImg.getY0() == 0:
487 ampImg = ampImg[:, self.config.nPixBorderUpDown:, afwImage.LOCAL]
488 else:
489 ampImg = ampImg[:, :-self.config.nPixBorderUpDown, afwImage.LOCAL]
491 if self._getNumGoodPixels(ampImg) == 0: # amp contains no usable pixels
492 continue
494 if self.config.doVampirePixels:
495 # This is only applied in LSSTComCam flatBootstrap pipeline
496 footprintSet_VampirePixel = self.getVampirePixels(ampImg)
497 footprintSet_VampirePixel.setMask(maskedIm.mask, ("BAD"))
499 # Remove a background estimate
500 meanClip = afwMath.makeStatistics(ampImg, afwMath.MEANCLIP, ).getValue()
501 ampImg -= meanClip
503 # Determine thresholds
504 stDev = afwMath.makeStatistics(ampImg, afwMath.STDEVCLIP, ).getValue()
505 expTime = exp.getInfo().getVisitInfo().getExposureTime()
506 datasetType = exp.getMetadata().get('IMGTYPE', 'UNKNOWN')
507 if np.isnan(expTime):
508 self.log.warning("expTime=%s for AMP %s in %s. Setting expTime to 1 second",
509 expTime, ampName, datasetType)
510 expTime = 1.
511 thresholdType = self.config.thresholdType
512 if thresholdType == 'VALUE':
513 # LCA-128 and eoTest: bright/hot pixels in dark images are
514 # defined as any pixel with more than 5 e-/s of dark current.
515 # We scale by the exposure time.
516 if datasetType.lower() == 'dark':
517 # hot pixel threshold
518 valueThreshold = self.config.darkCurrentThreshold*expTime/amp.getGain()
519 elif datasetType.lower() == 'bias':
520 # hot pixel threshold, no exposure time.
521 valueThreshold = self.config.biasThreshold
522 else:
523 # LCA-128 and eoTest: dark/cold pixels in flat images as
524 # defined as any pixel with photoresponse <80% of
525 # the mean (at 500nm).
527 # We subtracted the mean above, so the threshold will be
528 # negative cold pixel threshold.
529 valueThreshold = (self.config.fracThresholdFlat-1)*meanClip
530 # Find equivalent sigma values.
531 if stDev == 0.0:
532 self.log.warning("stDev=%s for AMP %s in %s. Setting nSigma to inf.",
533 stDev, ampName, datasetType)
534 nSigmaList = [np.inf]
535 else:
536 nSigmaList = [valueThreshold/stDev]
537 else:
538 hotPixelThreshold = self.config.nSigmaBright
539 coldPixelThreshold = self.config.nSigmaDark
540 if datasetType.lower() == 'dark':
541 nSigmaList = [hotPixelThreshold]
542 valueThreshold = stDev*hotPixelThreshold
543 elif datasetType.lower() == 'bias':
544 self.log.warning(
545 "Bias frame detected, but thresholdType == STDEV; not looking for defects.",
546 )
547 return Defects.fromFootprintList([])
548 else:
549 nSigmaList = [hotPixelThreshold, coldPixelThreshold]
550 valueThreshold = [x*stDev for x in nSigmaList]
552 self.log.info("Image type: %s. Amp: %s. Threshold Type: %s. Sigma values and Pixel"
553 "Values (hot and cold pixels thresholds): %s, %s",
554 datasetType, ampName, thresholdType, nSigmaList, valueThreshold)
556 if datasetType.lower() == "flat" and exp.getDetector().getPhysicalType() == "E2V":
557 self._setE2VMidline(amp, ampImg)
559 mergedSet = None
560 for sigma in nSigmaList:
561 nSig = np.abs(sigma)
562 self.debugHistogram('ampFlux', ampImg, nSig, exp)
563 polarity = {-1: False, 1: True}[np.sign(sigma)]
565 threshold = afwDetection.createThreshold(nSig, 'stdev', polarity=polarity)
567 try:
568 footprintSet = afwDetection.FootprintSet(ampImg, threshold)
569 except InvalidParameterError:
570 # This occurs if the image sigma value is 0.0.
571 # Let's mask the whole area.
572 minValue = np.nanmin(ampImg.image.array) - 1.0
573 threshold = afwDetection.createThreshold(minValue, 'value', polarity=True)
574 footprintSet = afwDetection.FootprintSet(ampImg, threshold)
576 footprintSet.setMask(maskedIm.mask, ("DETECTED" if polarity else "DETECTED_NEGATIVE"))
578 if mergedSet is None:
579 mergedSet = footprintSet
580 else:
581 mergedSet.merge(footprintSet)
583 if polarity:
584 # hot pixels
585 for fp in footprintSet.getFootprints():
586 hotPixelCount[ampName] += fp.getArea()
587 else:
588 # cold pixels
589 for fp in footprintSet.getFootprints():
590 coldPixelCount[ampName] += fp.getArea()
592 if self.config.doVampirePixels:
593 # Count the number of pixels masked
594 vampirePixelCount = 0
595 for fp in footprintSet_VampirePixel.getFootprints():
596 vampirePixelCount += fp.getArea()
597 self.log.info("%s Vampire pixels are masked", vampirePixelCount)
598 # Add vampire pixels to footprint set
599 mergedSet.merge(footprintSet_VampirePixel)
601 footprintList += mergedSet.getFootprints()
603 self.debugView('defectMap', ampImg,
604 Defects.fromFootprintList(mergedSet.getFootprints()), exp.getDetector())
606 defects = Defects.fromFootprintList(footprintList)
607 defects = self.dilateSaturatedColumns(exp, defects)
608 defects, _ = self.maskBlocksIfIntermitentBadPixelsInColumn(defects)
609 defects, count = self.maskBadColumns(exp, defects)
610 # We want this to reflect the number of completely bad columns.
611 defects.updateCounters(columns=count, hot=hotPixelCount, cold=coldPixelCount)
613 return defects
615 @staticmethod
616 def _getNumGoodPixels(maskedIm, badMaskString="NO_DATA"):
617 """Return the number of non-bad pixels in the image."""
618 nPixels = maskedIm.mask.array.size
619 nBad = countMaskedPixels(maskedIm, badMaskString)
620 return nPixels - nBad
622 def _setEdgeBits(self, exposureOrMaskedImage, maskplaneToSet='EDGE'):
623 """Set edge bits on an exposure or maskedImage.
625 Raises
626 ------
627 TypeError
628 Raised if parameter ``exposureOrMaskedImage`` is an invalid type.
629 """
630 if isinstance(exposureOrMaskedImage, afwImage.Exposure):
631 mi = exposureOrMaskedImage.maskedImage
632 elif isinstance(exposureOrMaskedImage, afwImage.MaskedImage):
633 mi = exposureOrMaskedImage
634 else:
635 t = type(exposureOrMaskedImage)
636 raise TypeError(f"Function supports exposure or maskedImage but not {t}")
638 MASKBIT = mi.mask.getPlaneBitMask(maskplaneToSet)
639 if self.config.nPixBorderLeftRight:
640 mi.mask[: self.config.nPixBorderLeftRight, :, afwImage.LOCAL] |= MASKBIT
641 mi.mask[-self.config.nPixBorderLeftRight:, :, afwImage.LOCAL] |= MASKBIT
642 if self.config.nPixBorderUpDown:
643 mi.mask[:, : self.config.nPixBorderUpDown, afwImage.LOCAL] |= MASKBIT
644 mi.mask[:, -self.config.nPixBorderUpDown:, afwImage.LOCAL] |= MASKBIT
646 def maskBlocksIfIntermitentBadPixelsInColumn(self, defects):
647 """Mask blocks in a column if there are on-and-off bad pixels
649 If there's a column with on and off bad pixels, mask all the
650 pixels in between, except if there is a large enough gap of
651 consecutive good pixels between two bad pixels in the column.
653 Parameters
654 ----------
655 defects : `lsst.ip.isr.Defects`
656 The defects found in the image so far
658 Returns
659 -------
660 defects : `lsst.ip.isr.Defects`
661 If the number of bad pixels in a column is not larger or
662 equal than self.config.badPixelColumnThreshold, the input
663 list is returned. Otherwise, the defects list returned
664 will include boxes that mask blocks of on-and-of pixels.
665 badColumnCount : `int`
666 Number of bad columns partially masked.
667 """
668 badColumnCount = 0
669 # Get the (x, y) values of each bad pixel in amp.
670 coordinates = []
671 for defect in defects:
672 bbox = defect.getBBox()
673 x0, y0 = bbox.getMinX(), bbox.getMinY()
674 deltaX0, deltaY0 = bbox.getDimensions()
675 for j in np.arange(y0, y0+deltaY0):
676 for i in np.arange(x0, x0 + deltaX0):
677 coordinates.append((i, j))
679 x, y = [], []
680 for coordinatePair in coordinates:
681 x.append(coordinatePair[0])
682 y.append(coordinatePair[1])
684 x = np.array(x)
685 y = np.array(y)
686 # Find the defects with same "x" (vertical) coordinate (column).
687 unique, counts = np.unique(x, return_counts=True)
688 multipleX = []
689 for (a, b) in zip(unique, counts):
690 if b >= self.config.badOnAndOffPixelColumnThreshold:
691 multipleX.append(a)
692 if len(multipleX) != 0:
693 defects = self._markBlocksInBadColumn(x, y, multipleX, defects)
694 badColumnCount += 1
696 return defects, badColumnCount
698 def dilateSaturatedColumns(self, exp, defects):
699 """Dilate saturated columns by a configurable amount.
701 Parameters
702 ----------
703 exp : `lsst.afw.image.exposure.Exposure`
704 The exposure in which to find defects.
705 defects : `lsst.ip.isr.Defects`
706 The defects found in the image so far
708 Returns
709 -------
710 defects : `lsst.ip.isr.Defects`
711 The expanded defects.
712 """
713 if self.config.saturatedColumnDilationRadius <= 0:
714 # This is a no-op.
715 return defects
717 mask = afwImage.Mask.getPlaneBitMask(self.config.saturatedColumnMask)
719 satY, satX = np.where((exp.mask.array & mask) > 0)
721 if len(satX) == 0:
722 # No saturated pixels, nothing to do.
723 return defects
725 radius = self.config.saturatedColumnDilationRadius
727 with defects.bulk_update():
728 for index in range(len(satX)):
729 minX = np.clip(satX[index] - radius, 0, None)
730 maxX = np.clip(satX[index] + radius, None, exp.image.array.shape[1] - 1)
731 s = Box2I(minimum=Point2I(minX, satY[index]),
732 maximum=Point2I(maxX, satY[index]))
733 defects.append(s)
735 return defects
737 def maskBadColumns(self, exp, defects):
738 """Mask full amplifier columns if they are sufficiently bad.
740 Parameters
741 ----------
742 defects : `lsst.ip.isr.Defects`
743 The defects found in the image so far
745 Returns
746 -------
747 exp : `lsst.afw.image.exposure.Exposure`
748 The exposure in which to find defects.
749 defects : `lsst.ip.isr.Defects`
750 If the number of bad pixels in a column is not larger or
751 equal than self.config.badPixelColumnThreshold, the input
752 list is returned. Otherwise, the defects list returned
753 will include boxes that mask blocks of on-and-of pixels.
754 badColumnCount : `int`
755 Number of bad columns masked.
756 """
757 # Render the defects into an image.
758 defectImage = afwImage.ImageI(exp.getBBox())
760 for defect in defects:
761 defectImage[defect.getBBox()] = 1
763 badColumnCount = 0
765 if self.config.badPixelsToFillColumnThreshold > 0:
766 with defects.bulk_update():
767 for amp in exp.getDetector():
768 subImage = defectImage[amp.getBBox()].array
769 nInCol = np.sum(subImage, axis=0)
771 badColIndices, = (nInCol >= self.config.badPixelsToFillColumnThreshold).nonzero()
772 badColumns = badColIndices + amp.getBBox().getMinX()
774 for badColumn in badColumns:
775 s = Box2I(minimum=Point2I(badColumn, amp.getBBox().getMinY()),
776 maximum=Point2I(badColumn, amp.getBBox().getMaxY()))
777 defects.append(s)
779 badColumnCount += len(badColIndices)
781 if self.config.saturatedPixelsToFillColumnThreshold > 0:
782 mask = afwImage.Mask.getPlaneBitMask(self.config.saturatedColumnMask)
784 with defects.bulk_update():
785 for amp in exp.getDetector():
786 subMask = exp.mask[amp.getBBox()].array
787 # Turn all the SAT bits into 1s
788 subMask &= mask
789 subMask[subMask > 0] = 1
791 nInCol = np.sum(subMask, axis=0)
793 badColIndices, = (nInCol >= self.config.saturatedPixelsToFillColumnThreshold).nonzero()
794 badColumns = badColIndices + amp.getBBox().getMinX()
796 for badColumn in badColumns:
797 s = Box2I(minimum=Point2I(badColumn, amp.getBBox().getMinY()),
798 maximum=Point2I(badColumn, amp.getBBox().getMaxY()))
799 defects.append(s)
801 badColumnCount += len(badColIndices)
803 return defects, badColumnCount
805 def _markBlocksInBadColumn(self, x, y, multipleX, defects):
806 """Mask blocks in a column if number of on-and-off bad pixels is above
807 threshold.
809 This function is called if the number of on-and-off bad pixels
810 in a column is larger or equal than
811 self.config.badOnAndOffPixelColumnThreshold.
813 Parameters
814 ---------
815 x : `list`
816 Lower left x coordinate of defect box. x coordinate is
817 along the short axis if amp.
818 y : `list`
819 Lower left y coordinate of defect box. x coordinate is
820 along the long axis if amp.
821 multipleX : list
822 List of x coordinates in amp. with multiple bad pixels
823 (i.e., columns with defects).
824 defects : `lsst.ip.isr.Defects`
825 The defcts found in the image so far
827 Returns
828 -------
829 defects : `lsst.ip.isr.Defects`
830 The defects list returned that will include boxes that
831 mask blocks of on-and-of pixels.
832 """
833 with defects.bulk_update():
834 goodPixelColumnGapThreshold = self.config.goodPixelColumnGapThreshold
835 for x0 in multipleX:
836 index = np.where(x == x0)
837 multipleY = y[index] # multipleY and multipleX are in 1-1 correspondence.
838 multipleY.sort() # Ensure that the y values are sorted to look for gaps.
839 minY, maxY = np.min(multipleY), np.max(multipleY)
840 # Next few lines: don't mask pixels in column if gap
841 # of good pixels between two consecutive bad pixels is
842 # larger or equal than 'goodPixelColumnGapThreshold'.
843 diffIndex = np.where(np.diff(multipleY) >= goodPixelColumnGapThreshold)[0]
844 if len(diffIndex) != 0:
845 limits = [minY] # put the minimum first
846 for gapIndex in diffIndex:
847 limits.append(multipleY[gapIndex])
848 limits.append(multipleY[gapIndex+1])
849 limits.append(maxY) # maximum last
850 for i in np.arange(0, len(limits)-1, 2):
851 s = Box2I(minimum=Point2I(x0, limits[i]), maximum=Point2I(x0, limits[i+1]))
852 defects.append(s)
853 else: # No gap is large enough
854 s = Box2I(minimum=Point2I(x0, minY), maximum=Point2I(x0, maxY))
855 defects.append(s)
856 return defects
858 def debugView(self, stepname, ampImage, defects, detector): # pragma: no cover
859 """Plot the defects found by the task.
861 Parameters
862 ----------
863 stepname : `str`
864 Debug frame to request.
865 ampImage : `lsst.afw.image.MaskedImage`
866 Amplifier image to display.
867 defects : `lsst.ip.isr.Defects`
868 The defects to plot.
869 detector : `lsst.afw.cameraGeom.Detector`
870 Detector holding camera geometry.
871 """
872 frame = getDebugFrame(self._display, stepname)
873 if frame:
874 disp = afwDisplay.Display(frame=frame)
875 disp.scale('asinh', 'zscale')
876 disp.setMaskTransparency(80)
877 disp.setMaskPlaneColor("BAD", afwDisplay.RED)
879 maskedIm = ampImage.clone()
880 defects.maskPixels(maskedIm, "BAD")
882 mpDict = maskedIm.mask.getMaskPlaneDict()
883 for plane in mpDict.keys():
884 if plane in ['BAD']:
885 continue
886 disp.setMaskPlaneColor(plane, afwDisplay.IGNORE)
888 disp.setImageColormap('gray')
889 disp.mtv(maskedIm)
890 cameraGeom.utils.overlayCcdBoxes(detector, isTrimmed=True, display=disp)
891 prompt = "Press Enter to continue [c]... "
892 while True:
893 ans = input(prompt).lower()
894 if ans in ('', 'c', ):
895 break
897 def debugHistogram(self, stepname, ampImage, nSigmaUsed, exp):
898 """Make a histogram of the distribution of pixel values for
899 each amp.
901 The main image data histogram is plotted in blue. Edge
902 pixels, if masked, are in red. Note that masked edge pixels
903 do not contribute to the underflow and overflow numbers.
905 Note that this currently only supports the 16-amp LSST
906 detectors.
908 Parameters
909 ----------
910 stepname : `str`
911 Debug frame to request.
912 ampImage : `lsst.afw.image.MaskedImage`
913 Amplifier image to display.
914 nSigmaUsed : `float`
915 The number of sigma used for detection
916 exp : `lsst.afw.image.exposure.Exposure`
917 The exposure in which the defects were found.
918 """
919 frame = getDebugFrame(self._display, stepname)
920 if frame:
921 import matplotlib.pyplot as plt
923 detector = exp.getDetector()
924 nX = np.floor(np.sqrt(len(detector)))
925 nY = len(detector) // nX
926 fig, ax = plt.subplots(nrows=int(nY), ncols=int(nX), sharex='col', sharey='row', figsize=(13, 10))
928 expTime = exp.getInfo().getVisitInfo().getExposureTime()
930 for (amp, a) in zip(reversed(detector), ax.flatten()):
931 mi = exp.maskedImage[amp.getBBox()]
933 # normalize by expTime as we plot in ADU/s and don't
934 # always work with master calibs
935 mi.image.array /= expTime
936 stats = afwMath.makeStatistics(mi, afwMath.MEANCLIP | afwMath.STDEVCLIP)
937 mean, sigma = stats.getValue(afwMath.MEANCLIP), stats.getValue(afwMath.STDEVCLIP)
938 # Get array of pixels
939 EDGEBIT = exp.maskedImage.mask.getPlaneBitMask("EDGE")
940 imgData = mi.image.array[(mi.mask.array & EDGEBIT) == 0].flatten()
941 edgeData = mi.image.array[(mi.mask.array & EDGEBIT) != 0].flatten()
943 thrUpper = mean + nSigmaUsed*sigma
944 thrLower = mean - nSigmaUsed*sigma
946 nRight = len(imgData[imgData > thrUpper])
947 nLeft = len(imgData[imgData < thrLower])
949 nsig = nSigmaUsed + 1.2 # add something small so the edge of the plot is out from level used
950 leftEdge = mean - nsig * nSigmaUsed*sigma
951 rightEdge = mean + nsig * nSigmaUsed*sigma
952 nbins = np.linspace(leftEdge, rightEdge, 1000)
953 ey, bin_borders, patches = a.hist(edgeData, histtype='step', bins=nbins,
954 lw=1, edgecolor='red')
955 y, bin_borders, patches = a.hist(imgData, histtype='step', bins=nbins,
956 lw=3, edgecolor='blue')
958 # Report number of entries in over- and under-flow
959 # bins, i.e. off the edges of the histogram
960 nOverflow = len(imgData[imgData > rightEdge])
961 nUnderflow = len(imgData[imgData < leftEdge])
963 # Put v-lines and textboxes in
964 a.axvline(thrUpper, c='k')
965 a.axvline(thrLower, c='k')
966 msg = f"{amp.getName()}\nmean:{mean: .2f}\n$\\sigma$:{sigma: .2f}"
967 a.text(0.65, 0.6, msg, transform=a.transAxes, fontsize=11)
968 msg = f"nLeft:{nLeft}\nnRight:{nRight}\nnOverflow:{nOverflow}\nnUnderflow:{nUnderflow}"
969 a.text(0.03, 0.6, msg, transform=a.transAxes, fontsize=11.5)
971 # set axis limits and scales
972 a.set_ylim([1., 1.7*np.max(y)])
973 lPlot, rPlot = a.get_xlim()
974 a.set_xlim(np.array([lPlot, rPlot]))
975 a.set_yscale('log')
976 a.set_xlabel("ADU/s")
977 fig.show()
978 prompt = "Press Enter or c to continue [chp]..."
979 while True:
980 ans = input(prompt).lower()
981 if ans in ("", " ", "c",):
982 break
983 elif ans in ("p", ):
984 import pdb
985 pdb.set_trace()
986 elif ans in ("h", ):
987 print("[h]elp [c]ontinue [p]db")
988 plt.close()
991class MeasureDefectsCombinedConnections(pipeBase.PipelineTaskConnections,
992 dimensions=("instrument", "detector")):
993 inputExp = cT.Input(
994 name="dark",
995 doc="Input ISR-processed combined exposure to measure.",
996 storageClass="ExposureF",
997 dimensions=("instrument", "detector"),
998 multiple=False,
999 isCalibration=True,
1000 )
1001 camera = cT.PrerequisiteInput(
1002 name='camera',
1003 doc="Camera associated with this exposure.",
1004 storageClass="Camera",
1005 dimensions=("instrument", ),
1006 isCalibration=True,
1007 )
1009 outputDefects = cT.Output(
1010 name="cpDefectsFromDark",
1011 doc="Output measured defects.",
1012 storageClass="Defects",
1013 dimensions=("instrument", "detector"),
1014 )
1017class MeasureDefectsCombinedTaskConfig(MeasureDefectsTaskConfig,
1018 pipelineConnections=MeasureDefectsCombinedConnections):
1019 """Configuration for measuring defects from combined exposures.
1020 """
1021 pass
1024class MeasureDefectsCombinedTask(MeasureDefectsTask):
1025 """Task to measure defects in combined images."""
1027 ConfigClass = MeasureDefectsCombinedTaskConfig
1028 _DefaultName = "cpDefectMeasureCombined"
1031class MeasureDefectsCombinedWithFilterConnections(pipeBase.PipelineTaskConnections,
1032 dimensions=("instrument", "detector", "physical_filter")):
1033 """Task to measure defects in combined flats under a certain filter."""
1034 inputExp = cT.Input(
1035 name="flat",
1036 doc="Input ISR-processed combined exposure to measure.",
1037 storageClass="ExposureF",
1038 dimensions=("instrument", "detector", "physical_filter"),
1039 multiple=False,
1040 isCalibration=True,
1041 )
1042 camera = cT.PrerequisiteInput(
1043 name='camera',
1044 doc="Camera associated with this exposure.",
1045 storageClass="Camera",
1046 dimensions=("instrument", ),
1047 isCalibration=True,
1048 )
1050 outputDefects = cT.Output(
1051 name="cpDefectsFromFlat",
1052 doc="Output measured defects.",
1053 storageClass="Defects",
1054 dimensions=("instrument", "detector", "physical_filter"),
1055 )
1058class MeasureDefectsCombinedWithFilterTaskConfig(
1059 MeasureDefectsTaskConfig,
1060 pipelineConnections=MeasureDefectsCombinedWithFilterConnections):
1061 """Configuration for measuring defects from combined exposures.
1062 """
1063 pass
1066class MeasureDefectsCombinedWithFilterTask(MeasureDefectsTask):
1067 """Task to measure defects in combined images."""
1069 ConfigClass = MeasureDefectsCombinedWithFilterTaskConfig
1070 _DefaultName = "cpDefectMeasureWithFilterCombined"
1073class MergeDefectsConnections(pipeBase.PipelineTaskConnections,
1074 dimensions=("instrument", "detector")):
1075 inputDefects = cT.Input(
1076 name="singleExpDefects",
1077 doc="Measured defect lists.",
1078 storageClass="Defects",
1079 dimensions=("instrument", "detector", "exposure",),
1080 multiple=True,
1081 )
1082 camera = cT.PrerequisiteInput(
1083 name='camera',
1084 doc="Camera associated with these defects.",
1085 storageClass="Camera",
1086 dimensions=("instrument", ),
1087 isCalibration=True,
1088 )
1090 mergedDefects = cT.Output(
1091 name="defects",
1092 doc="Final merged defects.",
1093 storageClass="Defects",
1094 dimensions=("instrument", "detector"),
1095 multiple=False,
1096 isCalibration=True,
1097 )
1100class MergeDefectsTaskConfig(pipeBase.PipelineTaskConfig,
1101 pipelineConnections=MergeDefectsConnections):
1102 """Configuration for merging single exposure defects.
1103 """
1105 assertSameRun = pexConfig.Field(
1106 dtype=bool,
1107 doc=("Ensure that all visits are from the same run? Raises if this is not the case, or "
1108 "if the run key isn't found."),
1109 default=False, # false because most obs_packages don't have runs. obs_lsst/ts8 overrides this.
1110 )
1111 ignoreFilters = pexConfig.Field(
1112 dtype=bool,
1113 doc=("Set the filters used in the CALIB_ID to NONE regardless of the filters on the input"
1114 " images. Allows mixing of filters in the input flats. Set to False if you think"
1115 " your defects might be chromatic and want to have registry support for varying"
1116 " defects with respect to filter."),
1117 default=True,
1118 )
1119 nullFilterName = pexConfig.Field(
1120 dtype=str,
1121 doc=("The name of the null filter if ignoreFilters is True. Usually something like NONE or EMPTY"),
1122 default="NONE",
1123 )
1124 combinationMode = pexConfig.ChoiceField(
1125 doc="Which types of defects to identify",
1126 dtype=str,
1127 default="FRACTION",
1128 allowed={
1129 "AND": "Logical AND the pixels found in each visit to form set ",
1130 "OR": "Logical OR the pixels found in each visit to form set ",
1131 "FRACTION": "Use pixels found in more than config.combinationFraction of visits ",
1132 }
1133 )
1134 combinationFraction = pexConfig.RangeField(
1135 dtype=float,
1136 doc=("The fraction (0..1) of visits in which a pixel was found to be defective across"
1137 " the visit list in order to be marked as a defect. Note, upper bound is exclusive, so use"
1138 " mode AND to require pixel to appear in all images."),
1139 default=0.7,
1140 min=0,
1141 max=1,
1142 )
1143 nPixBorderUpDown = pexConfig.Field(
1144 dtype=int,
1145 doc="Number of pixels on top & bottom of image to mask as defects if edgesAsDefects is True.",
1146 default=5,
1147 )
1148 nPixBorderLeftRight = pexConfig.Field(
1149 dtype=int,
1150 doc="Number of pixels on left & right of image to mask as defects if edgesAsDefects is True.",
1151 default=5,
1152 )
1153 edgesAsDefects = pexConfig.Field(
1154 dtype=bool,
1155 doc="Mark all edge pixels, as defined by nPixBorder[UpDown, LeftRight], as defects.",
1156 default=False,
1157 )
1160class MergeDefectsTask(pipeBase.PipelineTask):
1161 """Merge the defects from multiple exposures.
1162 """
1164 ConfigClass = MergeDefectsTaskConfig
1165 _DefaultName = 'cpDefectMerge'
1167 def run(self, inputDefects, camera):
1168 """Merge a list of single defects to find the common defect regions.
1170 Parameters
1171 ----------
1172 inputDefects : `list` [`lsst.ip.isr.Defects`]
1173 Partial defects from a single exposure.
1174 camera : `lsst.afw.cameraGeom.Camera`
1175 Camera to use for metadata.
1177 Returns
1178 -------
1179 results : `lsst.pipe.base.Struct`
1180 Results struct containing:
1182 ``mergedDefects``
1183 The defects merged from the input lists
1184 (`lsst.ip.isr.Defects`).
1185 """
1186 detectorId = inputDefects[0].getMetadata().get('DETECTOR', None)
1187 if detectorId is None:
1188 raise RuntimeError("Cannot identify detector id.")
1189 detector = camera[detectorId]
1191 imageTypes = set()
1192 for inDefect in inputDefects:
1193 imageType = inDefect.getMetadata().get('cpDefectGenImageType', 'UNKNOWN')
1194 imageTypes.add(imageType)
1196 # Determine common defect pixels separately for each input image type.
1197 splitDefects = list()
1198 for imageType in imageTypes:
1199 sumImage = afwImage.MaskedImageF(detector.getBBox())
1200 count = 0
1201 for inDefect in inputDefects:
1202 if imageType == inDefect.getMetadata().get('cpDefectGenImageType', 'UNKNOWN'):
1203 count += 1
1204 for defect in inDefect:
1205 sumImage.image[defect.getBBox()] += 1.0
1206 sumImage /= count
1207 nDetected = len(np.where(sumImage.getImage().getArray() > 0)[0])
1208 self.log.info("Pre-merge %s pixels with non-zero detections for %s", nDetected, imageType)
1210 if self.config.combinationMode == 'AND':
1211 threshold = 1.0
1212 elif self.config.combinationMode == 'OR':
1213 threshold = 0.0
1214 elif self.config.combinationMode == 'FRACTION':
1215 threshold = self.config.combinationFraction
1216 else:
1217 raise RuntimeError(f"Got unsupported combinationMode {self.config.combinationMode}")
1218 indices = np.where(sumImage.getImage().getArray() > threshold)
1219 BADBIT = sumImage.getMask().getPlaneBitMask('BAD')
1220 sumImage.getMask().getArray()[indices] |= BADBIT
1221 self.log.info("Post-merge %s pixels marked as defects for %s", len(indices[0]), imageType)
1222 partialDefect = Defects.fromMask(sumImage, 'BAD')
1223 splitDefects.append(partialDefect)
1225 # Do final combination of separate image types
1226 finalImage = afwImage.MaskedImageF(detector.getBBox())
1227 for inDefect in splitDefects:
1228 for defect in inDefect:
1229 finalImage.image[defect.getBBox()] += 1
1230 finalImage /= len(splitDefects)
1231 nDetected = len(np.where(finalImage.getImage().getArray() > 0)[0])
1232 self.log.info("Pre-final merge %s pixels with non-zero detections", nDetected)
1234 # This combination is the OR of all image types
1235 threshold = 0.0
1236 indices = np.where(finalImage.getImage().getArray() > threshold)
1237 BADBIT = finalImage.getMask().getPlaneBitMask('BAD')
1238 finalImage.getMask().getArray()[indices] |= BADBIT
1239 self.log.info("Post-final merge %s pixels marked as defects", len(indices[0]))
1241 if self.config.edgesAsDefects:
1242 self.log.info("Masking edge pixels as defects.")
1243 # This code follows the pattern from isrTask.maskEdges().
1244 if self.config.nPixBorderLeftRight > 0:
1245 box = detector.getBBox()
1246 subImage = finalImage[box]
1247 box.grow(Extent2I(-self.config.nPixBorderLeftRight, 0))
1248 SourceDetectionTask.setEdgeBits(subImage, box, BADBIT)
1249 if self.config.nPixBorderUpDown > 0:
1250 box = detector.getBBox()
1251 subImage = finalImage[box]
1252 box.grow(Extent2I(0, -self.config.nPixBorderUpDown))
1253 SourceDetectionTask.setEdgeBits(subImage, box, BADBIT)
1255 merged = Defects.fromMask(finalImage, 'BAD')
1256 merged.updateMetadataFromExposures(inputDefects)
1257 merged.updateMetadata(camera=camera, detector=detector, filterName=None,
1258 setCalibId=True, setDate=True)
1260 return pipeBase.Struct(
1261 mergedDefects=merged,
1262 )
1264# Subclass the MergeDefects task to reduce the input dimensions
1265# from ("instrument", "detector", "exposure") to
1266# ("instrument", "detector").
1269class MergeDefectsCombinedConnections(pipeBase.PipelineTaskConnections,
1270 dimensions=("instrument", "detector")):
1271 inputDarkDefects = cT.Input(
1272 name="cpDefectsFromDark",
1273 doc="Measured defect lists.",
1274 storageClass="Defects",
1275 dimensions=("instrument", "detector",),
1276 multiple=True,
1277 )
1278 inputBiasDefects = cT.Input(
1279 name="cpDefectsFromBias",
1280 doc="Additional measured defect lists.",
1281 storageClass="Defects",
1282 dimensions=("instrument", "detector",),
1283 multiple=True,
1284 )
1285 inputFlatDefects = cT.Input(
1286 name="cpDefectsFromFlat",
1287 doc="Additional measured defect lists.",
1288 storageClass="Defects",
1289 dimensions=("instrument", "detector", "physical_filter"),
1290 multiple=True,
1291 )
1292 inputManualDefects = cT.Input(
1293 name="cpManualDefects",
1294 doc="Additional manual defects.",
1295 storageClass="Defects",
1296 dimensions=("instrument", "detector"),
1297 multiple=True,
1298 isCalibration=True,
1299 )
1300 camera = cT.PrerequisiteInput(
1301 name='camera',
1302 doc="Camera associated with these defects.",
1303 storageClass="Camera",
1304 dimensions=("instrument", ),
1305 isCalibration=True,
1306 )
1308 mergedDefects = cT.Output(
1309 name="defects",
1310 doc="Final merged defects.",
1311 storageClass="Defects",
1312 dimensions=("instrument", "detector"),
1313 multiple=False,
1314 isCalibration=True,
1315 )
1317 def __init__(self, *, config=None):
1318 super().__init__(config=config)
1320 if config.doManualDefects is not True:
1321 del self.inputManualDefects
1324class MergeDefectsCombinedTaskConfig(MergeDefectsTaskConfig,
1325 pipelineConnections=MergeDefectsCombinedConnections):
1326 """Configuration for merging defects from combined exposure.
1327 """
1328 doManualDefects = pexConfig.Field(
1329 dtype=bool,
1330 doc="Apply manual defects?",
1331 default=False,
1332 )
1334 def validate(self):
1335 super().validate()
1336 if self.combinationMode != 'OR':
1337 raise ValueError("combinationMode must be 'OR'")
1340class MergeDefectsCombinedTask(MergeDefectsTask):
1341 """Task to measure defects in combined images."""
1343 ConfigClass = MergeDefectsCombinedTaskConfig
1344 _DefaultName = "cpMergeDefectsCombined"
1346 @staticmethod
1347 def chooseBest(inputs):
1348 """Select the input with the most exposures used."""
1349 best = 0
1350 if len(inputs) > 1:
1351 nInput = 0
1352 for num, exp in enumerate(inputs):
1353 # This technically overcounts by a factor of 3.
1354 N = len([k for k, v in exp.getMetadata().toDict().items() if "CPP_INPUT_" in k])
1355 if N > nInput:
1356 best = num
1357 nInput = N
1358 return inputs[best]
1360 def runQuantum(self, butlerQC, inputRefs, outputRefs):
1361 inputs = butlerQC.get(inputRefs)
1362 # Turn inputFlatDefects and inputDarkDefects into a list which
1363 # is what MergeDefectsTask expects. If there are multiple,
1364 # use the one with the most inputs.
1365 tempList = [self.chooseBest(inputs['inputFlatDefects']),
1366 self.chooseBest(inputs['inputDarkDefects']),
1367 self.chooseBest(inputs['inputBiasDefects'])]
1369 if "inputManualDefects" in inputs.keys():
1370 tempList.extend(inputs["inputManualDefects"])
1372 # Rename inputDefects
1373 inputsCombined = {'inputDefects': tempList, 'camera': inputs['camera']}
1375 outputs = super().run(**inputsCombined)
1376 butlerQC.put(outputs, outputRefs)