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

360 statements  

« prev     ^ index     » next       coverage.py v7.2.5, created at 2023-05-10 02:12 -0700

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 "brighterFatterCorrection", 

28 "checkFilter", 

29 "countMaskedPixels", 

30 "createPsf", 

31 "darkCorrection", 

32 "flatCorrection", 

33 "fluxConservingBrighterFatterCorrection", 

34 "gainContext", 

35 "getPhysicalFilter", 

36 "growMasks", 

37 "illuminationCorrection", 

38 "interpolateDefectList", 

39 "interpolateFromMask", 

40 "makeThresholdMask", 

41 "saturationCorrection", 

42 "setBadRegions", 

43 "transferFlux", 

44 "transposeMaskedImage", 

45 "trimToMatchCalibBBox", 

46 "updateVariance", 

47 "widenSaturationTrails", 

48] 

49 

50import math 

51import numpy 

52 

53import lsst.geom 

54import lsst.afw.image as afwImage 

55import lsst.afw.detection as afwDetection 

56import lsst.afw.math as afwMath 

57import lsst.meas.algorithms as measAlg 

58import lsst.afw.cameraGeom as camGeom 

59 

60from lsst.meas.algorithms.detection import SourceDetectionTask 

61 

62from contextlib import contextmanager 

63 

64from .defects import Defects 

65 

66 

67def createPsf(fwhm): 

68 """Make a double Gaussian PSF. 

69 

70 Parameters 

71 ---------- 

72 fwhm : scalar 

73 FWHM of double Gaussian smoothing kernel. 

74 

75 Returns 

76 ------- 

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

78 The created smoothing kernel. 

79 """ 

80 ksize = 4*int(fwhm) + 1 

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

82 

83 

84def transposeMaskedImage(maskedImage): 

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

86 

87 Parameters 

88 ---------- 

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

90 Image to process. 

91 

92 Returns 

93 ------- 

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

95 The transposed copy of the input image. 

96 """ 

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

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

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

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

101 return transposed 

102 

103 

104def interpolateDefectList(maskedImage, defectList, fwhm, fallbackValue=None): 

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

106 

107 Parameters 

108 ---------- 

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

110 Image to process. 

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

112 List of defects to interpolate over. 

113 fwhm : scalar 

114 FWHM of double Gaussian smoothing kernel. 

115 fallbackValue : scalar, optional 

116 Fallback value if an interpolated value cannot be determined. 

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

118 """ 

119 psf = createPsf(fwhm) 

120 if fallbackValue is None: 

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

122 if 'INTRP' not in maskedImage.getMask().getMaskPlaneDict(): 

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

124 measAlg.interpolateOverDefects(maskedImage, psf, defectList, fallbackValue, True) 

125 return maskedImage 

126 

127 

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

129 """Mask pixels based on threshold detection. 

130 

131 Parameters 

132 ---------- 

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

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

135 threshold : scalar 

136 Detection threshold. 

137 growFootprints : scalar, optional 

138 Number of pixels to grow footprints of detected regions. 

139 maskName : str, optional 

140 Mask plane name, or list of names to convert 

141 

142 Returns 

143 ------- 

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

145 Defect list constructed from pixels above the threshold. 

146 """ 

147 # find saturated regions 

148 thresh = afwDetection.Threshold(threshold) 

149 fs = afwDetection.FootprintSet(maskedImage, thresh) 

150 

151 if growFootprints > 0: 

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

153 fpList = fs.getFootprints() 

154 

155 # set mask 

156 mask = maskedImage.getMask() 

157 bitmask = mask.getPlaneBitMask(maskName) 

158 afwDetection.setMaskFromFootprintList(mask, fpList, bitmask) 

159 

160 return Defects.fromFootprintList(fpList) 

161 

162 

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

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

165 

166 Parameters 

167 ---------- 

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

169 Mask image to process. 

170 radius : scalar 

171 Amount to grow the mask. 

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

173 Mask names that should be grown. 

174 maskValue : `str` 

175 Mask plane to assign the newly masked pixels to. 

