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

276 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-03 01:40 -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# 

22import numpy as np 

23 

24import lsst.pipe.base as pipeBase 

25import lsst.pipe.base.connectionTypes as cT 

26 

27from lsstDebug import getDebugFrame 

28import lsst.pex.config as pexConfig 

29 

30import lsst.afw.image as afwImage 

31import lsst.afw.math as afwMath 

32import lsst.afw.detection as afwDetection 

33import lsst.afw.display as afwDisplay 

34from lsst.afw import cameraGeom 

35from lsst.geom import Box2I, Point2I 

36from lsst.meas.algorithms import SourceDetectionTask 

37from lsst.ip.isr import Defects, countMaskedPixels 

38from lsst.pex.exceptions import InvalidParameterError 

39 

40from ._lookupStaticCalibration import lookupStaticCalibration 

41 

42__all__ = ['MeasureDefectsTaskConfig', 'MeasureDefectsTask', 

43 'MergeDefectsTaskConfig', 'MergeDefectsTask', ] 

44 

45 

46class MeasureDefectsConnections(pipeBase.PipelineTaskConnections, 

47 dimensions=("instrument", "exposure", "detector")): 

48 inputExp = cT.Input( 

49 name="defectExps", 

50 doc="Input ISR-processed exposures to measure.", 

51 storageClass="Exposure", 

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

53 multiple=False 

54 ) 

55 camera = cT.PrerequisiteInput( 

56 name='camera', 

57 doc="Camera associated with this exposure.", 

58 storageClass="Camera", 

59 dimensions=("instrument", ), 

60 isCalibration=True, 

61 lookupFunction=lookupStaticCalibration, 

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 nSigmaBright = pexConfig.Field( 

78 dtype=float, 

79 doc=("Number of sigma above mean for bright pixel detection. The default value was found to be" 

80 " appropriate for some LSST sensors in DM-17490."), 

81 default=4.8, 

82 ) 

83 nSigmaDark = pexConfig.Field( 

84 dtype=float, 

85 doc=("Number of sigma below mean for dark pixel detection. The default value was found to be" 

86 " appropriate for some LSST sensors in DM-17490."), 

87 default=-5.0, 

88 ) 

89 nPixBorderUpDown = pexConfig.Field( 

90 dtype=int, 

91 doc="Number of pixels to exclude from top & bottom of image when looking for defects.", 

92 default=7, 

93 ) 

94 nPixBorderLeftRight = pexConfig.Field( 

95 dtype=int, 

96 doc="Number of pixels to exclude from left & right of image when looking for defects.", 

97 default=7, 

98 ) 

99 badOnAndOffPixelColumnThreshold = pexConfig.Field( 

100 dtype=int, 

101 doc=("If BPC is the set of all the bad pixels in a given column (not necessarily consecutive) " 

102 "and the size of BPC is at least 'badOnAndOffPixelColumnThreshold', all the pixels between the " 

103 "pixels that satisfy minY (BPC) and maxY (BPC) will be marked as bad, with 'Y' being the long " 

104 "axis of the amplifier (and 'X' the other axis, which for a column is a constant for all " 

105 "pixels in the set BPC). If there are more than 'goodPixelColumnGapThreshold' consecutive " 

106 "non-bad pixels in BPC, an exception to the above is made and those consecutive " 

107 "'goodPixelColumnGapThreshold' are not marked as bad."), 

108 default=50, 

109 ) 

110 goodPixelColumnGapThreshold = pexConfig.Field( 

111 dtype=int, 

112 doc=("Size, in pixels, of usable consecutive pixels in a column with on and off bad pixels (see " 

113 "'badOnAndOffPixelColumnThreshold')."), 

114 default=30, 

115 ) 

116 

117 def validate(self): 

118 super().validate() 

119 if self.nSigmaBright < 0.0: 

120 raise ValueError("nSigmaBright must be above 0.0.") 

121 if self.nSigmaDark > 0.0: 

122 raise ValueError("nSigmaDark must be below 0.0.") 

123 

124 

125class MeasureDefectsTask(pipeBase.PipelineTask): 

126 """Measure the defects from one exposure. 

127 """ 

128 

129 ConfigClass = MeasureDefectsTaskConfig 

130 _DefaultName = 'cpDefectMeasure' 

131 

132 def run(self, inputExp, camera): 

133 """Measure one exposure for defects. 

134 

135 Parameters 

136 ---------- 

137 inputExp : `lsst.afw.image.Exposure` 

138 Exposure to examine. 

139 camera : `lsst.afw.cameraGeom.Camera` 

140 Camera to use for metadata. 

141 

142 Returns 

143 ------- 

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

145 Results struct containing: 

146 

147 ``outputDefects`` 

148 The defects measured from this exposure 

149 (`lsst.ip.isr.Defects`). 

150 """ 

151 detector = inputExp.getDetector() 

152 

153 filterName = inputExp.getFilter().physicalLabel 

154 datasetType = inputExp.getMetadata().get('IMGTYPE', 'UNKNOWN') 

155 

156 if datasetType.lower() == 'dark': 

157 nSigmaList = [self.config.nSigmaBright] 

158 else: 

159 nSigmaList = [self.config.nSigmaBright, self.config.nSigmaDark] 

160 defects = self.findHotAndColdPixels(inputExp, nSigmaList) 

161 

162 msg = "Found %s defects containing %s pixels in %s" 

163 self.log.info(msg, len(defects), self._nPixFromDefects(defects), datasetType) 

164 

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

166 setCalibId=True, setDate=True, 

167 cpDefectGenImageType=datasetType) 

