Coverage for python/lsst/cp/pipe/cpDefects.py: 71%

548 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-16 10:36 +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# 

22 

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

24 'MergeDefectsTaskConfig', 'MergeDefectsTask', 

25 'MeasureDefectsCombinedTaskConfig', 'MeasureDefectsCombinedTask', 

26 'MeasureDefectsCombinedWithFilterTaskConfig', 'MeasureDefectsCombinedWithFilterTask', 

27 'MergeDefectsCombinedTaskConfig', 'MergeDefectsCombinedTask', ] 

28 

29import numpy as np 

30 

31import lsst.pipe.base as pipeBase 

32import lsst.pipe.base.connectionTypes as cT 

33 

34from lsstDebug import getDebugFrame 

35import lsst.pex.config as pexConfig 

36 

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 

46 

47from .utils import bin_flat, FlatGradientFitter 

48from lsst.ip.isr import FlatGradient 

49 

50 

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 ) 

67 

68 outputDefects = cT.Output( 

69 name="singleExpDefects", 

70 doc="Output measured defects.", 

71 storageClass="Defects", 

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

73 ) 

74 

75 

76class MeasureDefectsTaskConfig(pipeBase.PipelineTaskConfig, 

77 pipelineConnections=MeasureDefectsConnections): 

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

79 """ 

80 

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

157 dtype=int, 

158 doc="Number of pixels to exclude from up & down of image when looking for defects in ITL.", 

159 default=0, 

160 ) 

161 nPixBorderLeftRightITL = pexConfig.Field( 

162 dtype=int, 

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

164 default=0, 

165 ) 

166 nPixBorderUpDownE2V = pexConfig.Field( 

167 dtype=int, 

168 doc="Number of pixels to exclude from up & down of image when looking for defects in E2V.", 

169 default=0, 

170 ) 

171 nPixBorderLeftRightE2V = pexConfig.Field( 

172 dtype=int, 

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

174 default=0, 

175 ) 

176 badOnAndOffPixelColumnThreshold = pexConfig.Field( 

177 dtype=int, 

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

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

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

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

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

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

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

185 default=50, 

186 ) 

187 goodPixelColumnGapThreshold = pexConfig.Field( 

188 dtype=int, 

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

190 "'badOnAndOffPixelColumnThreshold')."), 

191 default=30, 

192 ) 

193 badPixelsToFillColumnThreshold = pexConfig.Field( 

194 dtype=float, 

195 doc=("If the number of bad pixels in an amplifier column is above this threshold " 

196 "then the full amplifier column will be marked bad. This operation is performed after " 

197 "any merging of blinking columns performed with badOnAndOffPixelColumnThreshold. If this" 

198 "value is less than 0 then no bad column filling will be performed."), 

199 default=-1, 

200 ) 

201 saturatedColumnMask = pexConfig.Field( 

202 dtype=str, 

203 default="SAT", 

204 doc="Saturated mask plane for dilation.", 

205 ) 

206 saturatedColumnDilationRadius = pexConfig.Field( 

207 dtype=int, 

208 doc=("Dilation radius (along rows) to use to expand saturated columns " 

209 "to mitigate glow."), 

210 default=0, 

211 ) 

212 saturatedPixelsToFillColumnThreshold = pexConfig.Field( 

213 dtype=int, 

214 doc=("If the number of saturated pixels in an amplifier column is above this threshold " 

215 "then the full amplifier column will be marked bad. If this value is less than 0" 

216 "then no saturated column filling will be performed."), 

217 default=-1, 

218 ) 

219 e2vMidlineBreakNRow = pexConfig.Field( 

220 dtype=int, 

221 doc="E2V midline break number of midline break rows at the bottom of the top amps " 

222 "or the top of the bottom amps to always mask or ignore. Number of rows will " 

223 "be twice this config value. Only used if detector is E2V type. Set to <=0 " 

224 "to treat E2V midline break rows like any other flat-field pixel.", 

225 default=1, 

226 ) 

227 e2vMidlineBreakOption = pexConfig.ChoiceField( 

228 dtype=str, 

229 doc="How should the E2V midline break be treated? Only used if e2vMidlineBreakNRow > 0.", 

230 default="NEVERMASK", 

231 allowed={ 

232 "NEVERMASK": "Never mask the E2V midline break, no matter the flat values.", 

233 "MASK": "Always mask the E2V midline break.", 

234 }, 

235 ) 

236 ampGradientBinFactor = pexConfig.Field( 

237 dtype=int, 

238 doc="Binning factor used for fitting per-amp focal plane gradient.", 

239 default=8, 

240 ) 

241 ampGradientBoundarySize = pexConfig.Field( 

242 dtype=int, 

243 doc="Amp boundary exclusion size for fitting per-amp focal plane gradient.", 

244 default=20, 

245 ) 

246 ampGradientNodes = pexConfig.Field( 

247 dtype=int, 

248 doc="Number of spline nodes for per-amp focal plane gradient.", 

249 default=4, 

250 ) 

251 

252 def validate(self): 

253 super().validate() 

254 if self.nSigmaBright < 0.0: 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true

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

256 if self.nSigmaDark > 0.0: 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true

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

258 

259 

260class MeasureDefectsTask(pipeBase.PipelineTask): 

261 """Measure the defects from one exposure. 

262 """ 

263 

264 ConfigClass = MeasureDefectsTaskConfig 

265 _DefaultName = 'cpDefectMeasure' 

266 

267 def run(self, inputExp, camera): 

268 """Measure one exposure for defects. 

269 

270 Parameters 

271 ---------- 

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

273 Exposure to examine. 

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

275 Camera to use for metadata. 

276 

277 Returns 

278 ------- 

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

280 Results struct containing: 

281 

282 ``outputDefects`` 