176 """ 

177 if radius > 0: 

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

179 fpSet = afwDetection.FootprintSet(mask, thresh) 

180 fpSet = afwDetection.FootprintSet(fpSet, rGrow=radius, isotropic=False) 

181 fpSet.setMask(mask, maskValue) 

182 

183 

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

185 maskNameList=['SAT'], fallbackValue=None): 

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

187 

188 Parameters 

189 ---------- 

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

191 Image to process. 

192 fwhm : scalar 

193 FWHM of double Gaussian smoothing kernel. 

194 growSaturatedFootprints : scalar, optional 

195 Number of pixels to grow footprints for saturated pixels. 

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

197 Mask plane name. 

198 fallbackValue : scalar, optional 

199 Value of last resort for interpolation. 

200 """ 

201 mask = maskedImage.getMask() 

202 

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

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

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

206 # explain why we interpolated there. 

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

208 

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

210 fpSet = afwDetection.FootprintSet(mask, thresh) 

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

212 

213 interpolateDefectList(maskedImage, defectList, fwhm, fallbackValue=fallbackValue) 

214 

215 return maskedImage 

216 

217 

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

219 fallbackValue=None): 

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

221 

222 Parameters 

223 ---------- 

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

225 Image to process. 

226 saturation : scalar 

227 Saturation level used as the detection threshold. 

228 fwhm : scalar 

229 FWHM of double Gaussian smoothing kernel. 

230 growFootprints : scalar, optional 

231 Number of pixels to grow footprints of detected regions. 

232 interpolate : Bool, optional 

233 If True, saturated pixels are interpolated over. 

234 maskName : str, optional 

235 Mask plane name. 

236 fallbackValue : scalar, optional 

237 Value of last resort for interpolation. 

238 """ 

239 defectList = makeThresholdMask( 

240 maskedImage=maskedImage, 

241 threshold=saturation, 

242 growFootprints=growFootprints, 

243 maskName=maskName, 

244 ) 

245 if interpolate: 

246 interpolateDefectList(maskedImage, defectList, fwhm, fallbackValue=fallbackValue) 

247 

248 return maskedImage 

249 

250 

251def trimToMatchCalibBBox(rawMaskedImage, calibMaskedImage): 

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

253 

254 Use the dimension difference between the raw exposure and the 

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

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

257 each side. 

258 

259 Parameters 

260 ---------- 

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

262 Image to trim. 

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

264 Calibration image to draw new bounding box from. 

265 

266 Returns 

267 ------- 

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

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

270 

271 Raises 

272 ------ 

273 RuntimeError 

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

275 match ``calibMaskedImage``. 

276 """ 

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

278 if nx != ny: 

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

280 if nx % 2 != 0: 

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

282 if nx < 0: 

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

284 

285 nEdge = nx//2 

286 if nEdge > 0: 

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

288 SourceDetectionTask.setEdgeBits( 

289 rawMaskedImage, 

290 replacementMaskedImage.getBBox(), 

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

292 ) 

293 else: 

294 replacementMaskedImage = rawMaskedImage 

295 

296 return replacementMaskedImage 

297 

298 

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

300 """Apply bias correction in place. 

301 

302 Parameters 

303 ---------- 

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

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

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

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

308 trimToFit : `Bool`, optional 

309 If True, raw data is symmetrically trimmed to match 

310 calibration size. 

311 

312 Raises 

313 ------ 

314 RuntimeError 

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

316 the same size. 

317 

318 """ 

319 if trimToFit: 

320 maskedImage = trimToMatchCalibBBox(maskedImage, biasMaskedImage) 

321 

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

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

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

325 maskedImage -= biasMaskedImage 

326 

327 

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

329 """Apply dark correction in place. 

330 

331 Parameters 

332 ---------- 

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

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

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

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

337 expScale : scalar 

338 Dark exposure time for ``maskedImage``. 

339 darkScale : scalar 

340 Dark exposure time for ``darkMaskedImage``. 

341 invert : `Bool`, optional 

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

343 trimToFit : `Bool`, optional 

344 If True, raw data is symmetrically trimmed to match 

345 calibration size. 

346 

347 Raises 

348 ------ 

349 RuntimeError 

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

351 the same size. 

352 

353 Notes 

354 ----- 

355 The dark correction is applied by calculating: 

356 maskedImage -= dark * expScaling / darkScaling 

357 """ 

358 if trimToFit: 

359 maskedImage = trimToMatchCalibBBox(maskedImage, darkMaskedImage) 

