Coverage for python/lsst/ip/isr/isrFunctions.py: 87%

548 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 22:35 +0000

1# 

2# LSST Data Management System 

3# Copyright 2008, 2009, 2010 LSST Corporation. 

4# 

5# This product includes software developed by the 

6# LSST Project (http://www.lsst.org/). 

7# 

8# This program is free software: you can redistribute it and/or modify 

9# it under the terms of the GNU General Public License as published by 

10# the Free Software Foundation, either version 3 of the License, or 

11# (at your option) any later version. 

12# 

13# This program is distributed in the hope that it will be useful, 

14# but WITHOUT ANY WARRANTY; without even the implied warranty of 

15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

16# GNU General Public License for more details. 

17# 

18# You should have received a copy of the LSST License Statement and 

19# the GNU General Public License along with this program. If not, 

20# see <http://www.lsstcorp.org/LegalNotices/>. 

21# 

22 

23__all__ = [ 

24 "applyGains", 

25 "attachTransmissionCurve", 

26 "biasCorrection", 

27 "checkFilter", 

28 "compareCameraKeywords", 

29 "countMaskedPixels", 

30 "createPsf", 

31 "darkCorrection", 

32 "flatCorrection", 

33 "gainContext", 

34 "getPhysicalFilter", 

35 "growMasks", 

36 "maskDECamEdgeBleed", 

37 "maskE2VEdgeBleed", 

38 "maskITLEdgeBleed", 

39 "maskITLSatSag", 

40 "maskITLDip", 

41 "illuminationCorrection", 

42 "interpolateDefectList", 

43 "interpolateFromMask", 

44 "makeThresholdMask", 

45 "saturationCorrection", 

46 "setBadRegions", 

47 "transposeMaskedImage", 

48 "trimToMatchCalibBBox", 

49 "updateVariance", 

50 "widenSaturationTrails", 

51 "getExposureGains", 

52 "getExposureReadNoises", 

53] 

54 

55import logging 

56import math 

57import numpy 

58 

59import lsst.geom 

60import lsst.afw.image as afwImage 

61import lsst.afw.detection as afwDetection 

62import lsst.afw.math as afwMath 

63import lsst.meas.algorithms as measAlg 

64import lsst.afw.cameraGeom as camGeom 

65 

66from lsst.afw.geom import SpanSet, Stencil 

67from lsst.meas.algorithms.detection import SourceDetectionTask 

68 

69from contextlib import contextmanager 

70 

71from .defects import Defects 

72 

73 

74def createPsf(fwhm): 

75 """Make a double Gaussian PSF. 

76 

77 Parameters 

78 ---------- 

79 fwhm : scalar 

80 FWHM of double Gaussian smoothing kernel. 

81 

82 Returns 

83 ------- 

84 psf : `lsst.meas.algorithms.DoubleGaussianPsf` 

85 The created smoothing kernel. 

86 """ 

87 ksize = 4*int(fwhm) + 1 

88 return measAlg.DoubleGaussianPsf(ksize, ksize, fwhm/(2*math.sqrt(2*math.log(2)))) 

89 

90 

91def transposeMaskedImage(maskedImage): 

92 """Make a transposed copy of a masked image. 

93 

94 Parameters 

95 ---------- 

96 maskedImage : `lsst.afw.image.MaskedImage` 

97 Image to process. 

98 

99 Returns 

100 ------- 

101 transposed : `lsst.afw.image.MaskedImage` 

102 The transposed copy of the input image. 

103 """ 

104 transposed = maskedImage.Factory(lsst.geom.Extent2I(maskedImage.getHeight(), maskedImage.getWidth())) 

105 transposed.getImage().getArray()[:] = maskedImage.getImage().getArray().T 

106 transposed.getMask().getArray()[:] = maskedImage.getMask().getArray().T 

107 transposed.getVariance().getArray()[:] = maskedImage.getVariance().getArray().T 

108 return transposed 

109 

110 

111def interpolateDefectList(maskedImage, defectList, fwhm, fallbackValue=None, 

112 maskNameList=None, useLegacyInterp=True): 

113 """Interpolate over defects specified in a defect list. 

114 

115 Parameters 

116 ---------- 

117 maskedImage : `lsst.afw.image.MaskedImage` 

118 Image to process. 

119 defectList : `lsst.meas.algorithms.Defects` 

120 List of defects to interpolate over. 

121 fwhm : `float` 

122 FWHM of double Gaussian smoothing kernel. 

123 fallbackValue : scalar, optional 

124 Fallback value if an interpolated value cannot be determined. 

125 If None, then the clipped mean of the image is used. 

126 maskNameList : `list [string]` 

127 List of the defects to interpolate over (used for GP interpolator). 

128 useLegacyInterp : `bool` 

129 Use the legacy interpolation (polynomial interpolation) if True. Use 

130 Gaussian Process interpolation if False. 

131 

132 Notes 

133 ----- 

134 The ``fwhm`` parameter is used to create a PSF, but the underlying 

135 interpolation code (`lsst.meas.algorithms.interpolateOverDefects`) does 

136 not currently make use of this information in legacy Interpolation, but use 

137 if for the Gaussian Process as an estimation of the correlation lenght. 

138 """ 

139 psf = createPsf(fwhm) 

140 if fallbackValue is None: 140 ↛ 142line 140 didn't jump to line 142 because the condition on line 140 was always true

141 fallbackValue = afwMath.makeStatistics(maskedImage.getImage(), afwMath.MEANCLIP).getValue() 

142 if 'INTRP' not in maskedImage.getMask().getMaskPlaneDict(): 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true

143 maskedImage.getMask().addMaskPlane('INTRP') 

144 

145 # Hardcoded fwhm value. PSF estimated latter in step1, 

146 # not in ISR. 

147 if useLegacyInterp: 

148 kwargs = {} 

149 fwhm = fwhm 

150 else: 

151 # tested on a dozens of images and looks a good set of 

152 # hyperparameters, but cannot guarrenty this is optimal, 

153 # need further testing. 

154 kwargs = {"bin_spacing": 20, 

155 "threshold_dynamic_binning": 2000, 

156 "threshold_subdivide": 20000} 

157 fwhm = 15 

158 

159 measAlg.interpolateOverDefects(maskedImage, psf, defectList, 

160 fallbackValue=fallbackValue, 

161 useFallbackValueAtEdge=True, 

162 fwhm=fwhm, 

163 useLegacyInterp=useLegacyInterp, 

164 maskNameList=maskNameList, **kwargs) 

165 return maskedImage 

166 

167 

168def makeThresholdMask(maskedImage, threshold, growFootprints=1, maskName='SAT'): 

169 """Mask pixels based on threshold detection. 

170 

171 Parameters 

172 ---------- 

173 maskedImage : `lsst.afw.image.MaskedImage` 

174 Image to process. Only the mask plane is updated. 

175 threshold : scalar 

176 Detection threshold. 

177 growFootprints : scalar, optional 

178 Number of pixels to grow footprints of detected regions. 

179 maskName : str, optional 

180 Mask plane name, or list of names to convert 

181 

182 Returns 

183 ------- 

184 defectList : `lsst.meas.algorithms.Defects` 

185 Defect list constructed from pixels above the threshold. 

186 """ 

187 # find saturated regions 

188 thresh = afwDetection.Threshold(threshold) 

189 fs = afwDetection.FootprintSet(maskedImage, thresh) 

190 

191 if growFootprints > 0: 

192 fs = afwDetection.FootprintSet(fs, rGrow=growFootprints, isotropic=False) 

193 fpList = fs.getFootprints() 

194 

195 # set mask 

196 mask = maskedImage.getMask() 

197 bitmask = mask.getPlaneBitMask(maskName) 

198 afwDetection.setMaskFromFootprintList(mask, fpList, bitmask) 

199 

200 return Defects.fromFootprintList(fpList) 

201 

202 

203def growMasks(mask, radius=0, maskNameList=['BAD'], maskValue="BAD"): 

204 """Grow a mask by an amount and add to the requested plane. 

205 

206 Parameters 

207 ---------- 

208 mask : `lsst.afw.image.Mask` 

209 Mask image to process. 

210 radius : scalar 

211 Amount to grow the mask. 

212 maskNameList : `str` or `list` [`str`] 

213 Mask names that should be grown. 

214 maskValue : `str` 

215 Mask plane to assign the newly masked pixels to. 

216 """ 

217 if radius > 0: 217 ↛ exitline 217 didn't return from function 'growMasks' because the condition on line 217 was always true

218 spans = SpanSet.fromMask(mask, mask.getPlaneBitMask(maskNameList)) 

219 # Use MANHATTAN for equivalence with 'isotropic=False` footprint grows, 

220 # but CIRCLE is probably better and might be just as fast. 

221 spans = spans.dilated(radius, Stencil.MANHATTAN) 

222 spans = spans.clippedTo(mask.getBBox()) 

223 spans.setMask(mask, mask.getPlaneBitMask(maskValue)) 