168 

169 return pipeBase.Struct( 

170 outputDefects=defects, 

171 ) 

172 

173 @staticmethod 

174 def _nPixFromDefects(defects): 

175 """Count pixels in a defect. 

176 

177 Parameters 

178 ---------- 

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

180 Defects to measure. 

181 

182 Returns 

183 ------- 

184 nPix : `int` 

185 Number of defect pixels. 

186 """ 

187 nPix = 0 

188 for defect in defects: 

189 nPix += defect.getBBox().getArea() 

190 return nPix 

191 

192 def findHotAndColdPixels(self, exp, nSigma): 

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

194 

195 Using config-defined thresholds on a per-amp basis, mask 

196 pixels that are nSigma above threshold in dark frames (hot 

197 pixels), or nSigma away from the clipped mean in flats (hot & 

198 cold pixels). 

199 

200 Parameters 

201 ---------- 

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

203 The exposure in which to find defects. 

204 nSigma : `list` [`float`] 

205 Detection threshold to use. Positive for DETECTED pixels, 

206 negative for DETECTED_NEGATIVE pixels. 

207 

208 Returns 

209 ------- 

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

211 The defects found in the image. 

212 """ 

213 self._setEdgeBits(exp) 

214 maskedIm = exp.maskedImage 

215 

216 # the detection polarity for afwDetection, True for positive, 

217 # False for negative, and therefore True for darks as they only have 

218 # bright pixels, and both for flats, as they have bright and dark pix 

219 footprintList = [] 

220 

221 for amp in exp.getDetector(): 

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

223 

224 # crop ampImage depending on where the amp lies in the image 

225 if self.config.nPixBorderLeftRight: 

226 if ampImg.getX0() == 0: 

227 ampImg = ampImg[self.config.nPixBorderLeftRight:, :, afwImage.LOCAL] 

228 else: 

229 ampImg = ampImg[:-self.config.nPixBorderLeftRight, :, afwImage.LOCAL] 

230 if self.config.nPixBorderUpDown: 

231 if ampImg.getY0() == 0: 

232 ampImg = ampImg[:, self.config.nPixBorderUpDown:, afwImage.LOCAL] 

233 else: 

234 ampImg = ampImg[:, :-self.config.nPixBorderUpDown, afwImage.LOCAL] 

235 

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

237 continue 

238 

239 # Remove a background estimate 

240 ampImg -= afwMath.makeStatistics(ampImg, afwMath.MEANCLIP, ).getValue() 

241 

242 mergedSet = None 

243 for sigma in nSigma: 

244 nSig = np.abs(sigma) 

245 self.debugHistogram('ampFlux', ampImg, nSig, exp) 

246 polarity = {-1: False, 1: True}[np.sign(sigma)] 

247 

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

249 

250 try: 

251 footprintSet = afwDetection.FootprintSet(ampImg, threshold) 

252 except InvalidParameterError: 

253 # This occurs if the image sigma value is 0.0. 

254 # Let's mask the whole area. 

255 minValue = np.nanmin(ampImg.image.array) - 1.0 

256 threshold = afwDetection.createThreshold(minValue, 'value', polarity=True) 

257 footprintSet = afwDetection.FootprintSet(ampImg, threshold) 

258 

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

260 

261 if mergedSet is None: 

262 mergedSet = footprintSet 

263 else: 

264 mergedSet.merge(footprintSet) 

265 

266 footprintList += mergedSet.getFootprints() 

267 

268 self.debugView('defectMap', ampImg, 

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

270 

271 defects = Defects.fromFootprintList(footprintList) 

272 defects = self.maskBlocksIfIntermitentBadPixelsInColumn(defects) 

273 

274 return defects 

275 

276 @staticmethod 

277 def _getNumGoodPixels(maskedIm, badMaskString="NO_DATA"): 

278 """Return the number of non-bad pixels in the image.""" 

279 nPixels = maskedIm.mask.array.size 

280 nBad = countMaskedPixels(maskedIm, badMaskString) 

281 return nPixels - nBad 

282 

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

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

285 

286 Raises 

287 ------ 

288 TypeError 

289 Raised if parameter ``exposureOrMaskedImage`` is an invalid type. 

290 """ 