283 The defects measured from this exposure 

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

285 """ 

286 detector = inputExp.getDetector() 

287 try: 

288 filterName = inputExp.getFilter().physicalLabel 

289 except AttributeError: 

290 filterName = None 

291 

292 defects = self._findHotAndColdPixels(inputExp) 

293 

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

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

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

297 

298 defects.updateMetadataFromExposures([inputExp]) 

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

300 setCalibId=True, setDate=True, 

301 cpDefectGenImageType=datasetType) 

302 

303 return pipeBase.Struct( 

304 outputDefects=defects, 

305 ) 

306 

307 @staticmethod 

308 def _nPixFromDefects(defects): 

309 """Count pixels in a defect. 

310 

311 Parameters 

312 ---------- 

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

314 Defects to measure. 

315 

316 Returns 

317 ------- 

318 nPix : `int` 

319 Number of defect pixels. 

320 """ 

321 nPix = 0 

322 for defect in defects: 

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

324 return nPix 

325 

326 def getVampirePixels(self, ampImg): 

327 """Find vampire pixels (bright pixels in flats) and get footprint of 

328 extended area around them, 

329 

330 Parameters 

331 ---------- 

332 ampImg : `lsst.afw.image._maskedImage.MaskedImageF` 

333 The amplifier masked image to do the vampire pixels search on. 

334 

335 Returns 

336 ------- 

337 fs_grow : `lsst.afw.detection._detection.FootprintSet` 

338 The footprint set of areas around vampire pixels in the amplifier. 

339 """ 

340 

341 # Find bright pixels 

342 thresh = afwDetection.Threshold(self.config.thresholdVampirePixels) 

343 # Bright pixels footprint grown by a radius of radiusVampire pixels 

344 fs = afwDetection.FootprintSet(ampImg, thresh) 

345 fs_grow = afwDetection.FootprintSet(fs, rGrow=self.config.radiusVampirePixels, isotropic=True) 

346 

347 return fs_grow 

348 

349 def _setE2VMidline(self, amp, ampImg): 

350 """Set the E2V midline pixels on this amplifier. 

351 

352 If the task is configure to always mask it will set the midline rows to 

353 an illegal value; if it is to never mask it will set the midline rows 

354 to the median value. 

355 

356 Parameters 

357 ---------- 

358 amp : `lsst.afw.cameraGeom.Amplifier` 

359 Amplifier object. 

360 ampImg : `lsst.afw.image.ImageF` 

361 Image data for the amplifier. 

362 """ 

363 if self.config.e2vMidlineBreakNRow <= 0: 

364 return 

365 

366 if self.config.e2vMidlineBreakOption == "NEVERMASK": 

367 # Setting the midline values to the median value ensures 

368 # they will never trigger the defect finding threshold. 

369 value = np.nanmedian(ampImg.image.array) 

370 else: 

371 # Setting the midline values to a large negative value 

372 # ensures they will always trigger the defect finding 

373 # threshold. 

374 value = -1e30 

375 

376 if amp.getName().startswith("C0"): 

377 # This is a bottom amplifier, so change the top row(s). 

378 ampImg.image.array[-self.config.e2vMidlineBreakNRow:, :] = value 

379 else: 

380 # This is a top amplifier, so change the bottom row(s). 

381 ampImg.image.array[:self.config.e2vMidlineBreakNRow, :] = value 

382 

383 def _findHotAndColdPixels(self, exp): 

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

385 

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

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

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

389 cold pixels). 

390 

391 Parameters 

392 ---------- 

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

394 The exposure in which to find defects. 

395 

396 Returns 

397 ------- 

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

399 The defects found in the image. 

400 """ 

401 

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

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

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

405 footprintList = [] 

406 

407 hotPixelCount = {} 

408 coldPixelCount = {} 

409 

410 detector = exp.getDetector() 

411 detectorType = detector.getPhysicalType() 

412 

413 self._setEdgeBits(exp, detectorType=detectorType) 

414 

415 if self.config.fitAmpGradient: 

416 # 1. Bin flat. 

417 binnedExp = bin_flat( 

418 PhotonTransferCurveDataset(), 

419 exp, 

420 bin_factor=self.config.ampGradientBinFactor, 

421 amp_boundary=self.config.ampGradientBoundarySize, 

422 apply_gains=False, 

423 ) 

424 

425 # 2. Prepare transformations. 

426 transform = detector.getTransform( 

427 cameraGeom.PIXELS, 

428 cameraGeom.FOCAL_PLANE, 

429 ) 

430 

431 # 2a. Do transformation for binned coordinates. 

432 binnedXy = np.vstack((binnedExp["xd"], binnedExp["yd"])) 

433 binnedXf, binnedYf = np.vsplit( 

434 transform.getMapping().applyForward(binnedXy.astype(np.float64)), 

435 2, 

436 ) 

437 binnedXf = binnedXf.ravel() 

438 binnedYf = binnedYf.ravel() 

439 binnedRadius = np.sqrt(binnedXf**2. + binnedYf**2.) 

440 

441 # 2b. Do transformation for full detector coordinates. 

442 xx = np.arange(exp.image.array.shape[1], dtype=np.int64) 

443 yy = np.arange(exp.image.array.shape[0], dtype=np.int64) 

444 x, y = np.meshgrid(xx, yy) 

445 x = x.ravel() 

446 y = y.ravel() 

447 

448 xy = np.vstack((x, y)) 

449 xf, yf = np.vsplit(transform.getMapping().applyForward(xy.astype(np.float64)), 2) 

450 xf = xf.ravel() 

451 yf = yf.ravel() 

452 

453 # Measure the focal plane gradient on the binned image and remove 

454 # on the full image, amp by amp. 

455 for i, amp in enumerate(detector): 