224 

225 

226def maskE2VEdgeBleed(exposure, e2vEdgeBleedSatMinArea=10000, 

227 e2vEdgeBleedSatMaxArea=100000, 

228 e2vEdgeBleedYMax=350, 

229 saturatedMaskName="SAT", log=None): 

230 """Mask edge bleeds in E2V detectors. 

231 

232 Parameters 

233 ---------- 

234 exposure : `lsst.afw.image.Exposure` 

235 Exposure to apply masking to. 

236 e2vEdgeBleedSatMinArea : `int`, optional 

237 Minimum limit of saturated cores footprint area. 

238 e2vEdgeBleedSatMaxArea : `int`, optional 

239 Maximum limit of saturated cores footprint area. 

240 e2vEdgeBleedYMax: `float`, optional 

241 Height of edge bleed masking. 

242 saturatedMaskName : `str`, optional 

243 Mask name for saturation. 

244 log : `logging.Logger`, optional 

245 Logger to handle messages. 

246 """ 

247 

248 log = log if log else logging.getLogger(__name__) 

249 

250 maskedImage = exposure.maskedImage 

251 saturatedBit = maskedImage.mask.getPlaneBitMask(saturatedMaskName) 

252 

253 thresh = afwDetection.Threshold(saturatedBit, afwDetection.Threshold.BITMASK) 

254 

255 fpList = afwDetection.FootprintSet(exposure.mask, thresh).getFootprints() 

256 

257 satAreas = numpy.asarray([fp.getArea() for fp in fpList]) 

258 largeAreas, = numpy.where((satAreas >= e2vEdgeBleedSatMinArea) 

259 & (satAreas < e2vEdgeBleedSatMaxArea)) 

260 for largeAreasIndex in largeAreas: 

261 fpCore = fpList[largeAreasIndex] 

262 xCore, yCore = fpCore.getCentroid() 

263 xCore = int(xCore) 

264 yCore = int(yCore) 

265 

266 for amp in exposure.getDetector(): 

267 if amp.getBBox().contains(xCore, yCore): 

268 ampName = amp.getName() 

269 if ampName[:2] == 'C0': 269 ↛ 266line 269 didn't jump to line 266 because the condition on line 269 was always true

270 # Check that the footprint reaches the bottom of the 

271 # amplifier. 

272 if fpCore.getBBox().getMinY() == 0: 272 ↛ 266line 272 didn't jump to line 266 because the condition on line 272 was always true

273 # This is a large saturation footprint that hits the 

274 # edge, and is thus classified as an edge bleed. 

275 

276 # TODO DM-50587: Optimize number of rows to mask by 

277 # looking at the median signal level as a function of 

278 # row number on the right side of the saturation trail. 

279 

280 log.info("Found E2V edge bleed in amp %s, column %d.", ampName, xCore) 

281 maskedImage.mask[amp.getBBox()].array[:e2vEdgeBleedYMax, :] |= saturatedBit 

282 

283 

284def maskDECamEdgeBleed(exposure, satMinArea=10000, satMaxArea=100000, approachRows=20, 

285 nSigma=5.0, nRowsCheck=20, minLowPixelsPerRow=30, minLowPixelsExtent=10, 

286 marginFraction=0.125, saturatedMaskName="SAT", log=None): 

287 """Mask DECam-style edge bleeds with low rows next to the read register. 

288 

289 For each amplifier that contains a large saturated footprint reaching 

290 within ``approachRows`` of its read edge, this function confirms the dip 

291 from the per-row count of pixels more than ``nSigma`` below the clipped 

292 sky, measures how far the dip extends inward, and marks those rows 

293 (plus a margin) with the saturation mask plane. 

294 

295 Parameters 

296 ---------- 

297 exposure : `lsst.afw.image.Exposure` 

298 Assembled exposure to mask. 

299 satMinArea : `int`, optional 

300 Minimum area (pixels) of a saturated footprint to be considered. 

301 satMaxArea : `int`, optional 

302 Maximum area (pixels) of a saturated footprint to be considered. 

303 approachRows : `int`, optional 

304 A footprint must come within this many rows of the read edge. 

305 nSigma : `float`, optional 

306 A pixel is "low" if it is more than this many sigma below sky. 

307 nRowsCheck : `int`, optional 

308 Number of rows from the read edge used to confirm the bleed. 

309 minLowPixelsPerRow : `int`, optional 

310 Mean number of low pixels per row over the check rows required 

311 to confirm the bleed. 

312 minLowPixelsExtent : `int`, optional 

313 Number of low pixels a row must exceed to count toward the bleed 

314 height; the scan stops after five consecutive rows that do not. 

315 Should be smaller than ``minLowPixelsPerRow``. 

316 marginFraction : `float`, optional 

317 Extra rows masked beyond the measured height, as a fraction of 

318 that height (plus one row). 

319 saturatedMaskName : `str`, optional 

320 Mask plane name for saturation. 

321 log : `logging.Logger`, optional 

322 Logger to handle messages. 

323 

324 Raises 

325 ------ 

326 RuntimeError 

327 Raised if the exposure has no detector with amplifiers. 

328 """ 

329 log = log if log else logging.getLogger(__name__) 

330 

331 detector = exposure.getDetector() 

332 if detector is None or len(detector) == 0: 

333 raise RuntimeError("Cannot mask DECam edge bleeds: exposure has no detector with amplifiers.") 

334 

335 maskedImage = exposure.maskedImage 

336 saturatedBit = maskedImage.mask.getPlaneBitMask(saturatedMaskName) 

337 unusableBits = maskedImage.mask.getPlaneBitMask([saturatedMaskName, "BAD", "NO_DATA"]) 

338 statsBits = unusableBits | maskedImage.mask.getPlaneBitMask("SUSPECT") 

339 

340 thresh = afwDetection.Threshold(saturatedBit, afwDetection.Threshold.BITMASK) 

341 fpList = afwDetection.FootprintSet(maskedImage.mask, thresh).getFootprints() 

342 candidates = [] 

343 for fp in fpList: 

344 if satMinArea <= fp.getArea() < satMaxArea: 

345 xCore, yCore = fp.getCentroid() 

346 candidates.append((int(xCore), int(yCore), fp.getBBox())) 

347 if not candidates: 

348 log.debug("No saturated footprints in the DECam edge bleed area range.") 

349 return 

350 

351 statsCtrl = afwMath.StatisticsControl() 

352 statsCtrl.setAndMask(statsBits) 

353 # Number of consecutive rows without a dip that ends the height scan. 

354 nRowsStop = 5 

355 

356 for amp in detector: 

357 ampBBox = amp.getBBox() 

358 if not exposure.getBBox().contains(ampBBox): 

359 continue 

360 readTop = amp.getReadoutCorner() in (camGeom.ReadoutCorner.UL, camGeom.ReadoutCorner.UR) 

361 

362 if readTop: 

363 reachesReadEdge = any(ampBBox.contains(x, y) 

364 and fpBBox.getMaxY() >= ampBBox.getMaxY() - approachRows 

365 for x, y, fpBBox in candidates) 

366 else: 

367 reachesReadEdge = any(ampBBox.contains(x, y) 

368 and fpBBox.getMinY() <= ampBBox.getMinY() + approachRows 

369 for x, y, fpBBox in candidates) 

370 if not reachesReadEdge: 

371 continue 

372 

373 ampImage = maskedImage[ampBBox] 

374 stats = afwMath.makeStatistics(ampImage, afwMath.MEANCLIP | afwMath.STDEVCLIP | afwMath.NPOINT, 

375 statsCtrl) 

376 if stats.getValue(afwMath.NPOINT) < 1000: 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true

377 log.debug("Skipping DECam edge bleed check in amp %s: too few unmasked pixels.", 

378 amp.getName()) 

379 continue 

380 sky = stats.getValue(afwMath.MEANCLIP) 

381 sigma = stats.getValue(afwMath.STDEVCLIP) 

382 

383 usable = (ampImage.mask.array & unusableBits) == 0 

384 low = (ampImage.image.array < sky - nSigma*sigma) & usable 

385 # Row profiles with index 0 at the read edge, increasing inward. 

386 profile = low.sum(axis=1) 

387 nUsable = usable.sum(axis=1) 

388 if readTop: 

389 profile = profile[::-1] 

390 nUsable = nUsable[::-1] 

391 

392 # Skip edge rows that cannot hold enough low pixels to confirm a dip. 

393 enoughUsable = numpy.nonzero(nUsable >= minLowPixelsPerRow)[0] 

394 if len(enoughUsable) == 0: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true

395 log.debug("Skipping DECam edge bleed check in amp %s: no rows with usable pixels.", 

396 amp.getName()) 

397 continue 

398 start = enoughUsable[0] 

399 

400 if profile[start:start + nRowsCheck].mean() <= minLowPixelsPerRow: 

401 log.debug("Saturated footprint reaches read edge of amp %s but no dip found.", 

402 amp.getName()) 