291 if isinstance(exposureOrMaskedImage, afwImage.Exposure): 

292 mi = exposureOrMaskedImage.maskedImage 

293 elif isinstance(exposureOrMaskedImage, afwImage.MaskedImage): 

294 mi = exposureOrMaskedImage 

295 else: 

296 t = type(exposureOrMaskedImage) 

297 raise TypeError(f"Function supports exposure or maskedImage but not {t}") 

298 

299 MASKBIT = mi.mask.getPlaneBitMask(maskplaneToSet) 

300 if self.config.nPixBorderLeftRight: 

301 mi.mask[: self.config.nPixBorderLeftRight, :, afwImage.LOCAL] |= MASKBIT 

302 mi.mask[-self.config.nPixBorderLeftRight:, :, afwImage.LOCAL] |= MASKBIT 

303 if self.config.nPixBorderUpDown: 

304 mi.mask[:, : self.config.nPixBorderUpDown, afwImage.LOCAL] |= MASKBIT 

305 mi.mask[:, -self.config.nPixBorderUpDown:, afwImage.LOCAL] |= MASKBIT 

306 

307 def maskBlocksIfIntermitentBadPixelsInColumn(self, defects): 

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

309 

310 If there's a column with on and off bad pixels, mask all the 

311 pixels in between, except if there is a large enough gap of 

312 consecutive good pixels between two bad pixels in the column. 

313 

314 Parameters 

315 ---------- 

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

317 The defects found in the image so far 

318 

319 Returns 

320 ------- 

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

322 If the number of bad pixels in a column is not larger or 

323 equal than self.config.badPixelColumnThreshold, the input 

324 list is returned. Otherwise, the defects list returned 

325 will include boxes that mask blocks of on-and-of pixels. 