456 # Fit the focal plane radial gradient in the binned image. 

457 binnedInAmp = (binnedExp["amp_index"] == i) 

458 

459 nodes = np.linspace( 

460 np.min(binnedRadius[binnedInAmp]), 

461 np.max(binnedRadius[binnedInAmp]), 

462 self.config.ampGradientNodes, 

463 ) 

464 

465 norm = np.nanpercentile(binnedExp["value"][binnedInAmp], 95.0) 

466 

467 fitter = FlatGradientFitter( 

468 nodes, 

469 binnedXf[binnedInAmp], 

470 binnedYf[binnedInAmp], 

471 binnedExp["value"][binnedInAmp]/norm, 

472 np.array([]), 

473 constrain_zero=False, 

474 ) 

475 p0 = fitter.compute_p0() 

476 pars = fitter.fit(p0) 

477 

478 gradient = FlatGradient() 

479 gradient.setParameters( 

480 radialSplineNodes=nodes, 

481 radialSplineValues=pars[fitter.indices["spline"]], 

482 ) 

483 

484 # Apply the focal plane gradient to the full image. 

485 pixelsInAmp = amp.getBBox().contains(x, y) 

486 

487 model = gradient.computeRadialSplineModelXY(xf[pixelsInAmp], yf[pixelsInAmp]) 

488 

489 exp.image.array[y[pixelsInAmp], x[pixelsInAmp]] /= model 

490 

491 maskedIm = exp.maskedImage 

492 

493 for amp in exp.getDetector(): 

494 ampName = amp.getName() 

495 

496 hotPixelCount[ampName] = 0 

497 coldPixelCount[ampName] = 0 

498 

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

500 

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

502 # and depending on detector type to mask picture frame effect 

503 if detectorType == 'E2V': 

504 nPixBorderLeftRight = self.config.nPixBorderLeftRightE2V 

505 elif 'ITL' in detectorType: 

506 nPixBorderLeftRight = self.config.nPixBorderLeftRightITL 

507 else: 

508 nPixBorderLeftRight = self.config.nPixBorderLeftRight 

509 

510 if detectorType == 'E2V': 

511 nPixBorderUpDown = self.config.nPixBorderUpDownE2V 

512 elif 'ITL' in detectorType: 

513 nPixBorderUpDown = self.config.nPixBorderUpDownITL 

514 else: 

515 nPixBorderUpDown = self.config.nPixBorderUpDown 

516 

517 if nPixBorderLeftRight: 

518 if ampImg.getBBox().getMinX() == 0: 

519 ampImg = ampImg[nPixBorderLeftRight:, :, afwImage.LOCAL] 

520 elif ampImg.getBBox().getMaxX() == exp.getBBox().getMaxX(): 520 ↛ 523line 520 didn't jump to line 523 because the condition on line 520 was always true

521 ampImg = ampImg[:-nPixBorderLeftRight, :, afwImage.LOCAL] 

522 

523 if nPixBorderUpDown: 

524 if ampImg.getBBox().getMinY() == 0: 

525 ampImg = ampImg[:, nPixBorderUpDown:, afwImage.LOCAL] 

526 elif ampImg.getBBox().getMaxY() == exp.getBBox().getMaxY(): 

527 ampImg = ampImg[:, :-nPixBorderUpDown, afwImage.LOCAL] 

528 

529 if self._getNumGoodPixels(ampImg) == 0: # amp contains no usable pixels 529 ↛ 530line 529 didn't jump to line 530 because the condition on line 529 was never true

530 continue 

531 

532 if self.config.doVampirePixels: 

533 # This is only applied in LSSTComCam flatBootstrap pipeline 

534 footprintSet_VampirePixel = self.getVampirePixels(ampImg) 

535 footprintSet_VampirePixel.setMask(maskedIm.mask, ("BAD")) 

536 

537 # Remove a background estimate 

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

539 ampImg -= meanClip 

540 

541 # Determine thresholds 

542 stDev = afwMath.makeStatistics(ampImg, afwMath.STDEVCLIP, ).getValue() 

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

544 datasetType = exp.getMetadata().get('IMGTYPE', 'UNKNOWN') 

545 if np.isnan(expTime): 545 ↛ 546line 545 didn't jump to line 546 because the condition on line 545 was never true

546 self.log.warning("expTime=%s for AMP %s in %s. Setting expTime to 1 second", 

547 expTime, ampName, datasetType) 

548 expTime = 1. 

549 thresholdType = self.config.thresholdType 

550 if thresholdType == 'VALUE': 

551 # LCA-128 and eoTest: bright/hot pixels in dark images are 

552 # defined as any pixel with more than 5 e-/s of dark current. 

553 # We scale by the exposure time. 

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

555 # hot pixel threshold 

556 valueThreshold = self.config.darkCurrentThreshold*expTime/amp.getGain() 

557 elif datasetType.lower() == 'bias': 557 ↛ 559line 557 didn't jump to line 559 because the condition on line 557 was never true

558 # hot pixel threshold, no exposure time. 

559 valueThreshold = self.config.biasThreshold 

560 else: 

561 # LCA-128 and eoTest: dark/cold pixels in flat images as 

562 # defined as any pixel with photoresponse <80% of 

563 # the mean (at 500nm). 

564 

565 # We subtracted the mean above, so the threshold will be 

566 # negative cold pixel threshold. 

567 valueThreshold = (self.config.fracThresholdFlat-1)*meanClip 

568 # Find equivalent sigma values. 

569 if stDev == 0.0: 

570 self.log.warning("stDev=%s for AMP %s in %s. Setting nSigma to inf.", 

571 stDev, ampName, datasetType) 

572 nSigmaList = [np.inf] 

573 else: 

574 nSigmaList = [valueThreshold/stDev] 