360 

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

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

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

364 

365 scale = expScale / darkScale 

366 if not invert: 

367 maskedImage.scaledMinus(scale, darkMaskedImage) 

368 else: 

369 maskedImage.scaledPlus(scale, darkMaskedImage) 

370 

371 

372def updateVariance(maskedImage, gain, readNoise): 

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

374 

375 Parameters 

376 ---------- 

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

378 Image to process. The variance plane is modified. 

379 gain : scalar 

380 The amplifier gain in electrons/ADU. 

381 readNoise : scalar 

382 The amplifier read nmoise in ADU/pixel. 

383 """ 

384 var = maskedImage.getVariance() 

385 var[:] = maskedImage.getImage() 

386 var /= gain 

387 var += readNoise**2 

388 

389 

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

391 """Apply flat correction in place. 

392 

393 Parameters 

394 ---------- 

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

396 Image to process. The image is modified. 

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

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

399 scalingType : str 

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

401 'MEDIAN', or 'USER'. 

402 userScale : scalar, optional 

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

404 invert : `Bool`, optional 

405 If True, unflatten an already flattened image. 

406 trimToFit : `Bool`, optional 

407 If True, raw data is symmetrically trimmed to match 

408 calibration size. 

409 

410 Raises 

411 ------ 

412 RuntimeError 

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

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

415 """ 

416 if trimToFit: 

417 maskedImage = trimToMatchCalibBBox(maskedImage, flatMaskedImage) 

418 

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

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

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

422 

423 # Figure out scale from the data 

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

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

426 # some other mechanism. 

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

428 scalingType = afwMath.stringToStatisticsProperty(scalingType) 

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

430 elif scalingType == 'USER': 

431 flatScale = userScale 

432 else: 

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

434 

435 if not invert: 

436 maskedImage.scaledDivides(1.0/flatScale, flatMaskedImage) 

437 else: 

438 maskedImage.scaledMultiplies(1.0/flatScale, flatMaskedImage) 

439 

440 

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

442 """Apply illumination correction in place. 

443 

444 Parameters 

445 ---------- 

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

447 Image to process. The image is modified. 

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

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

450 illumScale : scalar 

451 Scale factor for the illumination correction. 

452 trimToFit : `Bool`, optional 

453 If True, raw data is symmetrically trimmed to match 

454 calibration size. 

455 

456 Raises 

457 ------ 

458 RuntimeError 

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

460 the same size. 

461 """ 

462 if trimToFit: 

463 maskedImage = trimToMatchCalibBBox(maskedImage, illumMaskedImage) 

464 

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

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

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

468 

469 maskedImage.scaledDivides(1.0/illumScale, illumMaskedImage) 

470 

471 

472def brighterFatterCorrection(exposure, kernel, maxIter, threshold, applyGain, gains=None): 

473 """Apply brighter fatter correction in place for the image. 

474 

475 Parameters 

476 ---------- 

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

478 Exposure to have brighter-fatter correction applied. Modified 

479 by this method. 

480 kernel : `numpy.ndarray` 

481 Brighter-fatter kernel to apply. 

482 maxIter : scalar 

483 Number of correction iterations to run. 

484 threshold : scalar 

485 Convergence threshold in terms of the sum of absolute 

486 deviations between an iteration and the previous one. 

487 applyGain : `Bool` 

488 If True, then the exposure values are scaled by the gain prior 

489 to correction. 

490 gains : `dict` [`str`, `float`] 

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

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

493 

494 Returns 

495 ------- 

496 diff : `float` 

497 Final difference between iterations achieved in correction. 

498 iteration : `int` 

499 Number of iterations used to calculate correction. 

500 

501 Notes 

502 ----- 

503 This correction takes a kernel that has been derived from flat 

504 field images to redistribute the charge. The gradient of the 

505 kernel is the deflection field due to the accumulated charge. 

506 

507 Given the original image I(x) and the kernel K(x) we can compute 

508 the corrected image Ic(x) using the following equation: 

509 

510 Ic(x) = I(x) + 0.5*d/dx(I(x)*d/dx(int( dy*K(x-y)*I(y)))) 

511 

512 To evaluate the derivative term we expand it as follows: 

513 