326 """ 

327 # Get the (x, y) values of each bad pixel in amp. 

328 coordinates = [] 

329 for defect in defects: 

330 bbox = defect.getBBox() 

331 x0, y0 = bbox.getMinX(), bbox.getMinY() 

332 deltaX0, deltaY0 = bbox.getDimensions() 

333 for j in np.arange(y0, y0+deltaY0): 

334 for i in np.arange(x0, x0 + deltaX0): 

335 coordinates.append((i, j)) 

336 

337 x, y = [], [] 

338 for coordinatePair in coordinates: 

339 x.append(coordinatePair[0]) 

340 y.append(coordinatePair[1]) 

341 

342 x = np.array(x) 

343 y = np.array(y) 

344 # Find the defects with same "x" (vertical) coordinate (column). 

345 unique, counts = np.unique(x, return_counts=True) 

346 multipleX = [] 

347 for (a, b) in zip(unique, counts): 

348 if b >= self.config.badOnAndOffPixelColumnThreshold: 

349 multipleX.append(a) 

350 if len(multipleX) != 0: 

351 defects = self._markBlocksInBadColumn(x, y, multipleX, defects) 

352 

353 return defects 

354 

355 def _markBlocksInBadColumn(self, x, y, multipleX, defects): 

356 """Mask blocks in a column if number of on-and-off bad pixels is above 

357 threshold. 

358 

359 This function is called if the number of on-and-off bad pixels 

360 in a column is larger or equal than 

361 self.config.badOnAndOffPixelColumnThreshold. 

362 

363 Parameters 

364 --------- 

365 x : `list` 

366 Lower left x coordinate of defect box. x coordinate is 

367 along the short axis if amp. 

368 y : `list` 

369 Lower left y coordinate of defect box. x coordinate is 

370 along the long axis if amp. 

371 multipleX : list 

372 List of x coordinates in amp. with multiple bad pixels 

373 (i.e., columns with defects). 

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

375 The defcts found in the image so far 

376 

377 Returns 

378 ------- 

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

380 The defects list returned that will include boxes that 

381 mask blocks of on-and-of pixels. 

382 """ 

383 with defects.bulk_update(): 

384 goodPixelColumnGapThreshold = self.config.goodPixelColumnGapThreshold 

385 for x0 in multipleX: 

386 index = np.where(x == x0) 

387 multipleY = y[index] # multipleY and multipleX are in 1-1 correspondence. 

388 minY, maxY = np.min(multipleY), np.max(multipleY) 

389 # Next few lines: don't mask pixels in column if gap 

390 # of good pixels between two consecutive bad pixels is 

391 # larger or equal than 'goodPixelColumnGapThreshold'. 

392 diffIndex = np.where(np.diff(multipleY) >= goodPixelColumnGapThreshold)[0] 

393 if len(diffIndex) != 0: 

394 limits = [minY] # put the minimum first 

395 for gapIndex in diffIndex: 

396 limits.append(multipleY[gapIndex]) 

397 limits.append(multipleY[gapIndex+1]) 

398 limits.append(maxY) # maximum last 

399 assert len(limits)%2 == 0, 'limits is even by design, but check anyways' 

400 for i in np.arange(0, len(limits)-1, 2): 

401 s = Box2I(minimum=Point2I(x0, limits[i]), maximum=Point2I(x0, limits[i+1])) 

402 defects.append(s) 

403 else: # No gap is large enough 

404 s = Box2I(minimum=Point2I(x0, minY), maximum=Point2I(x0, maxY)) 

405 defects.append(s) 

406 return defects 

407 

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

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

410 

411 Parameters 

412 ---------- 

413 stepname : `str` 

414 Debug frame to request. 

415 ampImage : `lsst.afw.image.MaskedImage` 

416 Amplifier image to display. 

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

418 The defects to plot. 

419 detector : `lsst.afw.cameraGeom.Detector` 

420 Detector holding camera geometry. 

421 """ 

422 frame = getDebugFrame(self._display, stepname) 

423 if frame: 

424 disp = afwDisplay.Display(frame=frame) 

425 disp.scale('asinh', 'zscale') 

426 disp.setMaskTransparency(80) 

427 disp.setMaskPlaneColor("BAD", afwDisplay.RED) 

428 

429 maskedIm = ampImage.clone() 

430 defects.maskPixels(maskedIm, "BAD") 

431 

432 mpDict = maskedIm.mask.getMaskPlaneDict() 