575 else: 

576 hotPixelThreshold = self.config.nSigmaBright 

577 coldPixelThreshold = self.config.nSigmaDark 

578 if datasetType.lower() == 'dark': 578 ↛ 579line 578 didn't jump to line 579 because the condition on line 578 was never true

579 nSigmaList = [hotPixelThreshold] 

580 valueThreshold = stDev*hotPixelThreshold 

581 elif datasetType.lower() == 'bias': 581 ↛ 582line 581 didn't jump to line 582 because the condition on line 581 was never true

582 self.log.warning( 

583 "Bias frame detected, but thresholdType == STDEV; not looking for defects.", 

584 ) 

585 return Defects.fromFootprintList([]) 

586 else: 

587 nSigmaList = [hotPixelThreshold, coldPixelThreshold] 

588 valueThreshold = [x*stDev for x in nSigmaList] 

589 

590 self.log.info("Image type: %s. Amp: %s. Threshold Type: %s. Sigma values and Pixel" 

591 "Values (hot and cold pixels thresholds): %s, %s", 

592 datasetType, ampName, thresholdType, nSigmaList, valueThreshold) 

593 

594 if datasetType.lower() == "flat" and exp.getDetector().getPhysicalType() == "E2V": 

595 self._setE2VMidline(amp, ampImg) 

596 

597 mergedSet = None 

598 for sigma in nSigmaList: 

599 nSig = np.abs(sigma) 

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

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

602 

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

604 

605 try: 

606 footprintSet = afwDetection.FootprintSet(ampImg, threshold) 

607 except InvalidParameterError: 

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

609 # Let's mask the whole area. 

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

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

612 footprintSet = afwDetection.FootprintSet(ampImg, threshold) 

613 

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

615 

616 if mergedSet is None: 

617 mergedSet = footprintSet 

618 else: 

619 mergedSet.merge(footprintSet) 

620 

621 if polarity: 

622 # hot pixels 

623 for fp in footprintSet.getFootprints(): 

624 hotPixelCount[ampName] += fp.getArea() 

625 else: 

626 # cold pixels 

627 for fp in footprintSet.getFootprints(): 

628 coldPixelCount[ampName] += fp.getArea() 

629 

630 if self.config.doVampirePixels: 

631 # Count the number of pixels masked 

632 vampirePixelCount = 0 

633 for fp in footprintSet_VampirePixel.getFootprints(): 

634 vampirePixelCount += fp.getArea() 

635 self.log.info("%s Vampire pixels are masked", vampirePixelCount) 

636 # Add vampire pixels to footprint set 

637 mergedSet.merge(footprintSet_VampirePixel) 

638 

639 footprintList += mergedSet.getFootprints() 

640 