514 0.5 * ( d/dx(I(x))*d/dx(int(dy*K(x-y)*I(y))) 

515 + I(x)*d^2/dx^2(int(dy* K(x-y)*I(y))) ) 

516 

517 Because we use the measured counts instead of the incident counts 

518 we apply the correction iteratively to reconstruct the original 

519 counts and the correction. We stop iterating when the summed 

520 difference between the current corrected image and the one from 

521 the previous iteration is below the threshold. We do not require 

522 convergence because the number of iterations is too large a 

523 computational cost. How we define the threshold still needs to be 

524 evaluated, the current default was shown to work reasonably well 

525 on a small set of images. For more information on the method see 

526 DocuShare Document-19407. 

527 

528 The edges as defined by the kernel are not corrected because they 

529 have spurious values due to the convolution. 

530 """ 

531 image = exposure.getMaskedImage().getImage() 

532 

533 # The image needs to be units of electrons/holes 

534 with gainContext(exposure, image, applyGain, gains): 

535 

536 kLx = numpy.shape(kernel)[0] 

537 kLy = numpy.shape(kernel)[1] 

538 kernelImage = afwImage.ImageD(kLx, kLy) 

539 kernelImage.getArray()[:, :] = kernel 

540 tempImage = image.clone() 

541 

542 nanIndex = numpy.isnan(tempImage.getArray()) 

543 tempImage.getArray()[nanIndex] = 0. 

544 

545 outImage = afwImage.ImageF(image.getDimensions()) 

546 corr = numpy.zeros_like(image.getArray()) 

547 prev_image = numpy.zeros_like(image.getArray()) 

548 convCntrl = afwMath.ConvolutionControl(False, True, 1) 

549 fixedKernel = afwMath.FixedKernel(kernelImage) 

550 

551 # Define boundary by convolution region. The region that the 

552 # correction will be calculated for is one fewer in each dimension 

553 # because of the second derivative terms. 

554 # NOTE: these need to use integer math, as we're using start:end as 

555 # numpy index ranges. 

556 startX = kLx//2 

557 endX = -kLx//2 

558 startY = kLy//2 

559 endY = -kLy//2 

560 

561 for iteration in range(maxIter): 

562 

563 afwMath.convolve(outImage, tempImage, fixedKernel, convCntrl) 

564 tmpArray = tempImage.getArray() 

565 outArray = outImage.getArray() 

566 

567 with numpy.errstate(invalid="ignore", over="ignore"): 

568 # First derivative term 

569 gradTmp = numpy.gradient(tmpArray[startY:endY, startX:endX]) 

570 gradOut = numpy.gradient(outArray[startY:endY, startX:endX]) 

571 first = (gradTmp[0]*gradOut[0] + gradTmp[1]*gradOut[1])[1:-1, 1:-1] 

572 

573 # Second derivative term 

574 diffOut20 = numpy.diff(outArray, 2, 0)[startY:endY, startX + 1:endX - 1] 

575 diffOut21 = numpy.diff(outArray, 2, 1)[startY + 1:endY - 1, startX:endX] 

576 second = tmpArray[startY + 1:endY - 1, startX + 1:endX - 1]*(diffOut20 + diffOut21) 

577 

578 corr[startY + 1:endY - 1, startX + 1:endX - 1] = 0.5*(first + second) 

579 

580 tmpArray[:, :] = image.getArray()[:, :] 

581 tmpArray[nanIndex] = 0. 

582 tmpArray[startY:endY, startX:endX] += corr[startY:endY, startX:endX] 

583 

584 if iteration > 0: 

585 diff = numpy.sum(numpy.abs(prev_image - tmpArray)) 

586 

587 if diff < threshold: 

588 break 

589 prev_image[:, :] = tmpArray[:, :] 

590 

591 image.getArray()[startY + 1:endY - 1, startX + 1:endX - 1] += \ 

592 corr[startY + 1:endY - 1, startX + 1:endX - 1] 

593 

594 return diff, iteration 

595 

596 

597def transferFlux(cFunc, fStep, correctionMode=True): 

598 """Take the input convolved deflection potential and the flux array 

599 to compute and apply the flux transfer into the correction array. 

600 

601 Parameters 

602 ---------- 

603 cFunc: `numpy.array` 

604 Deflection potential, being the convolution of the flux F with the 