403 continue 

404 

405 height = 0 

406 nBelow = 0 

407 for i in range(start, len(profile)): 407 ↛ 415line 407 didn't jump to line 415 because the loop on line 407 didn't complete

408 if profile[i] > minLowPixelsExtent: 

409 height = i + 1 

410 nBelow = 0 

411 else: 

412 nBelow += 1 

413 if nBelow >= nRowsStop: 

414 break 

415 height += int(height*marginFraction) + 1 

416 height = min(height, ampBBox.getHeight()) 

417 

418 if readTop: 

419 maskedImage.mask[ampBBox].array[-height:, :] |= saturatedBit 

420 else: 

421 maskedImage.mask[ampBBox].array[:height, :] |= saturatedBit 

422 log.info("Found DECam edge bleed in amp %s at the %s read edge; masked %d rows.", 

423 amp.getName(), "top" if readTop else "bottom", height) 

424 

425 

426def maskITLEdgeBleed(ccdExposure, badAmpDict, 

427 fpCore, itlEdgeBleedSatMinArea=10000, 

428 itlEdgeBleedSatMaxArea=100000, 

429 itlEdgeBleedThreshold=5000., 

430 itlEdgeBleedModelConstant=0.02, 

431 saturatedMaskName="SAT", log=None): 

432 """Mask edge bleeds in ITL detectors. 

433 

434 Parameters 

435 ---------- 

436 ccdExposure : `lsst.afw.image.Exposure` 

437 Exposure to apply masking to. 

438 badAmpDict : `dict` [`str`, `bool`] 

439 Dictionary of amplifiers, keyed by name, value is True if 

440 amplifier is fully masked. 

441 fpCore : `lsst.afw.detection._detection.Footprint` 

442 Footprint of saturated core. 

443 itlEdgeBleedThreshold : `float`, optional 

444 Threshold above median sky background for edge bleed detection 

445 (electron units). 

446 itlEdgeBleedModelConstant : `float`, optional 

447 Constant in the decaying exponential in the edge bleed masking. 

448 saturatedMaskName : `str`, optional 

449 Mask name for saturation. 

450 log : `logging.Logger`, optional 

451 Logger to handle messages. 

452 """ 

453 

454 log = log if log else logging.getLogger(__name__) 

455 

456 # Get median of amplifier saturation level 

457 satLevel = numpy.nanmedian([ccdExposure.metadata[f"LSST ISR SATURATION LEVEL {amp.getName()}"] 

458 for amp in ccdExposure.getDetector() if not badAmpDict[amp.getName()]]) 

459 

460 # 1. we check if there are several cores in the footprint: 

461 # Get centroid of saturated core 

462 xCore, yCore = fpCore.getCentroid() 

463 # Turn the Y detector coordinate into Y footprint coordinate 

464 yCoreFP = int(yCore) - fpCore.getBBox().getMinY() 

465 # Now test if there is one or more cores by checking if the slice at the 

466 # center is full of saturated pixels or has several segments of saturated 

467 # columns (i.e. several cores with trails) 

468 checkCoreNbRow = fpCore.getSpans().asArray()[yCoreFP, :] 

469 nbCore = 0 

470 indexSwitchTrue = [] 

471 indexSwitchFalse = [] 

472 if checkCoreNbRow[0]: 

473 # If the slice starts with saturated pixels 

474 inSatSegment = True 

475 nbCore = 1 

476 indexSwitchTrue.append(0) 

477 else: 

478 # If the slice starts with non saturated pixels 

479 inSatSegment = False 

480 

481 for i, value in enumerate(checkCoreNbRow): 

482 if value: 

483 if not inSatSegment: 

484 indexSwitchTrue.append(i) 

485 # nbCore is the number of detected cores. 

486 nbCore += 1 

487 inSatSegment = True 

488 elif inSatSegment: 

489 indexSwitchFalse.append(i) 

490 inSatSegment = False 

491 

492 # 1. we look for edge bleed in saturated cores in the footprint 

493 if nbCore == 2: 

494 # we now estimate the x coordinates of the edges of the subfootprint 

495 # for each core 

496 xEdgesCores = [0] 

497 xEdgesCores.append(int((indexSwitchTrue[1] + indexSwitchFalse[0])/2)) 

498 xEdgesCores.append(fpCore.getSpans().asArray().shape[1]) 

499 # Get the X and Y footprint coordinates of the cores 

500 for i in range(nbCore): 

501 subfp = fpCore.getSpans().asArray()[:, xEdgesCores[i]:xEdgesCores[i+1]] 

502 xCoreFP = int(xEdgesCores[i] + numpy.argmax(numpy.sum(subfp, axis=0))) 

503 # turn into X coordinate in detector space 

504 xCore = xCoreFP + fpCore.getBBox().getMinX() 

505 # get Y footprint coordinate of the core 

506 # by trimming the edges where edge bleeds are potentially dominant 

507 if subfp.shape[0] <= 200: 507 ↛ 510line 507 didn't jump to line 510 because the condition on line 507 was always true

508 yCoreFP = int(numpy.argmax(numpy.sum(subfp, axis=1))) 

509 else: 

510 yCoreFP = int(numpy.argmax(numpy.sum(subfp[100:-100, :], 

511 axis=1))) 

512 yCoreFP = 100+yCoreFP 

513 

514 # Estimate the width of the saturated core 

515 widthSat = numpy.sum(subfp[int(yCoreFP), :]) 

516 

517 subfpArea = numpy.sum(subfp) 

518 if subfpArea > itlEdgeBleedSatMinArea and subfpArea < itlEdgeBleedSatMaxArea: 518 ↛ 500line 518 didn't jump to line 500 because the condition on line 518 was always true

519 _applyMaskITLEdgeBleed(ccdExposure, xCore, 

520 satLevel, widthSat, 

521 itlEdgeBleedThreshold, 

522 itlEdgeBleedModelConstant, 

523 saturatedMaskName, log) 

524 elif nbCore > 2: 524 ↛ 526line 524 didn't jump to line 526 because the condition on line 524 was never true

525 # TODO DM-49736: support N cores in saturated footprint 

526 log.warning( 

527 "Too many (%d) cores in saturated footprint to mask edge bleeds.", 

528 nbCore, 

529 ) 

530 else: 

531 # Get centroid of saturated core 

532 xCore, yCore = fpCore.getCentroid() 

533 # Turn the Y detector coordinate into Y footprint coordinate 

534 yCoreFP = yCore - fpCore.getBBox().getMinY() 

535 # Get the number of saturated columns around the centroid 

536 widthSat = numpy.sum(fpCore.getSpans().asArray()[int(yCoreFP), :]) 

537 _applyMaskITLEdgeBleed(ccdExposure, xCore, 

538 satLevel, widthSat, itlEdgeBleedThreshold, 

539 itlEdgeBleedModelConstant, saturatedMaskName, log) 

540 

541 

542def _applyMaskITLEdgeBleed(ccdExposure, xCore, 

543 satLevel, widthSat, 

544 itlEdgeBleedThreshold=5000., 

545 itlEdgeBleedModelConstant=0.03, 

546 saturatedMaskName="SAT", log=None): 

547 """Apply ITL edge bleed masking model. 

548 

549 Parameters 

550 ---------- 

551 ccdExposure : `lsst.afw.image.Exposure` 

552 Exposure to apply masking to. 

553 xCore: `int` 

554 X coordinate of the saturated core. 

555 satLevel: `float` 

556 Minimum saturation level of the detector. 

557 widthSat: `float` 

558 Width of the saturated core. 

559 itlEdgeBleedThreshold : `float`, optional 

560 Threshold above median sky background for edge bleed detection 

561 (electron units). 

562 itlEdgeBleedModelConstant : `float`, optional 

563 Constant in the decaying exponential in the edge bleed masking. 

564 saturatedMaskName : `str`, optional 

565 Mask name for saturation. 

566 log : `logging.Logger`, optional 

567 Logger to handle messages. 

568 """ 

569 log = log if log else logging.getLogger(__name__) 

570 

571 maskedImage = ccdExposure.maskedImage 

572 xmax = maskedImage.image.array.shape[1] 

573 saturatedBit = maskedImage.mask.getPlaneBitMask(saturatedMaskName) 

574 

575 for amp in ccdExposure.getDetector(): 

576 # Select the 2 top and bottom amplifiers around the saturated 

577 # core with a potential edge bleed by selecting the amplifiers 

578 # that have the same X coordinate as the saturated core. 

579 # As we don't care about the Y coordinate, we set it to the 

580 # center of the BBox. 

581 yBox = amp.getBBox().getCenter()[1] 

582 if amp.getBBox().contains(xCore, yBox): 

583 

584 # Get the amp name 

585 ampName = amp.getName() 

586 

587 # Because in ITLs the edge bleed happens on both edges 

588 # of the detector, we make a cutout around 

589 # both the top and bottom 