433 for plane in mpDict.keys(): 

434 if plane in ['BAD']: 

435 continue 

436 disp.setMaskPlaneColor(plane, afwDisplay.IGNORE) 

437 

438 disp.setImageColormap('gray') 

439 disp.mtv(maskedIm) 

440 cameraGeom.utils.overlayCcdBoxes(detector, isTrimmed=True, display=disp) 

441 prompt = "Press Enter to continue [c]... " 

442 while True: 

443 ans = input(prompt).lower() 

444 if ans in ('', 'c', ): 

445 break 

446 

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

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

449 each amp. 

450 

451 The main image data histogram is plotted in blue. Edge 

452 pixels, if masked, are in red. Note that masked edge pixels 

453 do not contribute to the underflow and overflow numbers. 

454 

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

456 detectors. 

457 

458 Parameters 

459 ---------- 

460 stepname : `str` 

461 Debug frame to request. 

462 ampImage : `lsst.afw.image.MaskedImage` 

463 Amplifier image to display. 

464 nSigmaUsed : `float` 

465 The number of sigma used for detection 

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

467 The exposure in which the defects were found. 

468 """ 

469 frame = getDebugFrame(self._display, stepname) 

470 if frame: 

471 import matplotlib.pyplot as plt 

472 

473 detector = exp.getDetector() 

474 nX = np.floor(np.sqrt(len(detector))) 

475 nY = len(detector) // nX 

476 fig, ax = plt.subplots(nrows=int(nY), ncols=int(nX), sharex='col', sharey='row', figsize=(13, 10)) 

477 

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

479 

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

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

482 

483 # normalize by expTime as we plot in ADU/s and don't 

484 # always work with master calibs 

485 mi.image.array /= expTime 

486 stats = afwMath.makeStatistics(mi, afwMath.MEANCLIP | afwMath.STDEVCLIP) 

487 mean, sigma = stats.getValue(afwMath.MEANCLIP), stats.getValue(afwMath.STDEVCLIP) 

488 # Get array of pixels 

489 EDGEBIT = exp.maskedImage.mask.getPlaneBitMask("EDGE") 

490 imgData = mi.image.array[(mi.mask.array & EDGEBIT) == 0].flatten() 

491 edgeData = mi.image.array[(mi.mask.array & EDGEBIT) != 0].flatten() 

492 

493 thrUpper = mean + nSigmaUsed*sigma 

494 thrLower = mean - nSigmaUsed*sigma 

495 

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

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

498 

499 nsig = nSigmaUsed + 1.2 # add something small so the edge of the plot is out from level used 

500 leftEdge = mean - nsig * nSigmaUsed*sigma 

501 rightEdge = mean + nsig * nSigmaUsed*sigma 

502 nbins = np.linspace(leftEdge, rightEdge, 1000) 

503 ey, bin_borders, patches = a.hist(edgeData, histtype='step', bins=nbins, 

504 lw=1, edgecolor='red') 

505 y, bin_borders, patches = a.hist(imgData, histtype='step', bins=nbins, 

506 lw=3, edgecolor='blue') 

507 

508 # Report number of entries in over- and under-flow 

509 # bins, i.e. off the edges of the histogram 

510 nOverflow = len(imgData[imgData > rightEdge]) 

511 nUnderflow = len(imgData[imgData < leftEdge]) 

512 

513 # Put v-lines and textboxes in 

514 a.axvline(thrUpper, c='k') 

515 a.axvline(thrLower, c='k') 

516 msg = f"{amp.getName()}\nmean:{mean: .2f}\n$\\sigma$:{sigma: .2f}" 

517 a.text(0.65, 0.6, msg, transform=a.transAxes, fontsize=11) 

518 msg = f"nLeft:{nLeft}\nnRight:{nRight}\nnOverflow:{nOverflow}\nnUnderflow:{nUnderflow}" 

519 a.text(0.03, 0.6, msg, transform=a.transAxes, fontsize=11.5) 

520 

521 # set axis limits and scales 

522 a.set_ylim([1., 1.7*np.max(y)]) 

523 lPlot, rPlot = a.get_xlim() 

524 a.set_xlim(np.array([lPlot, rPlot])) 

525 a.set_yscale('log') 

526 a.set_xlabel("ADU/s") 

527 fig.show() 

528 prompt = "Press Enter or c to continue [chp]..." 

529 while True: 

530 ans = input(prompt).lower() 

531 if ans in ("", " ", "c",): 

532 break 

533 elif ans in ("p", ): 

534 import pdb 

535 pdb.set_trace() 

536 elif ans in ("h", ): 

537 print("[h]elp [c]ontinue [p]db") 

538 plt.close() 

539 

540 

541class MergeDefectsConnections(pipeBase.PipelineTaskConnections, 

542 dimensions=("instrument", "detector")): 

543 inputDefects = cT.Input( 

544 name="singleExpDefects", 

545 doc="Measured defect lists.", 

546 storageClass="Defects", 

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

548 multiple=True, 

549 ) 

550 camera = cT.PrerequisiteInput( 

551 name='camera', 

552 doc="Camera associated with these defects.", 

553 storageClass="Camera", 

554 dimensions=("instrument", ), 

555 isCalibration=True, 

556 lookupFunction=lookupStaticCalibration, 

557 ) 

558 

559 mergedDefects = cT.Output( 

560 name="defects", 

561 doc="Final merged defects.", 

562 storageClass="Defects", 

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

564 multiple=False, 

565 isCalibration=True, 

566 ) 

567 

568 

569class MergeDefectsTaskConfig(pipeBase.PipelineTaskConfig, 

570 pipelineConnections=MergeDefectsConnections): 

571 """Configuration for merging single exposure defects. 