605 kernel K. 

606 fStep: `numpy.array` 

607 The array of flux values which act as the source of the flux transfer. 

608 correctionMode: `bool` 

609 Defines if applying correction (True) or generating sims (False). 

610 

611 Returns 

612 ------- 

613 corr: 

614 BFE correction array 

615 """ 

616 

617 if cFunc.shape != fStep.shape: 

618 raise RuntimeError(f'transferFlux: array shapes do not match: {cFunc.shape}, {fStep.shape}') 

619 

620 # set the sign of the correction and set its value for the 

621 # time averaged solution 

622 if correctionMode: 

623 # negative sign if applying BFE correction 

624 factor = -0.5 

625 else: 

626 # positive sign if generating BFE simulations 

627 factor = 0.5 

628 

629 # initialise the BFE correction image to zero 

630 corr = numpy.zeros_like(cFunc) 

631 

632 # Generate a 2D mesh of x,y coordinates 

633 yDim, xDim = cFunc.shape 

634 y = numpy.arange(yDim, dtype=int) 

635 x = numpy.arange(xDim, dtype=int) 

636 xc, yc = numpy.meshgrid(x, y) 

637 

638 # process each axis in turn 

639 for ax in [0, 1]: 

640 

641 # gradient of phi on right/upper edge of pixel 

642 diff = numpy.diff(cFunc, axis=ax) 

643 

644 # expand array back to full size with zero gradient at the end 

645 gx = numpy.zeros_like(cFunc) 

646 yDiff, xDiff = diff.shape 

647 gx[:yDiff, :xDiff] += diff 

648 

649 # select pixels with either positive gradients on the right edge, 

650 # flux flowing to the right/up 

651 # or negative gradients, flux flowing to the left/down 

652 for i, sel in enumerate([gx > 0, gx < 0]): 

653 xSelPixels = xc[sel] 

654 ySelPixels = yc[sel] 

655 # and add the flux into the pixel to the right or top 

656 # depending on which axis we are handling 

657 if ax == 0: 

658 xPix = xSelPixels 

659 yPix = ySelPixels+1 

660 else: 

661 xPix = xSelPixels+1 

662 yPix = ySelPixels 

663 # define flux as the either current pixel value or pixel 

664 # above/right 

665 # depending on whether positive or negative gradient 

666 if i == 0: 

667 # positive gradients, flux flowing to higher coordinate values 

668 flux = factor * fStep[sel]*gx[sel] 

669 else: 

670 # negative gradients, flux flowing to lower coordinate values 

671 flux = factor * fStep[yPix, xPix]*gx[sel] 

672 # change the fluxes of the donor and receiving pixels 

673 # such that flux is conserved 

674 corr[sel] -= flux 

675 corr[yPix, xPix] += flux 

676 

677 # return correction array 

678 return corr 

679 

680 

681def fluxConservingBrighterFatterCorrection(exposure, kernel, maxIter, threshold, applyGain, 

682 gains=None, correctionMode=True): 

683 """Apply brighter fatter correction in place for the image. 

684 

685 This version presents a modified version of the algorithm 

686 found in ``lsst.ip.isr.isrFunctions.brighterFatterCorrection`` 

687 which conserves the image flux, resulting in improved 

688 correction of the cores of stars. The convolution has also been 

689 modified to mitigate edge effects. 

690 

691 Parameters 

692 ---------- 

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

694 Exposure to have brighter-fatter correction applied. Modified 

695 by this method. 

696 kernel : `numpy.ndarray` 

697 Brighter-fatter kernel to apply. 

698 maxIter : scalar 

699 Number of correction iterations to run. 

700 threshold : scalar 

701 Convergence threshold in terms of the sum of absolute 

702 deviations between an iteration and the previous one. 

703 applyGain : `Bool` 

704 If True, then the exposure values are scaled by the gain prior 

705 to correction. 

706 gains : `dict` [`str`, `float`] 

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

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

709 correctionMode : `Bool` 

710 If True (default) the function applies correction for BFE. If False, 

711 the code can instead be used to generate a simulation of BFE (sign 

712 change in the direction of the effect) 

713 

714 Returns 

715 ------- 

716 diff : `float` 

717 Final difference between iterations achieved in correction. 

718 iteration : `int` 