590 # edge bleed candidates around the saturated core. 

591 # We flip the cutout of the top amplifier 

592 # to then work with the same coordinates for both. 

593 # The way of selecting top vs bottom amp 

594 # is very specific to ITL. 

595 if ampName[:2] == 'C1': 

596 sliceImage = maskedImage.image.array[:200, :] 

597 sliceMask = maskedImage.mask.array[:200, :] 

598 elif ampName[:2] == 'C0': 598 ↛ 612line 598 didn't jump to line 612 because the condition on line 598 was always true

599 sliceImage = numpy.flipud(maskedImage.image.array[-200:, :]) 

600 sliceMask = numpy.flipud(maskedImage.mask.array[-200:, :]) 

601 

602 # The middle columns of edge bleeds often have 

603 # high counts, so we check there is an edge bleed 

604 # by looking at a small image up to 50 pixels from the edge 

605 # and around the saturated columns 

606 # of the saturated core, and checking its median is 

607 # above the sky background by itlEdgeBleedThreshold 

608 

609 # If the centroid is too close to the edge of the detector 

610 # (within 5 pixels), we set the limit to the mean check 

611 # to the edge of the detector 

612 lowerRangeSmall = int(xCore)-5 

613 upperRangeSmall = int(xCore)+5 

614 if lowerRangeSmall < 0: 

615 lowerRangeSmall = 0 

616 if upperRangeSmall > xmax: 616 ↛ 617line 616 didn't jump to line 617 because the condition on line 616 was never true

617 upperRangeSmall = xmax 

618 ampImageBG = numpy.median(maskedImage[amp.getBBox()].image.array) 

619 edgeMedian = numpy.median(sliceImage[:50, lowerRangeSmall:upperRangeSmall]) 

620 if edgeMedian > (ampImageBG + itlEdgeBleedThreshold): 

621 

622 log.info("Found ITL edge bleed in amp %s, column %d.", ampName, xCore) 

623 

624 # We need an estimate of the maximum width 

625 # of the edge bleed for our masking model 

626 # so we now estimate it by measuring the width of 

627 # areas above 60 percent of the saturation level 

628 # close to the edge, 

629 # in a cutout up to 100 pixels from the edge, 

630 # with a width of around the width of an amplifier. 

631 subImageXMin = int(xCore)-250 

632 subImageXMax = int(xCore)+250 

633 if subImageXMin < 0: 

634 subImageXMin = 0 

635 elif subImageXMax > xmax: 

636 subImageXMax = xmax 

637 

638 subImage = sliceImage[:100, subImageXMin:subImageXMax] 

639 maxWidthEdgeBleed = numpy.max(numpy.sum(subImage > 0.45*satLevel, 

640 axis=1)) 

641 

642 # Mask edge bleed with a decaying exponential model 

643 for y in range(200): 

644 edgeBleedHalfWidth = \ 

645 int(((maxWidthEdgeBleed)*numpy.exp(-itlEdgeBleedModelConstant*y) 

646 + widthSat)/2.) 

647 lowerRange = int(xCore)-edgeBleedHalfWidth 

648 upperRange = int(xCore)+edgeBleedHalfWidth 

649 # If the edge bleed model goes outside the detector 

650 # we set the limit for the masking 

651 # to the edge of the detector 

652 if lowerRange < 0: 

653 lowerRange = 0 

654 if upperRange > xmax: 

655 upperRange = xmax 

656 sliceMask[y, lowerRange:upperRange] |= saturatedBit 

657 

658 

659def maskITLSatSag(ccdExposure, fpCore, saturatedMaskName="SAT"): 

660 """Mask columns presenting saturation sag in saturated footprints in 

661 ITL detectors. 

662 

663 Parameters 

664 ---------- 

665 ccdExposure : `lsst.afw.image.Exposure` 

666 Exposure to apply masking to. 

667 fpCore : `lsst.afw.detection._detection.Footprint` 

668 Footprint of saturated core. 

669 saturatedMaskName : `str`, optional 

670 Mask name for saturation. 

671 """ 

672 

673 # TODO DM-49736: add a flux level check to apply masking 

674 

675 maskedImage = ccdExposure.maskedImage 

676 saturatedBit = maskedImage.mask.getPlaneBitMask(saturatedMaskName) 

677 

678 cc = numpy.sum(fpCore.getSpans().asArray(), axis=0) 

679 # Mask full columns that have 20 percent of the height of the footprint 

680 # saturated 

681 columnsToMaskFP = numpy.where(cc > fpCore.getSpans().asArray().shape[0]/5.) 

682 

683 columnsToMask = [x + int(fpCore.getBBox().getMinX()) for x in columnsToMaskFP] 

684 maskedImage.mask.array[:, columnsToMask] |= saturatedBit 

685 

686 

687def maskITLDip(exposure, detectorConfig, maskPlaneNames=["SUSPECT", "ITL_DIP"], log=None): 

688 """Add mask bits according to the ITL dip model. 

689 

690 Parameters 

691 ---------- 

692 exposure : `lsst.afw.image.Exposure` 

693 Exposure to do ITL dip masking. 

694 detectorConfig : `lsst.ip.isr.overscanAmpConfig.OverscanDetectorConfig` 

695 Configuration for this detector. 

696 maskPlaneNames : `list [`str`], optional 

697 Name of the ITL Dip mask planes. 

698 log : `logging.Logger`, optional 

699 If not set, a default logger will be used. 

700 """ 

701 if detectorConfig.itlDipBackgroundFraction == 0.0: 

702 # Nothing to do. 

703 return 

704 

705 if log is None: 705 ↛ 708line 705 didn't jump to line 708 because the condition on line 705 was always true

706 log = logging.getLogger(__name__) 

707 

708 thresh = afwDetection.Threshold( 

709 exposure.mask.getPlaneBitMask("SAT"), 

710 afwDetection.Threshold.BITMASK, 

711 ) 

712 fpList = afwDetection.FootprintSet(exposure.mask, thresh).getFootprints() 

713 

714 heights = numpy.asarray([fp.getBBox().getHeight() for fp in fpList]) 

715 

716 largeHeights, = numpy.where(heights >= detectorConfig.itlDipMinHeight) 

717 

718 if len(largeHeights) == 0: 718 ↛ 719line 718 didn't jump to line 719 because the condition on line 718 was never true

719 return 

720 

721 # Get the approximate image background. 

722 approxBackground = numpy.median(exposure.image.array) 

723 maskValue = exposure.mask.getPlaneBitMask(maskPlaneNames) 

724 

725 maskBak = exposure.mask.array.copy() 

726 nMaskedCols = 0 

727 

728 for index in largeHeights: 

729 fp = fpList[index] 

730 center = fp.getCentroid() 

731 

732 nSat = numpy.sum(fp.getSpans().asArray(), axis=0) 

733 width = numpy.sum(nSat > detectorConfig.itlDipMinHeight) 

734 

735 if width < detectorConfig.itlDipMinWidth: 

736 continue 

737 

738 width = numpy.clip(width, None, detectorConfig.itlDipMaxWidth) 

739 

740 dipMax = detectorConfig.itlDipBackgroundFraction * approxBackground * width 

741 

742 # Assume sky-noise dominated; we could add in read noise here. 

743 if dipMax < detectorConfig.itlDipMinBackgroundNoiseFraction * numpy.sqrt(approxBackground): 

744 continue 

745 

746 minCol = int(center.getX() - (detectorConfig.itlDipWidthScale * width) / 2.) 

747 maxCol = int(center.getX() + (detectorConfig.itlDipWidthScale * width) / 2.) 

748 minCol = numpy.clip(minCol, 0, None) 

749 maxCol = numpy.clip(maxCol, None, exposure.mask.array.shape[1] - 1) 

750 

751 log.info( 

752 "Found ITL dip (width %d; bkg %.2f); masking column %d to %d.", 

753 width, 

754 approxBackground, 

755 minCol, 

756 maxCol, 

757 ) 

758 

759 exposure.mask.array[:, minCol: maxCol + 1] |= maskValue 

760 

761 nMaskedCols += (maxCol - minCol + 1) 

762 

763 if nMaskedCols > detectorConfig.itlDipMaxColsPerImage: 

764 log.warning( 

765 "Too many (%d) columns would be masked on this image from dip masking; restoring original mask.", 

766 nMaskedCols, 

767 ) 

768 exposure.mask.array[:, :] = maskBak 

769 

770 

771def interpolateFromMask(maskedImage, fwhm, growSaturatedFootprints=1, 

772 maskNameList=['SAT'], fallbackValue=None, useLegacyInterp=True): 