572 """ 

573 

574 assertSameRun = pexConfig.Field( 

575 dtype=bool, 

576 doc=("Ensure that all visits are from the same run? Raises if this is not the case, or " 

577 "if the run key isn't found."), 

578 default=False, # false because most obs_packages don't have runs. obs_lsst/ts8 overrides this. 

579 ) 

580 ignoreFilters = pexConfig.Field( 

581 dtype=bool, 

582 doc=("Set the filters used in the CALIB_ID to NONE regardless of the filters on the input" 

583 " images. Allows mixing of filters in the input flats. Set to False if you think" 

584 " your defects might be chromatic and want to have registry support for varying" 

585 " defects with respect to filter."), 

586 default=True, 

587 ) 

588 nullFilterName = pexConfig.Field( 

589 dtype=str, 

590 doc=("The name of the null filter if ignoreFilters is True. Usually something like NONE or EMPTY"), 

591 default="NONE", 

592 ) 

593 combinationMode = pexConfig.ChoiceField( 

594 doc="Which types of defects to identify", 

595 dtype=str, 

596 default="FRACTION", 

597 allowed={ 

598 "AND": "Logical AND the pixels found in each visit to form set ", 

599 "OR": "Logical OR the pixels found in each visit to form set ", 

600 "FRACTION": "Use pixels found in more than config.combinationFraction of visits ", 

601 } 

602 ) 

603 combinationFraction = pexConfig.RangeField( 

604 dtype=float, 

605 doc=("The fraction (0..1) of visits in which a pixel was found to be defective across" 

606 " the visit list in order to be marked as a defect. Note, upper bound is exclusive, so use" 

607 " mode AND to require pixel to appear in all images."), 

608 default=0.7, 

609 min=0, 

610 max=1, 

611 ) 

612 edgesAsDefects = pexConfig.Field( 

613 dtype=bool, 

614 doc=("Mark all edge pixels, as defined by nPixBorder[UpDown, LeftRight], as defects." 

615 " Normal treatment is to simply exclude this region from the defect finding, such that no" 

616 " defect will be located there."), 

617 default=False, 

618 ) 

619 

620 

621class MergeDefectsTask(pipeBase.PipelineTask): 

622 """Merge the defects from multiple exposures. 