719 Number of iterations used to calculate correction. 

720 

721 Notes 

722 ----- 

723 Modified version of ``lsst.ip.isr.isrFunctions.brighterFatterCorrection``. 

724 

725 This correction takes a kernel that has been derived from flat 

726 field images to redistribute the charge. The gradient of the 

727 kernel is the deflection field due to the accumulated charge. 

728 

729 Given the original image I(x) and the kernel K(x) we can compute 

730 the corrected image Ic(x) using the following equation: 

731 

732 Ic(x) = I(x) + 0.5*d/dx(I(x)*d/dx(int( dy*K(x-y)*I(y)))) 

733 

734 Improved algorithm at this step applies the divergence theorem to 

735 obtain a pixelised correction. 

736 

737 Because we use the measured counts instead of the incident counts 

738 we apply the correction iteratively to reconstruct the original 

739 counts and the correction. We stop iterating when the summed 

740 difference between the current corrected image and the one from 

741 the previous iteration is below the threshold. We do not require 

742 convergence because the number of iterations is too large a 

743 computational cost. How we define the threshold still needs to be 

744 evaluated, the current default was shown to work reasonably well 

745 on a small set of images. 

746 

747 Edges are handled in the convolution by padding. This is still not 

748 a physical model for the edge, but avoids discontinuity in the correction. 

749 

750 Author of modified version: Lance.Miller@physics.ox.ac.uk 

751 (see DM-38555). 

752 """ 

753 image = exposure.getMaskedImage().getImage() 

754 

755 # The image needs to be units of electrons/holes 

756 with gainContext(exposure, image, applyGain, gains): 

757 

758 # get kernel and its shape 

759 kLy, kLx = kernel.shape 

760 kernelImage = afwImage.ImageD(kLx, kLy) 

761 kernelImage.getArray()[:, :] = kernel 

762 tempImage = image.clone() 

763 

764 nanIndex = numpy.isnan(tempImage.getArray()) 

765 tempImage.getArray()[nanIndex] = 0. 

766 

767 outImage = afwImage.ImageF(image.getDimensions()) 

768 corr = numpy.zeros_like(image.getArray()) 

769 prevImage = numpy.zeros_like(image.getArray()) 

770 convCntrl = afwMath.ConvolutionControl(False, True, 1) 

771 fixedKernel = afwMath.FixedKernel(kernelImage) 

772 

773 # set the padding amount 

774 # ensure we pad by an even amount larger than the kernel 

775 kLy = 2 * ((1+kLy)//2) 

776 kLx = 2 * ((1+kLx)//2) 

777 

778 # The deflection potential only depends on the gradient of 

779 # the convolution, so we can subtract the mean, which then 

780 # allows us to pad the image with zeros and avoid wrap-around effects 

781 # (although still not handling the image edges with a physical model) 

782 # This wouldn't be great if there were a strong image gradient. 

783 imYdimension, imXdimension = tempImage.array.shape 

784 imean = numpy.mean(tempImage.getArray()[~nanIndex]) 

785 # subtract mean from image 

786 tempImage -= imean 

787 tempImage.array[nanIndex] = 0. 

788 padArray = numpy.pad(tempImage.getArray(), ((0, kLy), (0, kLx))) 

789 outImage = afwImage.ImageF(numpy.pad(outImage.getArray(), ((0, kLy), (0, kLx)))) 

790 # Convert array to afw image so afwMath.convolve works 

791 padImage = afwImage.ImageF(padArray.shape[1], padArray.shape[0]) 

792 padImage.array[:] = padArray 

793 

794 for iteration in range(maxIter): 

795 

796 # create deflection potential, convolution of flux with kernel 

797 # using padded counts array 

798 afwMath.convolve(outImage, padImage, fixedKernel, convCntrl) 

799 tmpArray = tempImage.getArray() 

800 outArray = outImage.getArray() 

801 

802 # trim convolution output back to original shape 

803 outArray = outArray[:imYdimension, :imXdimension] 

804 

805 # generate the correction array, with correctionMode set as input 

806 corr[...] = transferFlux(outArray, tmpArray, correctionMode=correctionMode) 

807 

808 # update the arrays for the next iteration 

809 tmpArray[:, :] = image.getArray()[:, :] 

810 tmpArray += corr 

811 tmpArray[nanIndex] = 0. 

812 # update padded array 

813 # subtract mean 

814 tmpArray -= imean 

815 tempImage.array[nanIndex] = 0. 

816 padArray = numpy.pad(tempImage.getArray(), ((0, kLy), (0, kLx))) 

817 

818 if iteration > 0: 

819 diff = numpy.sum(numpy.abs(prevImage - tmpArray)) 

820 

821 if diff < threshold: 

822 break 

823 prevImage[:, :] = tmpArray[:, :] 

824 

825 image.getArray()[:] += corr[:] 

826 

827 return diff, iteration 

828 

829 

830@contextmanager 

831def gainContext(exp, image, apply, gains=None): 

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

833 

834 Parameters 

835 ---------- 

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

837 Exposure to apply/remove gain. 

838 image : `lsst.afw.image.Image` 

839 Image to apply/remove gain. 

840 apply : `Bool` 

841 If True, apply and remove the amplifier gain. 

842 gains : `dict` [`str`, `float`] 

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

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

845 

846 Yields 

847 ------ 

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

849 Exposure with the gain applied. 

850 """ 

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

