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

270 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-10-04 03:20 -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 

38 

39from ._lookupStaticCalibration import lookupStaticCalibration 

40 

41__all__ = ['MeasureDefectsTaskConfig', 'MeasureDefectsTask', 

42 'MergeDefectsTaskConfig', 'MergeDefectsTask', ] 

43 

44 

45class MeasureDefectsConnections(pipeBase.PipelineTaskConnections, 

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

47 inputExp = cT.Input( 

48 name="defectExps", 

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

50 storageClass="Exposure", 

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

52 multiple=False 

53 ) 

54 camera = cT.PrerequisiteInput( 

55 name='camera', 

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

57 storageClass="Camera", 

58 dimensions=("instrument", ), 

59 isCalibration=True, 

60 lookupFunction=lookupStaticCalibration, 

61 ) 

62 

63 outputDefects = cT.Output( 

64 name="singleExpDefects", 

65 doc="Output measured defects.", 

66 storageClass="Defects", 

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

68 ) 

69 

70 

71class MeasureDefectsTaskConfig(pipeBase.PipelineTaskConfig, 

72 pipelineConnections=MeasureDefectsConnections): 

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

74 """ 

75 

76 nSigmaBright = pexConfig.Field( 

77 dtype=float, 

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

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

80 default=4.8, 

81 ) 

82 nSigmaDark = pexConfig.Field( 

83 dtype=float, 

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

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

86 default=-5.0, 

87 ) 

88 nPixBorderUpDown = pexConfig.Field( 

89 dtype=int, 

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

91 default=7, 

92 ) 

93 nPixBorderLeftRight = pexConfig.Field( 

94 dtype=int, 

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

96 default=7, 

97 ) 

98 badOnAndOffPixelColumnThreshold = pexConfig.Field( 

99 dtype=int, 

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

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

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

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

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

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

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

107 default=50, 

108 ) 

109 goodPixelColumnGapThreshold = pexConfig.Field( 

110 dtype=int, 

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

112 "'badOnAndOffPixelColumnThreshold')."), 

113 default=30, 

114 ) 

115 

116 def validate(self): 

117 super().validate() 

118 if self.nSigmaBright < 0.0: 

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

120 if self.nSigmaDark > 0.0: 

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

122 

123 

124class MeasureDefectsTask(pipeBase.PipelineTask): 

125 """Measure the defects from one exposure. 

126 """ 

127 

128 ConfigClass = MeasureDefectsTaskConfig 

129 _DefaultName = 'cpDefectMeasure' 

130 

131 def run(self, inputExp, camera): 

132 """Measure one exposure for defects. 

133 

134 Parameters 

135 ---------- 

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

137 Exposure to examine. 

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

139 Camera to use for metadata. 

140 

141 Returns 

142 ------- 

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

144 Results struct containing: 

145 

146 ``outputDefects`` 

147 The defects measured from this exposure 

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

149 """ 

150 detector = inputExp.getDetector() 

151 

152 filterName = inputExp.getFilter().physicalLabel 

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

154 

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

156 nSigmaList = [self.config.nSigmaBright] 

157 else: 

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

159 defects = self.findHotAndColdPixels(inputExp, nSigmaList) 

160 

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

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

163 

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

165 setCalibId=True, setDate=True, 

166 cpDefectGenImageType=datasetType) 

167 

168 return pipeBase.Struct( 

169 outputDefects=defects, 

170 ) 

171 

172 @staticmethod 

173 def _nPixFromDefects(defects): 

174 """Count pixels in a defect. 

175 

176 Parameters 

177 ---------- 

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

179 Defects to measure. 

180 

181 Returns 

182 ------- 

183 nPix : `int` 

184 Number of defect pixels. 

185 """ 

186 nPix = 0 

187 for defect in defects: 

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

189 return nPix 

190 

191 def findHotAndColdPixels(self, exp, nSigma): 

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

193 

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

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

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

197 cold pixels). 

198 

199 Parameters 

200 ---------- 

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

202 The exposure in which to find defects. 

203 nSigma : `list` [`float`] 

204 Detection threshold to use. Positive for DETECTED pixels, 

205 negative for DETECTED_NEGATIVE pixels. 

206 

207 Returns 

208 ------- 

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

210 The defects found in the image. 

211 """ 

212 self._setEdgeBits(exp) 

213 maskedIm = exp.maskedImage 

214 

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

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

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

218 footprintList = [] 

219 

220 for amp in exp.getDetector(): 

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

222 

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

224 if self.config.nPixBorderLeftRight: 

225 if ampImg.getX0() == 0: 

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

227 else: 

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

229 if self.config.nPixBorderUpDown: 

230 if ampImg.getY0() == 0: 

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

232 else: 

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

234 

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

236 continue 

237 

238 # Remove a background estimate 

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

240 

241 mergedSet = None 

242 for sigma in nSigma: 

243 nSig = np.abs(sigma) 

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

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

246 

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

248 

249 footprintSet = afwDetection.FootprintSet(ampImg, threshold) 

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

251 

252 if mergedSet is None: 

253 mergedSet = footprintSet 

254 else: 

255 mergedSet.merge(footprintSet) 

256 

257 footprintList += mergedSet.getFootprints() 

258 

259 self.debugView('defectMap', ampImg, 

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

261 

262 defects = Defects.fromFootprintList(footprintList) 

263 defects = self.maskBlocksIfIntermitentBadPixelsInColumn(defects) 

264 

265 return defects 

266 

267 @staticmethod 

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

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

270 nPixels = maskedIm.mask.array.size 

271 nBad = countMaskedPixels(maskedIm, badMaskString) 

272 return nPixels - nBad 

273 

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

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

276 

277 Raises 

278 ------ 

279 TypeError 

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

281 """ 

282 if isinstance(exposureOrMaskedImage, afwImage.Exposure): 

283 mi = exposureOrMaskedImage.maskedImage 

284 elif isinstance(exposureOrMaskedImage, afwImage.MaskedImage): 

285 mi = exposureOrMaskedImage 

286 else: 

287 t = type(exposureOrMaskedImage) 

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

289 

290 MASKBIT = mi.mask.getPlaneBitMask(maskplaneToSet) 

291 if self.config.nPixBorderLeftRight: 

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

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

294 if self.config.nPixBorderUpDown: 

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

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

297 

298 def maskBlocksIfIntermitentBadPixelsInColumn(self, defects): 

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

300 

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

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

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

304 

305 Parameters 

306 ---------- 

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

308 The defects found in the image so far 

309 

310 Returns 

311 ------- 

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

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

314 equal than self.config.badPixelColumnThreshold, the input 

315 list is returned. Otherwise, the defects list returned 

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

317 """ 

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

319 coordinates = [] 

320 for defect in defects: 

321 bbox = defect.getBBox() 

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

323 deltaX0, deltaY0 = bbox.getDimensions() 

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

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

326 coordinates.append((i, j)) 

327 

328 x, y = [], [] 

329 for coordinatePair in coordinates: 

330 x.append(coordinatePair[0]) 

331 y.append(coordinatePair[1]) 

332 

333 x = np.array(x) 

334 y = np.array(y) 

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

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

337 multipleX = [] 

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

339 if b >= self.config.badOnAndOffPixelColumnThreshold: 

340 multipleX.append(a) 

341 if len(multipleX) != 0: 

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

343 

344 return defects 

345 

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

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

348 threshold. 

349 

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

351 in a column is larger or equal than 

352 self.config.badOnAndOffPixelColumnThreshold. 

353 

354 Parameters 

355 --------- 

356 x : `list` 

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

358 along the short axis if amp. 

359 y : `list` 

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

361 along the long axis if amp. 

362 multipleX : list 

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

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

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

366 The defcts found in the image so far 

367 

368 Returns 

369 ------- 

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

371 The defects list returned that will include boxes that 

372 mask blocks of on-and-of pixels. 

373 """ 

374 with defects.bulk_update(): 

375 goodPixelColumnGapThreshold = self.config.goodPixelColumnGapThreshold 

376 for x0 in multipleX: 

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

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

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

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

381 # of good pixels between two consecutive bad pixels is 

382 # larger or equal than 'goodPixelColumnGapThreshold'. 

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

384 if len(diffIndex) != 0: 

385 limits = [minY] # put the minimum first 

386 for gapIndex in diffIndex: 

387 limits.append(multipleY[gapIndex]) 

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

389 limits.append(maxY) # maximum last 

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

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

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

393 defects.append(s) 

394 else: # No gap is large enough 

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

396 defects.append(s) 

397 return defects 

398 

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

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

401 

402 Parameters 

403 ---------- 

404 stepname : `str` 

405 Debug frame to request. 

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

407 Amplifier image to display. 

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

409 The defects to plot. 

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

411 Detector holding camera geometry. 

412 """ 

413 frame = getDebugFrame(self._display, stepname) 

414 if frame: 

415 disp = afwDisplay.Display(frame=frame) 

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

417 disp.setMaskTransparency(80) 

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

419 

420 maskedIm = ampImage.clone() 

421 defects.maskPixels(maskedIm, "BAD") 

422 

423 mpDict = maskedIm.mask.getMaskPlaneDict() 

424 for plane in mpDict.keys(): 

425 if plane in ['BAD']: 

426 continue 

427 disp.setMaskPlaneColor(plane, afwDisplay.IGNORE) 

428 

429 disp.setImageColormap('gray') 

430 disp.mtv(maskedIm) 

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

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

433 while True: 

434 ans = input(prompt).lower() 

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

436 break 

437 

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

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

440 each amp. 

441 

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

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

444 do not contribute to the underflow and overflow numbers. 

445 

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

447 detectors. 

448 

449 Parameters 

450 ---------- 

451 stepname : `str` 

452 Debug frame to request. 

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

454 Amplifier image to display. 

455 nSigmaUsed : `float` 

456 The number of sigma used for detection 

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

458 The exposure in which the defects were found. 

459 """ 

460 frame = getDebugFrame(self._display, stepname) 

461 if frame: 

462 import matplotlib.pyplot as plt 

463 

464 detector = exp.getDetector() 

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

466 nY = len(detector) // nX 

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

468 

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

470 

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

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

473 

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

475 # always work with master calibs 

476 mi.image.array /= expTime 

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

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

479 # Get array of pixels 

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

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

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

483 

484 thrUpper = mean + nSigmaUsed*sigma 

485 thrLower = mean - nSigmaUsed*sigma 

486 

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

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

489 

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

491 leftEdge = mean - nsig * nSigmaUsed*sigma 

492 rightEdge = mean + nsig * nSigmaUsed*sigma 

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

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

495 lw=1, edgecolor='red') 

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

497 lw=3, edgecolor='blue') 

498 

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

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

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

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

503 

504 # Put v-lines and textboxes in 

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

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

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

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

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

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

511 

512 # set axis limits and scales 

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

514 lPlot, rPlot = a.get_xlim() 

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

516 a.set_yscale('log') 

517 a.set_xlabel("ADU/s") 

518 fig.show() 

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

520 while True: 

521 ans = input(prompt).lower() 

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

523 break 

524 elif ans in ("p", ): 

525 import pdb 

526 pdb.set_trace() 

527 elif ans in ("h", ): 

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

529 plt.close() 

530 

531 

532class MergeDefectsConnections(pipeBase.PipelineTaskConnections, 

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

534 inputDefects = cT.Input( 

535 name="singleExpDefects", 

536 doc="Measured defect lists.", 

537 storageClass="Defects", 

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

539 multiple=True, 

540 ) 

541 camera = cT.PrerequisiteInput( 

542 name='camera', 

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

544 storageClass="Camera", 

545 dimensions=("instrument", ), 

546 isCalibration=True, 

547 lookupFunction=lookupStaticCalibration, 

548 ) 

549 

550 mergedDefects = cT.Output( 

551 name="defects", 

552 doc="Final merged defects.", 

553 storageClass="Defects", 

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

555 multiple=False, 

556 isCalibration=True, 

557 ) 

558 

559 

560class MergeDefectsTaskConfig(pipeBase.PipelineTaskConfig, 

561 pipelineConnections=MergeDefectsConnections): 

562 """Configuration for merging single exposure defects. 

563 """ 

564 

565 assertSameRun = pexConfig.Field( 

566 dtype=bool, 

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

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

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

570 ) 

571 ignoreFilters = pexConfig.Field( 

572 dtype=bool, 

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

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

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

576 " defects with respect to filter."), 

577 default=True, 

578 ) 

579 nullFilterName = pexConfig.Field( 

580 dtype=str, 

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

582 default="NONE", 

583 ) 

584 combinationMode = pexConfig.ChoiceField( 

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

586 dtype=str, 

587 default="FRACTION", 

588 allowed={ 

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

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

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

592 } 

593 ) 

594 combinationFraction = pexConfig.RangeField( 

595 dtype=float, 

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

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

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

599 default=0.7, 

600 min=0, 

601 max=1, 

602 ) 

603 edgesAsDefects = pexConfig.Field( 

604 dtype=bool, 

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

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

607 " defect will be located there."), 

608 default=False, 

609 ) 

610 

611 

612class MergeDefectsTask(pipeBase.PipelineTask): 

613 """Merge the defects from multiple exposures. 

614 """ 

615 

616 ConfigClass = MergeDefectsTaskConfig 

617 _DefaultName = 'cpDefectMerge' 

618 

619 def run(self, inputDefects, camera): 

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

621 

622 Parameters 

623 ---------- 

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

625 Partial defects from a single exposure. 

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

627 Camera to use for metadata. 

628 

629 Returns 

630 ------- 

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

632 Results struct containing: 

633 

634 ``mergedDefects`` 

635 The defects merged from the input lists 

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

637 """ 

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

639 if detectorId is None: 

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

641 detector = camera[detectorId] 

642 

643 imageTypes = set() 

644 for inDefect in inputDefects: 

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

646 imageTypes.add(imageType) 

647 

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

649 splitDefects = list() 

650 for imageType in imageTypes: 

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

652 count = 0 

653 for inDefect in inputDefects: 

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

655 count += 1 

656 for defect in inDefect: 

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

658 sumImage /= count 

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

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

661 

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

663 threshold = 1.0 

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

665 threshold = 0.0 

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

667 threshold = self.config.combinationFraction 

668 else: 

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

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

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

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

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

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

675 splitDefects.append(partialDefect) 

676 

677 # Do final combination of separate image types 

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

679 for inDefect in splitDefects: 

680 for defect in inDefect: 

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

682 finalImage /= len(splitDefects) 

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

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

685 

686 # This combination is the OR of all image types 

687 threshold = 0.0 

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

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

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

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

692 

693 if self.config.edgesAsDefects: 

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

695 # Do the same as IsrTask.maskEdges() 

696 box = detector.getBBox() 

697 subImage = finalImage[box] 

698 box.grow(-self.nPixBorder) 

699 SourceDetectionTask.setEdgeBits(subImage, box, BADBIT) 

700 

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

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

703 setCalibId=True, setDate=True) 

704 

705 return pipeBase.Struct( 

706 mergedDefects=merged, 

707 )