623 """ 

624 

625 ConfigClass = MergeDefectsTaskConfig 

626 _DefaultName = 'cpDefectMerge' 

627 

628 def run(self, inputDefects, camera): 

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

630 

631 Parameters 

632 ---------- 

633 inputDefects : `list` [`lsst.ip.isr.Defects`] 

634 Partial defects from a single exposure. 

635 camera : `lsst.afw.cameraGeom.Camera` 

636 Camera to use for metadata. 

637 

638 Returns 

639 ------- 

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

641 Results struct containing: 

642 

643 ``mergedDefects`` 

644 The defects merged from the input lists 

645 (`lsst.ip.isr.Defects`). 

646 """ 

647 detectorId = inputDefects[0].getMetadata().get('DETECTOR', None) 

648 if detectorId is None: 

649 raise RuntimeError("Cannot identify detector id.") 

650 detector = camera[detectorId] 

651 

652 imageTypes = set() 

653 for inDefect in inputDefects: 

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

655 imageTypes.add(imageType) 

656 

657 # Determine common defect pixels separately for each input image type. 

658 splitDefects = list() 

659 for imageType in imageTypes: 

660 sumImage = afwImage.MaskedImageF(detector.getBBox()) 

661 count = 0 

662 for inDefect in inputDefects: 

663 if imageType == inDefect.getMetadata().get('cpDefectGenImageType', 'UNKNOWN'): 

664 count += 1 

665 for defect in inDefect: 

666 sumImage.image[defect.getBBox()] += 1.0 

667 sumImage /= count 

668 nDetected = len(np.where(sumImage.getImage().getArray() > 0)[0]) 

669 self.log.info("Pre-merge %s pixels with non-zero detections for %s" % (nDetected, imageType)) 

670 

671 if self.config.combinationMode == 'AND': 

672 threshold = 1.0 

673 elif self.config.combinationMode == 'OR': 

674 threshold = 0.0 

675 elif self.config.combinationMode == 'FRACTION': 

676 threshold = self.config.combinationFraction 

677 else: 

678 raise RuntimeError(f"Got unsupported combinationMode {self.config.combinationMode}") 

679 indices = np.where(sumImage.getImage().getArray() > threshold) 

680 BADBIT = sumImage.getMask().getPlaneBitMask('BAD') 

681 sumImage.getMask().getArray()[indices] |= BADBIT 

682 self.log.info("Post-merge %s pixels marked as defects for %s" % (len(indices[0]), imageType)) 

683 partialDefect = Defects.fromMask(sumImage, 'BAD') 

684 splitDefects.append(partialDefect) 

685 

686 # Do final combination of separate image types 

687 finalImage = afwImage.MaskedImageF(detector.getBBox()) 

688 for inDefect in splitDefects: 

689 for defect in inDefect: 

690 finalImage.image[defect.getBBox()] += 1 

691 finalImage /= len(splitDefects) 

692 nDetected = len(np.where(finalImage.getImage().getArray() > 0)[0]) 

693 self.log.info("Pre-final merge %s pixels with non-zero detections" % (nDetected, )) 

694 

695 # This combination is the OR of all image types 

696 threshold = 0.0 

697 indices = np.where(finalImage.getImage().getArray() > threshold) 

698 BADBIT = finalImage.getMask().getPlaneBitMask('BAD') 

699 finalImage.getMask().getArray()[indices] |= BADBIT 

700 self.log.info("Post-final merge %s pixels marked as defects" % (len(indices[0]), )) 

701 

702 if self.config.edgesAsDefects: 

703 self.log.info("Masking edge pixels as defects.") 

704 # Do the same as IsrTask.maskEdges() 

705 box = detector.getBBox() 

706 subImage = finalImage[box] 

707 box.grow(-self.nPixBorder) 

708 SourceDetectionTask.setEdgeBits(subImage, box, BADBIT) 

709 

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

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

712 setCalibId=True, setDate=True) 

713 

714 return pipeBase.Struct( 

715 mergedDefects=merged, 

716 )