Coverage for python/lsst/cp/pipe/defects.py: 19%

380 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-28 04:01 -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# 

22 

23__all__ = ['MeasureDefectsTaskConfig', 'MeasureDefectsTask', 

24 'MergeDefectsTaskConfig', 'MergeDefectsTask', 

25 'MeasureDefectsCombinedTaskConfig', 'MeasureDefectsCombinedTask', 

26 'MergeDefectsCombinedTaskConfig', 'MergeDefectsCombinedTask', ] 

27 

28import numpy as np 

29 

30import lsst.pipe.base as pipeBase 

31import lsst.pipe.base.connectionTypes as cT 

32 

33from lsstDebug import getDebugFrame 

34import lsst.pex.config as pexConfig 

35 

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 

45 

46 

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 ) 

63 

64 outputDefects = cT.Output( 

65 name="singleExpDefects", 

66 doc="Output measured defects.", 

67 storageClass="Defects", 

68 dimensions=("instrument", "detector", "exposure"), 

69 ) 

70 

71 

72class MeasureDefectsTaskConfig(pipeBase.PipelineTaskConfig, 

73 pipelineConnections=MeasureDefectsConnections): 

74 """Configuration for measuring defects from a list of exposures 

75 """ 

76 

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 ) 

148 

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.") 

155 

156 

157class MeasureDefectsTask(pipeBase.PipelineTask): 

158 """Measure the defects from one exposure. 

159 """ 

160 

161 ConfigClass = MeasureDefectsTaskConfig 

162 _DefaultName = 'cpDefectMeasure' 

163 

164 def run(self, inputExp, camera): 

165 """Measure one exposure for defects. 

166 

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. 

173 

174 Returns 

175 ------- 

176 results : `lsst.pipe.base.Struct` 

177 Results struct containing: 

178 

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 

188 

189 defects = self._findHotAndColdPixels(inputExp) 

190 

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) 

194 

195 defects.updateMetadataFromExposures([inputExp]) 

196 defects.updateMetadata(camera=camera, detector=detector, filterName=filterName, 

197 setCalibId=True, setDate=True, 

198 cpDefectGenImageType=datasetType) 

199 

200 return pipeBase.Struct( 

201 outputDefects=defects, 

202 ) 

203 

204 @staticmethod 

205 def _nPixFromDefects(defects): 

206 """Count pixels in a defect. 

207 

208 Parameters 

209 ---------- 

210 defects : `lsst.ip.isr.Defects` 

211 Defects to measure. 

212 

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 

222 

223 def _findHotAndColdPixels(self, exp): 

224 """Find hot and cold pixels in an image. 

225 

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). 

230 

231 Parameters 

232 ---------- 

233 exp : `lsst.afw.image.exposure.Exposure` 

234 The exposure in which to find defects. 

235 

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 

243 

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 = [] 

248 

249 hotPixelCount = {} 

250 coldPixelCount = {} 

251 

252 for amp in exp.getDetector(): 

253 ampName = amp.getName() 

254 

255 hotPixelCount[ampName] = 0 

256 coldPixelCount[ampName] = 0 

257 

258 ampImg = maskedIm[amp.getBBox()].clone() 

259 

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] 

271 

272 if self._getNumGoodPixels(ampImg) == 0: # amp contains no usable pixels 

273 continue 

274 

275 # Remove a background estimate 

276 meanClip = afwMath.makeStatistics(ampImg, afwMath.MEANCLIP, ).getValue() 

277 ampImg -= meanClip 

278 

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). 

302 

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] 

327 

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)] 

336 

337 threshold = afwDetection.createThreshold(nSig, 'stdev', polarity=polarity) 

338 

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) 

347 

348 footprintSet.setMask(maskedIm.mask, ("DETECTED" if polarity else "DETECTED_NEGATIVE")) 

349 

350 if mergedSet is None: 

351 mergedSet = footprintSet 

352 else: 

353 mergedSet.merge(footprintSet) 

354 

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() 

363 

364 footprintList += mergedSet.getFootprints() 

365 

366 self.debugView('defectMap', ampImg, 

367 Defects.fromFootprintList(mergedSet.getFootprints()), exp.getDetector()) 

368 

369 defects = Defects.fromFootprintList(footprintList) 

370 defects, count = self.maskBlocksIfIntermitentBadPixelsInColumn(defects) 

371 defects.updateCounters(columns=count, hot=hotPixelCount, cold=coldPixelCount) 

372 

373 return defects 

374 

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 

381 

382 def _setEdgeBits(self, exposureOrMaskedImage, maskplaneToSet='EDGE'): 

383 """Set edge bits on an exposure or maskedImage. 

384 

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}") 

397 

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 

405 

406 def maskBlocksIfIntermitentBadPixelsInColumn(self, defects): 

407 """Mask blocks in a column if there are on-and-off bad pixels 

408 

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. 

412 

413 Parameters 

414 ---------- 

415 defects : `lsst.ip.isr.Defects` 

416 The defects found in the image so far 

417 

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)) 

438 

439 x, y = [], [] 

440 for coordinatePair in coordinates: 

441 x.append(coordinatePair[0]) 

442 y.append(coordinatePair[1]) 

443 

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 

455 

456 return defects, badColumnCount 

457 

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. 

461 

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. 

465 

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 

479 

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 

510 

511 def debugView(self, stepname, ampImage, defects, detector): # pragma: no cover 

512 """Plot the defects found by the task. 