773 """Interpolate over defects identified by a particular set of mask planes. 

774 

775 Parameters 

776 ---------- 

777 maskedImage : `lsst.afw.image.MaskedImage` 

778 Image to process. 

779 fwhm : `float` 

780 FWHM of double Gaussian smoothing kernel. 

781 growSaturatedFootprints : scalar, optional 

782 Number of pixels to grow footprints for saturated pixels. 

783 maskNameList : `List` of `str`, optional 

784 Mask plane name. 

785 fallbackValue : scalar, optional 

786 Value of last resort for interpolation. 

787 

788 Notes 

789 ----- 

790 The ``fwhm`` parameter is used to create a PSF, but the underlying 

791 interpolation code (`lsst.meas.algorithms.interpolateOverDefects`) does 

792 not currently make use of this information. 

793 """ 

794 mask = maskedImage.getMask() 

795 

796 if growSaturatedFootprints > 0 and "SAT" in maskNameList: 

797 # If we are interpolating over an area larger than the original masked 

798 # region, we need to expand the original mask bit to the full area to 

799 # explain why we interpolated there. 

800 growMasks(mask, radius=growSaturatedFootprints, maskNameList=['SAT'], maskValue="SAT") 

801 

802 thresh = afwDetection.Threshold(mask.getPlaneBitMask(maskNameList), afwDetection.Threshold.BITMASK) 

803 fpSet = afwDetection.FootprintSet(mask, thresh) 

804 defectList = Defects.fromFootprintList(fpSet.getFootprints()) 

805 

806 interpolateDefectList(maskedImage, defectList, fwhm, fallbackValue=fallbackValue, 

807 maskNameList=maskNameList, useLegacyInterp=useLegacyInterp) 

808 

809 return maskedImage 

810 

811 

812def saturationCorrection(maskedImage, saturation, fwhm, growFootprints=1, interpolate=True, maskName='SAT', 

813 fallbackValue=None, useLegacyInterp=True): 

814 """Mark saturated pixels and optionally interpolate over them 

815 

816 Parameters 

817 ---------- 

818 maskedImage : `lsst.afw.image.MaskedImage` 

819 Image to process. 

820 saturation : scalar 

821 Saturation level used as the detection threshold. 

822 fwhm : `float` 

823 FWHM of double Gaussian smoothing kernel. 

824 growFootprints : scalar, optional 

825 Number of pixels to grow footprints of detected regions. 

826 interpolate : Bool, optional 

827 If True, saturated pixels are interpolated over. 

828 maskName : str, optional 

829 Mask plane name. 

830 fallbackValue : scalar, optional 

831 Value of last resort for interpolation. 

832 

833 Notes 

834 ----- 

835 The ``fwhm`` parameter is used to create a PSF, but the underlying 

836 interpolation code (`lsst.meas.algorithms.interpolateOverDefects`) does 

837 not currently make use of this information. 

838 """ 

839 defectList = makeThresholdMask( 

840 maskedImage=maskedImage, 

841 threshold=saturation, 

842 growFootprints=growFootprints, 

843 maskName=maskName, 

844 ) 

845 if interpolate: 

846 interpolateDefectList(maskedImage, defectList, fwhm, fallbackValue=fallbackValue, 

847 maskNameList=[maskName], useLegacyInterp=useLegacyInterp) 

848 

849 return maskedImage 

850 

851 

852def trimToMatchCalibBBox(rawMaskedImage, calibMaskedImage): 

853 """Compute number of edge trim pixels to match the calibration data. 

854 

855 Use the dimension difference between the raw exposure and the 

856 calibration exposure to compute the edge trim pixels. This trim 

857 is applied symmetrically, with the same number of pixels masked on 

858 each side. 

859 

860 Parameters 

861 ---------- 

862 rawMaskedImage : `lsst.afw.image.MaskedImage` 

863 Image to trim. 

864 calibMaskedImage : `lsst.afw.image.MaskedImage` 

865 Calibration image to draw new bounding box from. 

866 

867 Returns 

868 ------- 

869 replacementMaskedImage : `lsst.afw.image.MaskedImage` 

870 ``rawMaskedImage`` trimmed to the appropriate size. 

871 

872 Raises 

873 ------ 

874 RuntimeError 

875 Raised if ``rawMaskedImage`` cannot be symmetrically trimmed to 

876 match ``calibMaskedImage``. 

877 """ 

878 nx, ny = rawMaskedImage.getBBox().getDimensions() - calibMaskedImage.getBBox().getDimensions() 

879 if nx != ny: 879 ↛ 880line 879 didn't jump to line 880 because the condition on line 879 was never true

880 raise RuntimeError("Raw and calib maskedImages are trimmed differently in X and Y.") 

881 if nx % 2 != 0: 881 ↛ 882line 881 didn't jump to line 882 because the condition on line 881 was never true

882 raise RuntimeError("Calibration maskedImage is trimmed unevenly in X.") 

883 if nx < 0: 883 ↛ 884line 883 didn't jump to line 884 because the condition on line 883 was never true

884 raise RuntimeError("Calibration maskedImage is larger than raw data.") 

885 

886 nEdge = nx//2 

887 if nEdge > 0: 

888 replacementMaskedImage = rawMaskedImage[nEdge:-nEdge, nEdge:-nEdge, afwImage.LOCAL] 

889 SourceDetectionTask.setEdgeBits( 

890 rawMaskedImage, 

891 replacementMaskedImage.getBBox(), 

892 rawMaskedImage.getMask().getPlaneBitMask("EDGE") 

893 ) 

894 else: 

895 replacementMaskedImage = rawMaskedImage 

896 

897 return replacementMaskedImage 

898 

899 

900def biasCorrection(maskedImage, biasMaskedImage, trimToFit=False): 

901 """Apply bias correction in place. 

902 

903 Parameters 

904 ---------- 

905 maskedImage : `lsst.afw.image.MaskedImage` 

906 Image to process. The image is modified by this method. 

907 biasMaskedImage : `lsst.afw.image.MaskedImage` 

908 Bias image of the same size as ``maskedImage`` 

909 trimToFit : `Bool`, optional 

910 If True, raw data is symmetrically trimmed to match 

911 calibration size. 

912 

913 Raises 

914 ------ 

915 RuntimeError 

916 Raised if ``maskedImage`` and ``biasMaskedImage`` do not have 

917 the same size. 

918 

919 """ 

920 if trimToFit: 

921 maskedImage = trimToMatchCalibBBox(maskedImage, biasMaskedImage) 

922 

923 if maskedImage.getBBox(afwImage.LOCAL) != biasMaskedImage.getBBox(afwImage.LOCAL): 

924 raise RuntimeError("maskedImage bbox %s != biasMaskedImage bbox %s" % 

925 (maskedImage.getBBox(afwImage.LOCAL), biasMaskedImage.getBBox(afwImage.LOCAL))) 

926 maskedImage -= biasMaskedImage 

927 

928 

929def darkCorrection(maskedImage, darkMaskedImage, expScale, darkScale, invert=False, trimToFit=False): 

930 """Apply dark correction in place. 

931 

932 Parameters 

933 ---------- 

934 maskedImage : `lsst.afw.image.MaskedImage` 

935 Image to process. The image is modified by this method. 

936 darkMaskedImage : `lsst.afw.image.MaskedImage` 

937 Dark image of the same size as ``maskedImage``. 

938 expScale : scalar 

939 Dark exposure time for ``maskedImage``. 

940 darkScale : scalar 

941 Dark exposure time for ``darkMaskedImage``. 

942 invert : `Bool`, optional 

943 If True, re-add the dark to an already corrected image. 

944 trimToFit : `Bool`, optional 

945 If True, raw data is symmetrically trimmed to match 

946 calibration size. 

947 

948 Raises 

949 ------ 

950 RuntimeError 

951 Raised if ``maskedImage`` and ``darkMaskedImage`` do not have 

952 the same size. 

953 

954 Notes 

955 ----- 

956 The dark correction is applied by calculating: 

957 maskedImage -= dark * expScaling / darkScaling 

958 """ 

959 if trimToFit: 

960 maskedImage = trimToMatchCalibBBox(maskedImage, darkMaskedImage) 

961 

962 if maskedImage.getBBox(afwImage.LOCAL) != darkMaskedImage.getBBox(afwImage.LOCAL): 

963 raise RuntimeError("maskedImage bbox %s != darkMaskedImage bbox %s" % 

964 (maskedImage.getBBox(afwImage.LOCAL), darkMaskedImage.getBBox(afwImage.LOCAL))) 

965 

966 scale = expScale / darkScale 

967 if not invert: 

968 maskedImage.scaledMinus(scale, darkMaskedImage) 

969 else: 

970 maskedImage.scaledPlus(scale, darkMaskedImage) 

971 

972 

973def updateVariance(maskedImage, gain, readNoise, replace=True): 

974 """Set the variance plane based on the image plane. 

975 

976 The maskedImage must have units of `adu` (if gain != 1.0) or 

977 electron (if gain == 1.0). This routine will always produce a 

978 variance plane in the same units as the image. 

979 

980 Parameters 

981 ---------- 

982 maskedImage : `lsst.afw.image.MaskedImage` 

983 Image to process. The variance plane is modified. 

984 gain : scalar 

985 The amplifier gain in electron/adu. 

986 readNoise : scalar 

987 The amplifier read noise in electron/pixel. 

988 replace : `bool`, optional 

989 Replace the current variance? If False, the image 

990 variance will be added to the current variance plane. 

991 """ 