852 # be a real mess 

853 if gains and apply is True: 

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

855 for ampName in ampNames: 

856 if ampName not in gains.keys(): 

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

858 

859 if apply: 

860 ccd = exp.getDetector() 

861 for amp in ccd: 

862 sim = image.Factory(image, amp.getBBox()) 

863 if gains: 

864 gain = gains[amp.getName()] 

865 else: 

866 gain = amp.getGain() 

867 sim *= gain 

868 

869 try: 

870 yield exp 

871 finally: 

872 if apply: 

873 ccd = exp.getDetector() 

874 for amp in ccd: 

875 sim = image.Factory(image, amp.getBBox()) 

876 if gains: 

877 gain = gains[amp.getName()] 

878 else: 

879 gain = amp.getGain() 

880 sim /= gain 

881 

882 

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

884 sensorTransmission=None, atmosphereTransmission=None): 

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

886 different components. 

887 

888 Parameters 

889 ---------- 

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

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

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

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

894 associated with sensorTransmission. 

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

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

897 to be evaluated in focal-plane coordinates. 

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

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

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

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

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

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

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

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

906 atmosphere, assumed to be spatially constant. 

907 

908 Returns 

909 ------- 

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

911 The TransmissionCurve attached to the exposure. 

912 

913 Notes 

914 ----- 

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

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

917 """ 

918 combined = afwImage.TransmissionCurve.makeIdentity() 

919 if atmosphereTransmission is not None: 

920 combined *= atmosphereTransmission 

921 if opticsTransmission is not None: 

922 combined *= opticsTransmission 

923 if filterTransmission is not None: 

924 combined *= filterTransmission 

925 detector = exposure.getDetector() 

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

927 toSys=camGeom.PIXELS) 

928 combined = combined.transformedBy(fpToPix) 

929 if sensorTransmission is not None: 

930 combined *= sensorTransmission 

931 exposure.getInfo().setTransmissionCurve(combined) 

932 return combined 

933 

934 

935def applyGains(exposure, normalizeGains=False, ptcGains=None): 

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

937 

938 Parameters 

939 ---------- 

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

941 Exposure to process. The image is modified. 

942 normalizeGains : `Bool`, optional 

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

944 each amplifier to equal the median of those medians. 

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

946 Dictionary keyed by amp name containing the PTC gains. 

947 """ 

948 ccd = exposure.getDetector() 

949 ccdImage = exposure.getMaskedImage() 

950 

951 medians = [] 

952 for amp in ccd: 

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

954 if ptcGains: 

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

956 else: 

957 sim *= amp.getGain() 

958 

959 if normalizeGains: 

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

961 

962 if normalizeGains: 

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

964 for index, amp in enumerate(ccd): 

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

966 if medians[index] != 0.0: 

967 sim *= median/medians[index] 

968 

969 

970def widenSaturationTrails(mask): 

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

972 trail. 

973 

974 Parameters 

975 ---------- 

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

977 Mask which will have the saturated areas grown. 