641 self.debugView('defectMap', ampImg, 

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

643 

644 defects = Defects.fromFootprintList(footprintList) 

645 defects = self.dilateSaturatedColumns(exp, defects) 

646 defects, _ = self.maskBlocksIfIntermitentBadPixelsInColumn(defects) 

647 defects, count = self.maskBadColumns(exp, defects) 

648 # We want this to reflect the number of completely bad columns. 

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

650 

651 return defects 

652 

653 @staticmethod 

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

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

656 nPixels = maskedIm.mask.array.size 

657 nBad = countMaskedPixels(maskedIm, badMaskString) 

658 return nPixels - nBad 

659 

660 def _setEdgeBits(self, exposureOrMaskedImage, maskplaneToSet='EDGE', detectorType='CCD'): 

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

662 

663 Parameters 

664 ---------- 

665 exposureOrMaskedImage : `lsst.afw.image.exposure.Exposure` 

666 or `lsst.afw.image.MaskedImage` 

667 The exposure or masked image in which to find defects. 

668 maskplaneToSet : `str` 

669 Name of mask plane the edges are set to. 

670 detectorType : `str` 

671 Name of the detector type. 

672 

673 Raises 

674 ------ 

675 TypeError 

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

677 """ 

678 if isinstance(exposureOrMaskedImage, afwImage.Exposure): 

679 mi = exposureOrMaskedImage.maskedImage 

680 elif isinstance(exposureOrMaskedImage, afwImage.MaskedImage): 680 ↛ 683line 680 didn't jump to line 683 because the condition on line 680 was always true

681 mi = exposureOrMaskedImage 

682 else: 

683 t = type(exposureOrMaskedImage) 

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

685 

686 MASKBIT = mi.mask.getPlaneBitMask(maskplaneToSet) 

687 

688 if detectorType == 'E2V': 

689 nPixBorderLeftRight = self.config.nPixBorderLeftRightE2V 

690 elif 'ITL' in detectorType: 

691 nPixBorderLeftRight = self.config.nPixBorderLeftRightITL 

692 else: 

693 nPixBorderLeftRight = self.config.nPixBorderLeftRight 

694 

695 if detectorType == 'E2V': 

696 nPixBorderUpDown = self.config.nPixBorderUpDownE2V 

697 elif 'ITL' in detectorType: 

698 nPixBorderUpDown = self.config.nPixBorderUpDownITL 

699 else: 

700 nPixBorderUpDown = self.config.nPixBorderUpDown 

701 

702 if nPixBorderLeftRight: 

703 mi.mask[: nPixBorderLeftRight, :, afwImage.LOCAL] |= MASKBIT 

704 mi.mask[-nPixBorderLeftRight:, :, afwImage.LOCAL] |= MASKBIT 

705 if nPixBorderUpDown: 

706 mi.mask[:, : nPixBorderUpDown, afwImage.LOCAL] |= MASKBIT 

707 mi.mask[:, -nPixBorderUpDown:, afwImage.LOCAL] |= MASKBIT 

708 

709 def maskBlocksIfIntermitentBadPixelsInColumn(self, defects): 

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

711 

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

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

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

715 

716 Parameters 

717 ---------- 

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

719 The defects found in the image so far 

720 

721 Returns 

722 ------- 

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

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

725 equal than self.config.badPixelColumnThreshold, the input 

726 list is returned. Otherwise, the defects list returned 

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

728 badColumnCount : `int` 

729 Number of bad columns partially masked. 

730 """ 

731 badColumnCount = 0 

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

733 coordinates = [] 

734 for defect in defects: 

735 bbox = defect.getBBox() 

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

737 deltaX0, deltaY0 = bbox.getDimensions() 

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

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

740 coordinates.append((i, j)) 

741 

742 x, y = [], [] 

743 for coordinatePair in coordinates: 

744 x.append(coordinatePair[0]) 

745 y.append(coordinatePair[1]) 

746 

747 x = np.array(x) 

748 y = np.array(y) 

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

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

751 multipleX = [] 

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

753 if b >= self.config.badOnAndOffPixelColumnThreshold: 

754 multipleX.append(a) 

755 if len(multipleX) != 0: 

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

757 badColumnCount += 1 

758 

759 return defects, badColumnCount 

760 

761 def dilateSaturatedColumns(self, exp, defects): 

762 """Dilate saturated columns by a configurable amount. 

763 

764 Parameters 

765 ---------- 

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

767 The exposure in which to find defects. 

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

769 The defects found in the image so far 

770 

771 Returns 

772 ------- 

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

774 The expanded defects. 

775 """ 

776 if self.config.saturatedColumnDilationRadius <= 0: 

777 # This is a no-op. 

778 return defects 

779 

780 mask = afwImage.Mask.getPlaneBitMask(self.config.saturatedColumnMask) 

781 

782 satY, satX = np.where((exp.mask.array & mask) > 0) 

783 

784 if len(satX) == 0: 

785 # No saturated pixels, nothing to do. 

786 return defects 

787 

788 radius = self.config.saturatedColumnDilationRadius 

789 

790 with defects.bulk_update(): 

791 for index in range(len(satX)): 

792 minX = np.clip(satX[index] - radius, 0, None) 

793 maxX = np.clip(satX[index] + radius, None, exp.image.array.shape[1] - 1) 

794 s = Box2I(minimum=Point2I(minX, satY[index]), 

795 maximum=Point2I(maxX, satY[index])) 

796 defects.append(s) 

797 

798 return defects 

799 

800 def maskBadColumns(self, exp, defects): 

801 """Mask full amplifier columns if they are sufficiently bad. 

802 

803 Parameters 

804 ---------- 

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

806 The defects found in the image so far 

807 

808 Returns 

809 ------- 

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

811 The exposure in which to find defects. 

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

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

814 equal than self.config.badPixelColumnThreshold, the input 

815 list is returned. Otherwise, the defects list returned 

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

817 badColumnCount : `int` 

818 Number of bad columns masked. 

819 """ 

820 # Render the defects into an image. 

821 defectImage = afwImage.ImageI(exp.getBBox()) 

822 

823 for defect in defects: 

824 defectImage[defect.getBBox()] = 1 

825 

826 badColumnCount = 0 

827 

828 if self.config.badPixelsToFillColumnThreshold > 0: 

829 with defects.bulk_update(): 

830 for amp in exp.getDetector(): 

831 subImage = defectImage[amp.getBBox()].array 

832 nInCol = np.sum(subImage, axis=0) 

833 

834 badColIndices, = (nInCol >= self.config.badPixelsToFillColumnThreshold).nonzero() 

835 badColumns = badColIndices + amp.getBBox().getMinX() 

836 

837 for badColumn in badColumns: 

838 s = Box2I(minimum=Point2I(badColumn, amp.getBBox().getMinY()), 

839 maximum=Point2I(badColumn, amp.getBBox().getMaxY())) 

840 defects.append(s) 

841 

842 badColumnCount += len(badColIndices) 

843 

844 if self.config.saturatedPixelsToFillColumnThreshold > 0: 

845 mask = afwImage.Mask.getPlaneBitMask(self.config.saturatedColumnMask) 

846 

847 with defects.bulk_update(): 

848 for amp in exp.getDetector(): 

849 subMask = exp.mask[amp.getBBox()].array 

850 # Turn all the SAT bits into 1s 

851 subMask &= mask 

852 subMask[subMask > 0] = 1 

853 

854 nInCol = np.sum(subMask, axis=0) 

855 

856 badColIndices, = (nInCol >= self.config.saturatedPixelsToFillColumnThreshold).nonzero() 

857 badColumns = badColIndices + amp.getBBox().getMinX() 

858 

859 for badColumn in badColumns: 

860 s = Box2I(minimum=Point2I(badColumn, amp.getBBox().getMinY()), 

861 maximum=Point2I(badColumn, amp.getBBox().getMaxY())) 

862 defects.append(s) 

863 

864 badColumnCount += len(badColIndices) 

865 

866 return defects, badColumnCount 

867 

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

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

870 threshold. 

871 

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

873 in a column is larger or equal than 

874 self.config.badOnAndOffPixelColumnThreshold. 

875 

876 Parameters 

877 --------- 

878 x : `list` 

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

880 along the short axis if amp. 

881 y : `list` 

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

883 along the long axis if amp. 

884 multipleX : list 

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

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

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

888 The defcts found in the image so far 

889 

890 Returns 

891 ------- 

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

893 The defects list returned that will include boxes that 

894 mask blocks of on-and-of pixels. 

895 """ 

896 with defects.bulk_update(): 

897 goodPixelColumnGapThreshold = self.config.goodPixelColumnGapThreshold 

898 for x0 in multipleX: 

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

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

901 multipleY.sort() # Ensure that the y values are sorted to look for gaps. 

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

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

904 # of good pixels between two consecutive bad pixels is 

905 # larger or equal than 'goodPixelColumnGapThreshold'. 

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

907 if len(diffIndex) != 0: 

908 limits = [minY] # put the minimum first 

909 for gapIndex in diffIndex: 

910 limits.append(multipleY[gapIndex]) 

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

912 limits.append(maxY) # maximum last 

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

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

915 defects.append(s) 

916 else: # No gap is large enough 

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

918 defects.append(s) 

919 return defects 

920 

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

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

923 

924 Parameters 

925 ---------- 

926 stepname : `str` 

927 Debug frame to request. 

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

929 Amplifier image to display. 

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

931 The defects to plot. 

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

933 Detector holding camera geometry. 

934 """ 

935 frame = getDebugFrame(self._display, stepname) 

936 if frame: 

937 disp = afwDisplay.Display(frame=frame) 

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

939 disp.setMaskTransparency(80) 

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

941 

942 maskedIm = ampImage.clone() 

943 defects.maskPixels(maskedIm, "BAD") 

944 

945 mpDict = maskedIm.mask.getMaskPlaneDict() 

946 for plane in mpDict.keys(): 

947 if plane in ['BAD']: 

948 continue 

949 disp.setMaskPlaneColor(plane, afwDisplay.IGNORE) 

950 

951 disp.setImageColormap('gray') 

952 disp.mtv(maskedIm) 

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

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

955 while True: 

956 ans = input(prompt).lower() 

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

958 break 

959 

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

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

962 each amp. 

963 

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

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

966 do not contribute to the underflow and overflow numbers. 

967 

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

969 detectors. 

970 

971 Parameters 

972 ---------- 

973 stepname : `str` 

974 Debug frame to request. 

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

976 Amplifier image to display. 

977 nSigmaUsed : `float` 

978 The number of sigma used for detection 

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

980 The exposure in which the defects were found. 

981 """ 

982 frame = getDebugFrame(self._display, stepname) 

983 if frame: 983 ↛ 984line 983 didn't jump to line 984 because the condition on line 983 was never true

984 import matplotlib.pyplot as plt 

985 

986 detector = exp.getDetector() 

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

988 nY = len(detector) // nX 

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

990 

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

992 

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

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

995 

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

997 # always work with master calibs 

998 mi.image.array /= expTime 

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

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

1001 # Get array of pixels 

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

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

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

1005 

1006 thrUpper = mean + nSigmaUsed*sigma 

1007 thrLower = mean - nSigmaUsed*sigma 

1008 

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

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

1011 

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

1013 leftEdge = mean - nsig * nSigmaUsed*sigma 

1014 rightEdge = mean + nsig * nSigmaUsed*sigma 

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

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

1017 lw=1, edgecolor='red') 

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

1019 lw=3, edgecolor='blue') 

1020 

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

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

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

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

1025 

1026 # Put v-lines and textboxes in 

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

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

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

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

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

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

1033 

1034 # set axis limits and scales 

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

1036 lPlot, rPlot = a.get_xlim() 

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

1038 a.set_yscale('log') 

1039 a.set_xlabel("ADU/s") 

1040 fig.show() 

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

1042 while True: 

1043 ans = input(prompt).lower() 

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

1045 break 

1046 elif ans in ("p", ): 

1047 import pdb 

1048 pdb.set_trace() 

1049 elif ans in ("h", ): 

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

1051 plt.close() 

1052 

1053 

1054class MeasureDefectsCombinedConnections(pipeBase.PipelineTaskConnections, 

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

1056 inputExp = cT.Input( 

1057 name="dark", 

1058 doc="Input ISR-processed combined exposure to measure.", 

1059 storageClass="ExposureF", 

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

1061 multiple=False, 

1062 isCalibration=True, 

1063 ) 

1064 camera = cT.PrerequisiteInput( 

1065 name='camera', 

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

1067 storageClass="Camera", 

1068 dimensions=("instrument", ), 

1069 isCalibration=True, 

1070 ) 

1071 

1072 outputDefects = cT.Output( 

1073 name="cpDefectsFromDark", 

1074 doc="Output measured defects.", 

1075 storageClass="Defects", 

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

1077 ) 

1078 

1079 

1080class MeasureDefectsCombinedTaskConfig(MeasureDefectsTaskConfig, 

1081 pipelineConnections=MeasureDefectsCombinedConnections): 

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

1083 """ 

1084 pass 

1085 

1086 

1087class MeasureDefectsCombinedTask(MeasureDefectsTask): 

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

1089 

1090 ConfigClass = MeasureDefectsCombinedTaskConfig 

1091 _DefaultName = "cpDefectMeasureCombined" 

1092 

1093 

1094class MeasureDefectsCombinedWithFilterConnections(pipeBase.PipelineTaskConnections, 

1095 dimensions=("instrument", "detector", "physical_filter")): 

1096 """Task to measure defects in combined flats under a certain filter.""" 

1097 inputExp = cT.Input( 

1098 name="flat", 

1099 doc="Input ISR-processed combined exposure to measure.", 

1100 storageClass="ExposureF", 

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

1102 multiple=False, 

1103 isCalibration=True, 

1104 ) 

1105 camera = cT.PrerequisiteInput( 

1106 name='camera', 

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

1108 storageClass="Camera", 

1109 dimensions=("instrument", ), 

1110 isCalibration=True, 

1111 ) 

1112 

1113 outputDefects = cT.Output( 

1114 name="cpDefectsFromFlat", 

1115 doc="Output measured defects.", 

1116 storageClass="Defects", 

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

1118 ) 

1119 

1120 

1121class MeasureDefectsCombinedWithFilterTaskConfig( 

1122 MeasureDefectsTaskConfig, 

1123 pipelineConnections=MeasureDefectsCombinedWithFilterConnections): 

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

1125 """ 

1126 pass 

1127 

1128 

1129class MeasureDefectsCombinedWithFilterTask(MeasureDefectsTask): 

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

1131 

1132 ConfigClass = MeasureDefectsCombinedWithFilterTaskConfig 

1133 _DefaultName = "cpDefectMeasureWithFilterCombined" 

1134 

1135 

1136class MergeDefectsConnections(pipeBase.PipelineTaskConnections, 

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

1138 inputDefects = cT.Input( 

1139 name="singleExpDefects", 

1140 doc="Measured defect lists.", 

1141 storageClass="Defects", 

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

1143 multiple=True, 

1144 ) 

1145 camera = cT.PrerequisiteInput( 

1146 name='camera', 

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

1148 storageClass="Camera", 

1149 dimensions=("instrument", ), 

1150 isCalibration=True, 

1151 ) 

1152 

1153 mergedDefects = cT.Output( 

1154 name="defects", 

1155 doc="Final merged defects.", 

1156 storageClass="Defects", 

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

1158 multiple=False, 

1159 isCalibration=True, 

1160 ) 

1161 

1162 

1163class MergeDefectsTaskConfig(pipeBase.PipelineTaskConfig, 

1164 pipelineConnections=MergeDefectsConnections): 

1165 """Configuration for merging single exposure defects. 

1166 """ 

1167 

1168 assertSameRun = pexConfig.Field( 

1169 dtype=bool, 

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

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

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

1173 ) 

1174 ignoreFilters = pexConfig.Field( 

1175 dtype=bool, 

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

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

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

1179 " defects with respect to filter."), 

1180 default=True, 

1181 ) 

1182 nullFilterName = pexConfig.Field( 

1183 dtype=str, 

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

1185 default="NONE", 

1186 ) 

1187 combinationMode = pexConfig.ChoiceField( 

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

1189 dtype=str, 

1190 default="FRACTION", 

1191 allowed={ 

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

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

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

1195 } 

1196 ) 

1197 combinationFraction = pexConfig.RangeField( 

1198 dtype=float, 

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

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

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

1202 default=0.7, 

1203 min=0, 

1204 max=1, 

1205 ) 

1206 nPixBorderUpDown = pexConfig.Field( 

1207 dtype=int, 

1208 doc=("Width (in pixels) of CCD top and bottom edges set as defects" 

1209 "if edgesAsDefects is True."), 

1210 default=5, 

1211 ) 

1212 nPixBorderLeftRight = pexConfig.Field( 

1213 dtype=int, 

1214 doc=("Width (in pixels) of CCD left and right edges set as defects" 

1215 "if edgesAsDefects is True."), 

1216 default=5, 

1217 ) 

1218 nPixBorderUpDownITL = pexConfig.Field( 

1219 dtype=int, 

1220 doc=("Width (in pixels) of ITL CCD top and bottom edges set as defects" 

1221 "if edgesAsDefects is True."), 

1222 default=5, 

1223 ) 

1224 nPixBorderLeftRightITL = pexConfig.Field( 

1225 dtype=int, 

1226 doc=("Width (in pixels) of ITL CCD left and right edges set as defects" 

1227 "if edgesAsDefects is True."), 

1228 default=5, 

1229 ) 

1230 nPixBorderUpDownE2V = pexConfig.Field( 

1231 dtype=int, 

1232 doc=("Width (in pixels) of E2V CCD top and bottom edges set as defects" 

1233 "if edgesAsDefects is True."), 

1234 default=5, 

1235 ) 

1236 nPixBorderLeftRightE2V = pexConfig.Field( 

1237 dtype=int, 

1238 doc=("Width (in pixels) of E2V CCD left and right edges set as defects" 

1239 "if edgesAsDefects is True."), 

1240 default=5, 

1241 ) 

1242 edgesAsDefects = pexConfig.Field( 

1243 dtype=bool, 

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

1245 default=False, 

1246 ) 

1247 

1248 

1249class MergeDefectsTask(pipeBase.PipelineTask): 

1250 """Merge the defects from multiple exposures. 

1251 """ 

1252 

1253 ConfigClass = MergeDefectsTaskConfig 

1254 _DefaultName = 'cpDefectMerge' 

1255 

1256 def run(self, inputDefects, camera): 

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

1258 

1259 Parameters 

1260 ---------- 

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

1262 Partial defects from a single exposure. 

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

1264 Camera to use for metadata. 

1265 

1266 Returns 

1267 ------- 

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

1269 Results struct containing: 

1270 

1271 ``mergedDefects`` 

1272 The defects merged from the input lists 

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

1274 """ 

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

1276 if detectorId is None: 

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

1278 detector = camera[detectorId] 

1279 

1280 imageTypes = set() 

1281 for inDefect in inputDefects: 

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

1283 imageTypes.add(imageType) 

1284 

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

1286 splitDefects = list() 

1287 for imageType in imageTypes: 

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

1289 count = 0 

1290 for inDefect in inputDefects: 

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

1292 count += 1 

1293 for defect in inDefect: 

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

1295 sumImage /= count 

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

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

1298 

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

1300 threshold = 1.0 

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

1302 threshold = 0.0 

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

1304 threshold = self.config.combinationFraction 

1305 else: 

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

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

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

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

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

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

1312 splitDefects.append(partialDefect) 

1313 

1314 # Do final combination of separate image types 

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

1316 for inDefect in splitDefects: 

1317 for defect in inDefect: 

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

1319 finalImage /= len(splitDefects) 

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

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

1322 

1323 # This combination is the OR of all image types 

1324 threshold = 0.0 

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

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

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

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

1329 

1330 if self.config.edgesAsDefects: 

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

1332 detectorType = detector.getPhysicalType() 

1333 if detectorType == 'E2V': 

1334 nPixBorderLeftRight = self.config.nPixBorderLeftRightE2V 

1335 elif 'ITL' in detectorType: 

1336 nPixBorderLeftRight = self.config.nPixBorderLeftRightITL 

1337 else: 

1338 nPixBorderLeftRight = self.config.nPixBorderLeftRight 

1339 

1340 if detectorType == 'E2V': 

1341 nPixBorderUpDown = self.config.nPixBorderUpDownE2V 

1342 elif 'ITL' in detectorType: 

1343 nPixBorderUpDown = self.config.nPixBorderUpDownITL 

1344 else: 

1345 nPixBorderUpDown = self.config.nPixBorderUpDown 

1346 

1347 # This code follows the pattern from isrTask.maskEdges(). 

1348 if nPixBorderLeftRight > 0: 

1349 box = detector.getBBox() 

1350 subImage = finalImage[box] 

1351 box.grow(Extent2I(-nPixBorderLeftRight, 0)) 

1352 SourceDetectionTask.setEdgeBits(subImage, box, BADBIT) 

1353 if nPixBorderUpDown > 0: 

1354 box = detector.getBBox() 

1355 subImage = finalImage[box] 

1356 box.grow(Extent2I(0, -nPixBorderUpDown)) 

1357 SourceDetectionTask.setEdgeBits(subImage, box, BADBIT) 

1358 

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

1360 merged.updateMetadataFromExposures(inputDefects) 

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

1362 setCalibId=True, setDate=True) 