513 

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) 

531 

532 maskedIm = ampImage.clone() 

533 defects.maskPixels(maskedIm, "BAD") 

534 

535 mpDict = maskedIm.mask.getMaskPlaneDict() 

536 for plane in mpDict.keys(): 

537 if plane in ['BAD']: 

538 continue 

539 disp.setMaskPlaneColor(plane, afwDisplay.IGNORE) 

540 

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 

549 

550 def debugHistogram(self, stepname, ampImage, nSigmaUsed, exp): 

551 """Make a histogram of the distribution of pixel values for 

552 each amp. 

553 

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. 

557 

558 Note that this currently only supports the 16-amp LSST 

559 detectors. 

560 

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 

575 

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)) 

580 

581 expTime = exp.getInfo().getVisitInfo().getExposureTime() 

582 

583 for (amp, a) in zip(reversed(detector), ax.flatten()): 

584 mi = exp.maskedImage[amp.getBBox()] 

585 

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() 

595 

596 thrUpper = mean + nSigmaUsed*sigma 

597 thrLower = mean - nSigmaUsed*sigma 

598 

599 nRight = len(imgData[imgData > thrUpper]) 

600 nLeft = len(imgData[imgData < thrLower]) 

601 

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') 

610 

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]) 

615 

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) 

623 

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() 

642 

643 

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 ) 

661 

662 outputDefects = cT.Output( 

663 name="cpPartialDefectsFromDarkCombined", 

664 doc="Output measured defects.", 

665 storageClass="Defects", 

666 dimensions=("instrument", "detector"), 

667 ) 

668 

669 

670class MeasureDefectsCombinedTaskConfig(MeasureDefectsTaskConfig, 

671 pipelineConnections=MeasureDefectsCombinedConnections): 

672 """Configuration for measuring defects from combined exposures. 

673 """ 

674 pass 

675 

676 

677class MeasureDefectsCombinedTask(MeasureDefectsTask): 

678 """Task to measure defects in combined images.""" 

679 

680 ConfigClass = MeasureDefectsCombinedTaskConfig 

681 _DefaultName = "cpDefectMeasureCombined" 

682 

683 

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 ) 

702 

703 outputDefects = cT.Output( 

704 name="cpPartialDefectsFromFlatCombinedWithFilter", 

705 doc="Output measured defects.", 

706 storageClass="Defects", 

707 dimensions=("instrument", "detector", "physical_filter"), 

708 ) 

709 

710 

711class MeasureDefectsCombinedWithFilterTaskConfig( 

712 MeasureDefectsTaskConfig, 

713 pipelineConnections=MeasureDefectsCombinedWithFilterConnections): 

714 """Configuration for measuring defects from combined exposures. 

715 """ 

716 pass 

717 

718 

719class MeasureDefectsCombinedWithFilterTask(MeasureDefectsTask): 

720 """Task to measure defects in combined images.""" 

721 

722 ConfigClass = MeasureDefectsCombinedWithFilterTaskConfig 

723 _DefaultName = "cpDefectMeasureWithFilterCombined" 

724 

725 

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 ) 

742 

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 ) 

751 

752 

753class MergeDefectsTaskConfig(pipeBase.PipelineTaskConfig, 

754 pipelineConnections=MergeDefectsConnections): 

755 """Configuration for merging single exposure defects. 

756 """ 

757 

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 ) 

811 

812 

813class MergeDefectsTask(pipeBase.PipelineTask): 

814 """Merge the defects from multiple exposures. 

815 """ 

816 

817 ConfigClass = MergeDefectsTaskConfig 

818 _DefaultName = 'cpDefectMerge' 

819 

820 def run(self, inputDefects, camera): 

821 """Merge a list of single defects to find the common defect regions. 

822 

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. 

829 

830 Returns 

831 ------- 

832 results : `lsst.pipe.base.Struct` 

833 Results struct containing: 

834 

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] 

843 

844 imageTypes = set() 

845 for inDefect in inputDefects: 

846 imageType = inDefect.getMetadata().get('cpDefectGenImageType', 'UNKNOWN') 

847 imageTypes.add(imageType) 

848 

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)) 

862 

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) 

877 

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, )) 

886 

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]), )) 

893 

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) 

907 

908 merged = Defects.fromMask(finalImage, 'BAD') 

909 merged.updateMetadataFromExposures(inputDefects) 

910 merged.updateMetadata(camera=camera, detector=detector, filterName=None, 

911 setCalibId=True, setDate=True) 

912 

913 return pipeBase.Struct( 

914 mergedDefects=merged, 

915 ) 

916 

917# Subclass the MergeDefects task to reduce the input dimensions 

918# from ("instrument", "detector", "exposure") to 

919# ("instrument", "detector"). 

920 

921 

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 ) 

952 

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 ) 

961 

962 

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'") 

971 

972 

973class MergeDefectsCombinedTask(MergeDefectsTask): 

974 """Task to measure defects in combined images.""" 

975 

976 ConfigClass = MergeDefectsCombinedTaskConfig 

977 _DefaultName = "cpDefectMergeCombined" 

978 

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] 

992 

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'])] 

1001 

1002 # Rename inputDefects 

1003 inputsCombined = {'inputDefects': tempList, 'camera': inputs['camera']} 

1004 

1005 outputs = super().run(**inputsCombined) 

1006 butlerQC.put(outputs, outputRefs)