978 """ 

979 

980 extraGrowDict = {} 

981 for i in range(1, 6): 

982 extraGrowDict[i] = 0 

983 for i in range(6, 8): 

984 extraGrowDict[i] = 1 

985 for i in range(8, 10): 

986 extraGrowDict[i] = 3 

987 extraGrowMax = 4 

988 

989 if extraGrowMax <= 0: 

990 return 

991 

992 saturatedBit = mask.getPlaneBitMask("SAT") 

993 

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

995 width = mask.getWidth() 

996 

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

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

999 

1000 for fp in fpList: 

1001 for s in fp.getSpans(): 

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

1003 

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

1005 if extraGrow > 0: 

1006 y = s.getY() - ymin 

1007 x0 -= xmin + extraGrow 

1008 x1 -= xmin - extraGrow 

1009 

1010 if x0 < 0: 

1011 x0 = 0 

1012 if x1 >= width - 1: 

1013 x1 = width - 1 

1014 

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

1016 

1017 

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

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

1020 

1021 Parameters 

1022 ---------- 

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

1024 Exposure to mask. The exposure mask is modified. 

1025 badStatistic : `str`, optional 

1026 Statistic to use to generate the replacement value from the 

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

1028 

1029 Returns 

1030 ------- 

1031 badPixelCount : scalar 

1032 Number of bad pixels masked. 

1033 badPixelValue : scalar 

1034 Value substituted for bad pixels. 

1035 

1036 Raises 

1037 ------ 

1038 RuntimeError 

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

1040 """ 

1041 if badStatistic == "MEDIAN": 

1042 statistic = afwMath.MEDIAN 

1043 elif badStatistic == "MEANCLIP": 

1044 statistic = afwMath.MEANCLIP 

1045 else: 

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

1047 

1048 mi = exposure.getMaskedImage() 

1049 mask = mi.getMask() 

1050 BAD = mask.getPlaneBitMask("BAD") 

1051 INTRP = mask.getPlaneBitMask("INTRP") 

1052 

1053 sctrl = afwMath.StatisticsControl() 

1054 sctrl.setAndMask(BAD) 

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

1056 

1057 maskArray = mask.getArray() 

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

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

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

1061 

1062 return badPixels.sum(), value 

1063 

1064 

1065def checkFilter(exposure, filterList, log): 

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

1067 

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

1069 for all filter dependent stages. 

1070 

1071 Parameters 

1072 ---------- 

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

1074 Exposure to examine. 

1075 filterList : `list` [`str`] 

1076 List of physical_filter names to check. 

1077 log : `logging.Logger` 

1078 Logger to handle messages. 

1079 

1080 Returns 

1081 ------- 

1082 result : `bool` 

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

1084 """ 

1085 if len(filterList) == 0: 

1086 return False 

1087 thisFilter = exposure.getFilter() 

1088 if thisFilter is None: 

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

1090 return False 

1091 

1092 thisPhysicalFilter = getPhysicalFilter(thisFilter, log) 

1093 if thisPhysicalFilter in filterList: 

1094 return True 

1095 elif thisFilter.bandLabel in filterList: 

1096 if log: 

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

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

1099 return True 

1100 else: 

1101 return False 

1102 

1103 

1104def getPhysicalFilter(filterLabel, log): 

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

1106 

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

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

1109 "Unknown". 

1110 

1111 Parameters 

1112 ---------- 

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

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

1115 physical filter label. 

1116 log : `logging.Logger` 

1117 Logger to handle messages. 

1118 

1119 Returns 

1120 ------- 

1121 physicalFilter : `str` 

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

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

1124 """ 

1125 if filterLabel is None: 

1126 physicalFilter = "Unknown" 

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

1128 else: 

1129 try: 

1130 physicalFilter = filterLabel.physicalLabel 

1131 except RuntimeError: 

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

1133 physicalFilter = "Unknown" 

1134 return physicalFilter 

1135 

1136 

1137def countMaskedPixels(maskedIm, maskPlane): 

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

1139 

1140 Parameters 

1141 ---------- 

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

1143 Masked image to examine. 

1144 maskPlane : `str` 

1145 Name of the mask plane to examine. 

1146 

1147 Returns 

1148 ------- 

1149 nPix : `int` 

1150 Number of pixels in the requested mask plane. 

1151 """ 

1152 maskBit = maskedIm.mask.getPlaneBitMask(maskPlane) 

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

1154 return nPix