992 var = maskedImage.variance 

993 if replace: 

994 var[:, :] = maskedImage.image 

995 else: 

996 var[:, :] += maskedImage.image 

997 var /= gain 

998 var += (readNoise/gain)**2 

999 

1000 

1001def flatCorrection(maskedImage, flatMaskedImage, scalingType, userScale=1.0, invert=False, trimToFit=False): 

1002 """Apply flat correction in place. 

1003 

1004 Parameters 

1005 ---------- 

1006 maskedImage : `lsst.afw.image.MaskedImage` 

1007 Image to process. The image is modified. 

1008 flatMaskedImage : `lsst.afw.image.MaskedImage` 

1009 Flat image of the same size as ``maskedImage`` 

1010 scalingType : str 

1011 Flat scale computation method. Allowed values are 'MEAN', 

1012 'MEDIAN', or 'USER'. 

1013 userScale : scalar, optional 

1014 Scale to use if ``scalingType='USER'``. 

1015 invert : `Bool`, optional 

1016 If True, unflatten an already flattened image. 

1017 trimToFit : `Bool`, optional 

1018 If True, raw data is symmetrically trimmed to match 

1019 calibration size. 

1020 

1021 Raises 

1022 ------ 

1023 RuntimeError 

1024 Raised if ``maskedImage`` and ``flatMaskedImage`` do not have 

1025 the same size or if ``scalingType`` is not an allowed value. 

1026 """ 

1027 if trimToFit: 

1028 maskedImage = trimToMatchCalibBBox(maskedImage, flatMaskedImage) 

1029 

1030 if maskedImage.getBBox(afwImage.LOCAL) != flatMaskedImage.getBBox(afwImage.LOCAL): 

1031 raise RuntimeError("maskedImage bbox %s != flatMaskedImage bbox %s" % 

1032 (maskedImage.getBBox(afwImage.LOCAL), flatMaskedImage.getBBox(afwImage.LOCAL))) 

1033 

1034 # Figure out scale from the data 

1035 # Ideally the flats are normalized by the calibration product pipeline, 

1036 # but this allows some flexibility in the case that the flat is created by 

1037 # some other mechanism. 

1038 if scalingType in ('MEAN', 'MEDIAN'): 

1039 scalingType = afwMath.stringToStatisticsProperty(scalingType) 

1040 flatScale = afwMath.makeStatistics(flatMaskedImage.image, scalingType).getValue() 

1041 elif scalingType == 'USER': 

1042 flatScale = userScale 

1043 else: 

1044 raise RuntimeError('%s : %s not implemented' % ("flatCorrection", scalingType)) 

1045 

1046 if not invert: 

1047 maskedImage.scaledDivides(1.0/flatScale, flatMaskedImage) 

1048 else: 

1049 maskedImage.scaledMultiplies(1.0/flatScale, flatMaskedImage) 

1050 

1051 

1052def illuminationCorrection(maskedImage, illumMaskedImage, illumScale, trimToFit=True): 

1053 """Apply illumination correction in place. 

1054 

1055 Parameters 

1056 ---------- 

1057 maskedImage : `lsst.afw.image.MaskedImage` 

1058 Image to process. The image is modified. 

1059 illumMaskedImage : `lsst.afw.image.MaskedImage` 

1060 Illumination correction image of the same size as ``maskedImage``. 

1061 illumScale : scalar 

1062 Scale factor for the illumination correction. 

1063 trimToFit : `Bool`, optional 

1064 If True, raw data is symmetrically trimmed to match 

1065 calibration size. 

1066 

1067 Raises 

1068 ------ 

1069 RuntimeError 

1070 Raised if ``maskedImage`` and ``illumMaskedImage`` do not have 

1071 the same size. 

1072 """ 

1073 if trimToFit: 

1074 maskedImage = trimToMatchCalibBBox(maskedImage, illumMaskedImage) 

1075 

1076 if maskedImage.getBBox(afwImage.LOCAL) != illumMaskedImage.getBBox(afwImage.LOCAL): 

1077 raise RuntimeError("maskedImage bbox %s != illumMaskedImage bbox %s" % 

1078 (maskedImage.getBBox(afwImage.LOCAL), illumMaskedImage.getBBox(afwImage.LOCAL))) 

1079 

1080 maskedImage.scaledDivides(1.0/illumScale, illumMaskedImage) 

1081 

1082 

1083@contextmanager 

1084def gainContext(exp, image, apply, gains=None, invert=False, isTrimmed=True): 

1085 """Context manager that applies and removes gain. 

1086 

1087 Parameters 

1088 ---------- 

1089 exp : `lsst.afw.image.Exposure` 

1090 Exposure to apply/remove gain. 

1091 image : `lsst.afw.image.Image` 

1092 Image to apply/remove gain. 

1093 apply : `bool` 

1094 If True, apply and remove the amplifier gain. 

1095 gains : `dict` [`str`, `float`], optional 

1096 A dictionary, keyed by amplifier name, of the gains to use. 

1097 If gains is None, the nominal gains in the amplifier object are used. 

1098 invert : `bool`, optional 

1099 Invert the gains (e.g. convert electrons to adu temporarily)? 

1100 isTrimmed : `bool`, optional 

1101 Is this a trimmed exposure? 

1102 

1103 Yields 

1104 ------ 

1105 exp : `lsst.afw.image.Exposure` 

1106 Exposure with the gain applied. 

1107 """ 

1108 # check we have all of them if provided because mixing and matching would 

1109 # be a real mess 

1110 if gains and apply is True: 

1111 ampNames = [amp.getName() for amp in exp.getDetector()] 

1112 for ampName in ampNames: 

1113 if ampName not in gains.keys(): 1113 ↛ 1114line 1113 didn't jump to line 1114 because the condition on line 1113 was never true

1114 raise RuntimeError(f"Gains provided to gain context, but no entry found for amp {ampName}") 

1115 

1116 if apply: 

1117 ccd = exp.getDetector() 

1118 for amp in ccd: 

1119 sim = image.Factory(image, amp.getBBox() if isTrimmed else amp.getRawBBox()) 

1120 if gains: 

1121 gain = gains[amp.getName()] 

1122 else: 

1123 gain = amp.getGain() 

1124 if invert: 1124 ↛ 1125line 1124 didn't jump to line 1125 because the condition on line 1124 was never true

1125 sim /= gain 

1126 else: 

1127 sim *= gain 

1128 

1129 try: 

1130 yield exp 

1131 finally: 

1132 if apply: 

1133 ccd = exp.getDetector() 

1134 for amp in ccd: 

1135 sim = image.Factory(image, amp.getBBox() if isTrimmed else amp.getRawBBox()) 

1136 if gains: 

1137 gain = gains[amp.getName()] 

1138 else: 

1139 gain = amp.getGain() 

1140 if invert: 1140 ↛ 1141line 1140 didn't jump to line 1141 because the condition on line 1140 was never true

1141 sim *= gain 

1142 else: 

1143 sim /= gain 

1144 

1145 

1146def attachTransmissionCurve(exposure, opticsTransmission=None, filterTransmission=None, 

1147 sensorTransmission=None, atmosphereTransmission=None): 

1148 """Attach a TransmissionCurve to an Exposure, given separate curves for 

1149 different components. 

1150 

1151 Parameters 

1152 ---------- 

1153 exposure : `lsst.afw.image.Exposure` 

1154 Exposure object to modify by attaching the product of all given 

1155 ``TransmissionCurves`` in post-assembly trimmed detector coordinates. 

1156 Must have a valid ``Detector`` attached that matches the detector 

1157 associated with sensorTransmission. 

1158 opticsTransmission : `lsst.afw.image.TransmissionCurve` 

1159 A ``TransmissionCurve`` that represents the throughput of the optics, 

1160 to be evaluated in focal-plane coordinates. 

1161 filterTransmission : `lsst.afw.image.TransmissionCurve` 

1162 A ``TransmissionCurve`` that represents the throughput of the filter 

1163 itself, to be evaluated in focal-plane coordinates. 

1164 sensorTransmission : `lsst.afw.image.TransmissionCurve` 

1165 A ``TransmissionCurve`` that represents the throughput of the sensor 

1166 itself, to be evaluated in post-assembly trimmed detector coordinates. 

1167 atmosphereTransmission : `lsst.afw.image.TransmissionCurve` 

1168 A ``TransmissionCurve`` that represents the throughput of the 

1169 atmosphere, assumed to be spatially constant. 

1170 

1171 Returns 

1172 ------- 

1173 combined : `lsst.afw.image.TransmissionCurve` 

1174 The TransmissionCurve attached to the exposure. 

1175 

1176 Notes 

1177 ----- 

1178 All ``TransmissionCurve`` arguments are optional; if none are provided, the 

1179 attached ``TransmissionCurve`` will have unit transmission everywhere. 

1180 """ 