1363 

1364 return pipeBase.Struct( 

1365 mergedDefects=merged, 

1366 ) 

1367 

1368# Subclass the MergeDefects task to reduce the input dimensions 

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

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

1371 

1372 

1373class MergeDefectsCombinedConnections(pipeBase.PipelineTaskConnections, 

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

1375 inputDarkDefects = cT.Input( 

1376 name="cpDefectsFromDark", 

1377 doc="Measured defect lists.", 

1378 storageClass="Defects", 

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

1380 multiple=True, 

1381 ) 

1382 inputBiasDefects = cT.Input( 

1383 name="cpDefectsFromBias", 

1384 doc="Additional measured defect lists.", 

1385 storageClass="Defects", 

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

1387 multiple=True, 

1388 ) 

1389 inputFlatDefects = cT.Input( 

1390 name="cpDefectsFromFlat", 

1391 doc="Additional measured defect lists.", 

1392 storageClass="Defects", 

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

1394 multiple=True, 

1395 ) 

1396 inputManualDefects = cT.Input( 

1397 name="cpManualDefects", 

1398 doc="Additional manual defects.", 

1399 storageClass="Defects", 

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

1401 multiple=True, 

1402 isCalibration=True, 

1403 ) 

1404 camera = cT.PrerequisiteInput( 

1405 name='camera', 

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

1407 storageClass="Camera", 

1408 dimensions=("instrument", ), 

1409 isCalibration=True, 

1410 ) 

1411 

1412 mergedDefects = cT.Output( 

1413 name="defects", 

1414 doc="Final merged defects.", 

1415 storageClass="Defects", 

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

1417 multiple=False, 

1418 isCalibration=True, 

1419 ) 

1420 

1421 def __init__(self, *, config=None): 

1422 super().__init__(config=config) 

1423 

1424 if config.doManualDefects is not True: 

1425 del self.inputManualDefects 

1426 

1427 

1428class MergeDefectsCombinedTaskConfig(MergeDefectsTaskConfig, 

1429 pipelineConnections=MergeDefectsCombinedConnections): 

1430 """Configuration for merging defects from combined exposure. 

1431 """ 

1432 doManualDefects = pexConfig.Field( 

1433 dtype=bool, 

1434 doc="Apply manual defects?", 

1435 default=False, 

1436 ) 

1437 

1438 def validate(self): 

1439 super().validate() 

1440 if self.combinationMode != 'OR': 1440 ↛ 1441line 1440 didn't jump to line 1441 because the condition on line 1440 was never true

1441 raise ValueError("combinationMode must be 'OR'") 

1442 

1443 

1444class MergeDefectsCombinedTask(MergeDefectsTask): 

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

1446 

1447 ConfigClass = MergeDefectsCombinedTaskConfig 

1448 _DefaultName = "cpMergeDefectsCombined" 

1449 

1450 @staticmethod 

1451 def chooseBest(inputs): 

1452 """Select the input with the most exposures used.""" 

1453 best = 0 

1454 if len(inputs) > 1: 

1455 nInput = 0 

1456 for num, exp in enumerate(inputs): 

1457 # This technically overcounts by a factor of 3. 

1458 N = len([k for k, v in exp.getMetadata().toDict().items() if "CPP_INPUT_" in k]) 

1459 if N > nInput: 

1460 best = num 

1461 nInput = N 

1462 return inputs[best] 

1463 

1464 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

1465 inputs = butlerQC.get(inputRefs) 

1466 # Turn inputFlatDefects and inputDarkDefects into a list which 

1467 # is what MergeDefectsTask expects. If there are multiple, 

1468 # use the one with the most inputs. 

1469 tempList = [self.chooseBest(inputs['inputFlatDefects']), 

1470 self.chooseBest(inputs['inputDarkDefects']), 

1471 self.chooseBest(inputs['inputBiasDefects'])] 

1472 

1473 if "inputManualDefects" in inputs.keys(): 

1474 tempList.extend(inputs["inputManualDefects"]) 

1475 

1476 # Rename inputDefects 

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

1478 

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

1480 butlerQC.put(outputs, outputRefs)