1181 combined = afwImage.TransmissionCurve.makeIdentity() 

1182 if atmosphereTransmission is not None: 

1183 combined *= atmosphereTransmission 

1184 if opticsTransmission is not None: 

1185 combined *= opticsTransmission 

1186 if filterTransmission is not None: 

1187 combined *= filterTransmission 

1188 detector = exposure.getDetector() 

1189 fpToPix = detector.getTransform(fromSys=camGeom.FOCAL_PLANE, 

1190 toSys=camGeom.PIXELS) 

1191 combined = combined.transformedBy(fpToPix) 

1192 if sensorTransmission is not None: 

1193 combined *= sensorTransmission 

1194 exposure.getInfo().setTransmissionCurve(combined) 

1195 return combined 

1196 

1197 

1198def applyGains(exposure, normalizeGains=False, ptcGains=None, isTrimmed=True): 

1199 """Scale an exposure by the amplifier gains. 

1200 

1201 Parameters 

1202 ---------- 

1203 exposure : `lsst.afw.image.Exposure` 

1204 Exposure to process. The image is modified. 

1205 normalizeGains : `Bool`, optional 

1206 If True, then amplifiers are scaled to force the median of 

1207 each amplifier to equal the median of those medians. 

1208 ptcGains : `dict`[`str`], optional 

1209 Dictionary keyed by amp name containing the PTC gains. 

1210 isTrimmed : `bool`, optional 

1211 Is the input image trimmed? 

1212 """ 

1213 ccd = exposure.getDetector() 

1214 ccdImage = exposure.getMaskedImage() 

1215 

1216 medians = [] 

1217 for amp in ccd: 

1218 if isTrimmed: 

1219 sim = ccdImage.Factory(ccdImage, amp.getBBox()) 

1220 else: 

1221 sim = ccdImage.Factory(ccdImage, amp.getRawBBox()) 

1222 if ptcGains: 1222 ↛ 1225line 1222 didn't jump to line 1225 because the condition on line 1222 was always true

1223 sim *= ptcGains[amp.getName()] 

1224 else: 

1225 sim *= amp.getGain() 

1226 

1227 if normalizeGains: 1227 ↛ 1228line 1227 didn't jump to line 1228 because the condition on line 1227 was never true

1228 medians.append(numpy.median(sim.getImage().getArray())) 

1229 

1230 if normalizeGains: 1230 ↛ 1231line 1230 didn't jump to line 1231 because the condition on line 1230 was never true

1231 median = numpy.median(numpy.array(medians)) 

1232 for index, amp in enumerate(ccd): 

1233 if isTrimmed: 

1234 sim = ccdImage.Factory(ccdImage, amp.getBBox()) 

1235 else: 

1236 sim = ccdImage.Factory(ccdImage, amp.getRawBBox()) 

1237 if medians[index] != 0.0: 

1238 sim *= median/medians[index] 

1239 

1240 

1241def widenSaturationTrails(mask): 

1242 """Grow the saturation trails by an amount dependent on the width of the 

1243 trail. 

1244 

1245 Parameters 

1246 ---------- 

1247 mask : `lsst.afw.image.Mask` 

1248 Mask which will have the saturated areas grown. 

1249 """ 

1250 

1251 extraGrowDict = {} 

1252 for i in range(1, 6): 

1253 extraGrowDict[i] = 0 

1254 for i in range(6, 8): 

1255 extraGrowDict[i] = 1 

1256 for i in range(8, 10): 

1257 extraGrowDict[i] = 3 

1258 extraGrowMax = 4 

1259 

1260 if extraGrowMax <= 0: 1260 ↛ 1261line 1260 didn't jump to line 1261 because the condition on line 1260 was never true

1261 return 

1262 

1263 saturatedBit = mask.getPlaneBitMask("SAT") 

1264 

1265 xmin, ymin = mask.getBBox().getMin() 

1266 width = mask.getWidth() 

1267 

1268 thresh = afwDetection.Threshold(saturatedBit, afwDetection.Threshold.BITMASK) 

1269 fpList = afwDetection.FootprintSet(mask, thresh).getFootprints() 

1270 

1271 for fp in fpList: 1271 ↛ 1272line 1271 didn't jump to line 1272 because the loop on line 1271 never started

1272 for s in fp.getSpans(): 

1273 x0, x1 = s.getX0(), s.getX1() 

1274 

1275 extraGrow = extraGrowDict.get(x1 - x0 + 1, extraGrowMax) 

1276 if extraGrow > 0: 

1277 y = s.getY() - ymin 

1278 x0 -= xmin + extraGrow 

1279 x1 -= xmin - extraGrow 

1280 

1281 if x0 < 0: 

1282 x0 = 0 

1283 if x1 >= width - 1: 

1284 x1 = width - 1 

1285 

1286 mask.array[y, x0:x1+1] |= saturatedBit 

1287 

1288 

1289def setBadRegions(exposure, badStatistic="MEDIAN"): 

1290 """Set all BAD areas of the chip to the average of the rest of the exposure 

1291 

1292 Parameters 

1293 ---------- 

1294 exposure : `lsst.afw.image.Exposure` 

1295 Exposure to mask. The exposure mask is modified. 

1296 badStatistic : `str`, optional 

1297 Statistic to use to generate the replacement value from the 

1298 image data. Allowed values are 'MEDIAN' or 'MEANCLIP'. 

1299 

1300 Returns 

1301 ------- 

1302 badPixelCount : scalar 

1303 Number of bad pixels masked. 

1304 badPixelValue : scalar 

1305 Value substituted for bad pixels. 

1306 

1307 Raises 

1308 ------ 

1309 RuntimeError 

1310 Raised if `badStatistic` is not an allowed value. 

1311 """ 

1312 if badStatistic == "MEDIAN": 

1313 statistic = afwMath.MEDIAN 

1314 elif badStatistic == "MEANCLIP": 

1315 statistic = afwMath.MEANCLIP 

1316 else: 

1317 raise RuntimeError("Impossible method %s of bad region correction" % badStatistic) 

1318 

1319 mi = exposure.getMaskedImage() 

1320 mask = mi.getMask() 

1321 BAD = mask.getPlaneBitMask("BAD") 

1322 INTRP = mask.getPlaneBitMask("INTRP") 

1323 

1324 sctrl = afwMath.StatisticsControl() 

1325 sctrl.setAndMask(BAD) 

1326 value = afwMath.makeStatistics(mi, statistic, sctrl).getValue() 

1327 

1328 maskArray = mask.getArray() 

1329 imageArray = mi.getImage().getArray() 

1330 badPixels = numpy.logical_and((maskArray & BAD) > 0, (maskArray & INTRP) == 0) 

1331 imageArray[:] = numpy.where(badPixels, value, imageArray) 

1332 

1333 return badPixels.sum(), value 

1334 

1335 

1336def checkFilter(exposure, filterList, log): 

1337 """Check to see if an exposure is in a filter specified by a list. 

1338 

1339 The goal of this is to provide a unified filter checking interface 

1340 for all filter dependent stages. 

1341 

1342 Parameters 

1343 ---------- 

1344 exposure : `lsst.afw.image.Exposure` 

1345 Exposure to examine. 

1346 filterList : `list` [`str`] 

1347 List of physical_filter names to check. 

1348 log : `logging.Logger` 

1349 Logger to handle messages. 

1350 

1351 Returns 

1352 ------- 

1353 result : `bool` 

1354 True if the exposure's filter is contained in the list. 

1355 """ 

1356 if len(filterList) == 0: 

1357 return False 

1358 thisFilter = exposure.getFilter() 

1359 if thisFilter is None: 1359 ↛ 1360line 1359 didn't jump to line 1360 because the condition on line 1359 was never true

1360 log.warning("No FilterLabel attached to this exposure!") 

1361 return False 

1362 

1363 thisPhysicalFilter = getPhysicalFilter(thisFilter, log) 

1364 if thisPhysicalFilter in filterList: 1364 ↛ 1366line 1364 didn't jump to line 1366 because the condition on line 1364 was always true

1365 return True 

1366 elif thisFilter.bandLabel in filterList: 

1367 if log: 

1368 log.warning("Physical filter (%s) should be used instead of band %s for filter configurations" 

1369 " (%s)", thisPhysicalFilter, thisFilter.bandLabel, filterList) 

1370 return True 

1371 else: 

1372 return False 

1373 

1374 

1375def getPhysicalFilter(filterLabel, log): 

1376 """Get the physical filter label associated with the given filterLabel. 

1377 

1378 If ``filterLabel`` is `None` or there is no physicalLabel attribute 

1379 associated with the given ``filterLabel``, the returned label will be 

1380 "Unknown". 

1381 

1382 Parameters 

1383 ---------- 

1384 filterLabel : `lsst.afw.image.FilterLabel` 

1385 The `lsst.afw.image.FilterLabel` object from which to derive the 

1386 physical filter label. 

1387 log : `logging.Logger` 

1388 Logger to handle messages. 

1389 

1390 Returns 

1391 ------- 

1392 physicalFilter : `str` 

1393 The value returned by the physicalLabel attribute of ``filterLabel`` if 

1394 it exists, otherwise set to \"Unknown\". 

1395 """ 

1396 if filterLabel is None: 

1397 physicalFilter = "Unknown" 

1398 log.warning("filterLabel is None. Setting physicalFilter to \"Unknown\".") 

1399 else: 

1400 try: 

1401 physicalFilter = filterLabel.physicalLabel 

1402 except RuntimeError: 

1403 log.warning("filterLabel has no physicalLabel attribute. Setting physicalFilter to \"Unknown\".") 

1404 physicalFilter = "Unknown" 

1405 return physicalFilter 

1406 

1407 

1408def countMaskedPixels(maskedIm, maskPlane): 

1409 """Count the number of pixels in a given mask plane. 

1410 

1411 Parameters 

1412 ---------- 

1413 maskedIm : `~lsst.afw.image.MaskedImage` 

1414 Masked image to examine. 

1415 maskPlane : `str` 

1416 Name of the mask plane to examine. 

1417 

1418 Returns 

1419 ------- 

1420 nPix : `int` 

1421 Number of pixels in the requested mask plane. 

1422 """ 

1423 maskBit = maskedIm.mask.getPlaneBitMask(maskPlane) 

1424 nPix = numpy.where(numpy.bitwise_and(maskedIm.mask.array, maskBit))[0].flatten().size 

1425 return nPix 

1426 

1427 

1428def getExposureGains(exposure): 

1429 """Get the per-amplifier gains used for this exposure. 

1430 

1431 Parameters 

1432 ---------- 

1433 exposure : `lsst.afw.image.Exposure` 

1434 The exposure to find gains for. 

1435 

1436 Returns 

1437 ------- 

1438 gains : `dict` [`str` `float`] 

1439 Dictionary of gain values, keyed by amplifier name. 

1440 Returns empty dict when detector is None. 

1441 """ 

1442 det = exposure.getDetector() 

1443 if det is None: 1443 ↛ 1444line 1443 didn't jump to line 1444 because the condition on line 1443 was never true

1444 return dict() 

1445 

1446 metadata = exposure.getMetadata() 

1447 gains = {} 

1448 for amp in det: 

1449 ampName = amp.getName() 

1450 # The key may use the new LSST ISR or the old LSST prefix 

1451 if (key1 := f"LSST ISR GAIN {ampName}") in metadata: 

1452 gains[ampName] = metadata[key1] 

1453 elif (key2 := f"LSST GAIN {ampName}") in metadata: 

1454 gains[ampName] = metadata[key2] 

1455 else: 

1456 gains[ampName] = amp.getGain() 

1457 return gains 

1458 

1459 

1460def getExposureReadNoises(exposure): 

1461 """Get the per-amplifier read noise used for this exposure. 

1462 

1463 Parameters 

1464 ---------- 

1465 exposure : `lsst.afw.image.Exposure` 

1466 The exposure to find read noise for. 

1467 

1468 Returns 

1469 ------- 

1470 readnoises : `dict` [`str` `float`] 

1471 Dictionary of read noise values, keyed by amplifier name. 

1472 Returns empty dict when detector is None. 

1473 """ 

1474 det = exposure.getDetector() 

1475 if det is None: 1475 ↛ 1476line 1475 didn't jump to line 1476 because the condition on line 1475 was never true

1476 return dict() 

1477 

1478 metadata = exposure.getMetadata() 

1479 readnoises = {} 

1480 for amp in det: 

1481 ampName = amp.getName() 

1482 # The key may use the new LSST ISR or the old LSST prefix 

1483 if (key1 := f"LSST ISR READNOISE {ampName}") in metadata: 

1484 readnoises[ampName] = metadata[key1] 

1485 elif (key2 := f"LSST READNOISE {ampName}") in metadata: 

1486 readnoises[ampName] = metadata[key2] 

1487 else: 

1488 readnoises[ampName] = amp.getReadNoise() 

1489 return readnoises 

1490 

1491 

1492def isTrimmedExposure(exposure): 

1493 """Check if the unused pixels (pre-/over-scan pixels) have 

1494 been trimmed from an exposure. 

1495 

1496 Parameters 

1497 ---------- 

1498 exposure : `lsst.afw.image.Exposure` 

1499 The exposure to check. 

1500 

1501 Returns 

1502 ------- 

1503 result : `bool` 

1504 True if the image is trimmed, else False. 

1505 """ 

1506 return exposure.getDetector().getBBox() == exposure.getBBox() 

1507 

1508 

1509def isTrimmedImage(image, detector): 

1510 """Check if the unused pixels (pre-/over-scan pixels) have 

1511 been trimmed from an image 

1512 

1513 Parameters 

1514 ---------- 

1515 image : `lsst.afw.image.Image` 

1516 The image to check. 

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

1518 The detector associated with the image. 

1519 

1520 Returns 

1521 ------- 

1522 result : `bool` 

1523 True if the image is trimmed, else False. 

1524 """ 

1525 return detector.getBBox() == image.getBBox() 

1526 

1527 

1528def compareCameraKeywords( 

1529 doRaiseOnCalibMismatch, 

1530 cameraKeywordsToCompare, 

1531 exposureMetadata, 

1532 calib, 

1533 calibName, 

1534 log=None, 

1535): 

1536 """Compare header keywords to confirm camera states match. 

1537 

1538 Parameters 

1539 ---------- 

1540 doRaiseOnCalibMismatch : `bool` 

1541 Raise on calibration mismatch? Otherwise, log a warning. 

1542 cameraKeywordsToCompare : `list` [`str`] 

1543 List of camera keywords to compare. 

1544 exposureMetadata : `lsst.daf.base.PropertyList` 

1545 Header for the exposure being processed. 

1546 calib : `lsst.afw.image.Exposure` or `lsst.ip.isr.IsrCalib` 

1547 Calibration to be applied. 

1548 calibName : `str` 

1549 Calib type for log message. 

1550 log : `logging.Logger`, optional 

1551 Logger to handle messages. 

1552 """ 

1553 try: 

1554 calibMetadata = calib.metadata 

1555 except AttributeError: 

1556 return 

1557 

1558 log = log if log else logging.getLogger(__name__) 

1559 

1560 missingKeywords = [] 

1561 for keyword in cameraKeywordsToCompare: 

1562 exposureValue = exposureMetadata.get(keyword, None) 

1563 if exposureValue is None: 

1564 log.debug("Sequencer keyword %s not found in exposure metadata.", keyword) 

1565 continue 

1566 

1567 calibValue = calibMetadata.get(keyword, None) 

1568 

1569 # We don't log here if there is a missing keyword. 

1570 if calibValue is None: 

1571 missingKeywords.append(keyword) 

1572 continue 

1573 

1574 if exposureValue != calibValue: 

1575 if doRaiseOnCalibMismatch: 

1576 raise RuntimeError( 

1577 "Sequencer mismatch for %s [%s]: exposure: %s calib: %s", 

1578 calibName, 

1579 keyword, 

1580 exposureValue, 

1581 calibValue, 

1582 ) 

1583 else: 

1584 log.warning( 

1585 "Sequencer mismatch for %s [%s]: exposure: %s calib: %s", 

1586 calibName, 

1587 keyword, 

1588 exposureValue, 

1589 calibValue, 

1590 ) 

1591 exposureMetadata[f"ISR {calibName.upper()} SEQUENCER MISMATCH"] = True 

1592 

1593 if missingKeywords: 

1594 log.info( 

1595 "Calibration %s missing keywords %s, which were not checked.", 

1596 calibName, 

1597 ",".join(missingKeywords), 

1598 ) 

1599 

1600 

1601def symmetrize(inputArray): 

1602 """ Copy array over 4 quadrants prior to convolution. 

1603 

1604 Parameters 

1605 ---------- 

1606 inputarray : `numpy.array` 

1607 Input array to symmetrize. 

1608 

1609 Returns 

1610 ------- 

1611 aSym : `numpy.array` 

1612 Symmetrized array. 

1613 """ 

1614 targetShape = list(inputArray.shape) 

1615 r1, r2 = inputArray.shape[-1], inputArray.shape[-2] 

1616 targetShape[-1] = 2*r1-1 

1617 targetShape[-2] = 2*r2-1 

1618 aSym = numpy.ndarray(tuple(targetShape)) 

1619 aSym[..., r2-1:, r1-1:] = inputArray 

1620 aSym[..., r2-1:, r1-1::-1] = inputArray 

1621 aSym[..., r2-1::-1, r1-1::-1] = inputArray 

1622 aSym[..., r2-1::-1, r1-1:] = inputArray 

1623 

1624 return aSym