Coverage for python/lsst/ip/isr/crosstalk.py: 10%

289 statements  

« prev     ^ index     » next       coverage.py v7.2.5, created at 2023-05-23 03:38 -0700

1# 

2# LSST Data Management System 

3# Copyright 2008-2017 AURA/LSST. 

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 <https://www.lsstcorp.org/LegalNotices/>. 

21# 

22""" 

23Apply intra-detector crosstalk corrections 

24""" 

25 

26__all__ = ["CrosstalkCalib", "CrosstalkConfig", "CrosstalkTask", 

27 "NullCrosstalkTask"] 

28 

29import numpy as np 

30from astropy.table import Table 

31 

32import lsst.afw.math 

33import lsst.afw.detection 

34import lsst.daf.butler 

35from lsst.pex.config import Config, Field, ChoiceField, ListField 

36from lsst.pipe.base import Task 

37 

38from lsst.ip.isr import IsrCalib 

39 

40 

41class CrosstalkCalib(IsrCalib): 

42 """Calibration of amp-to-amp crosstalk coefficients. 

43 

44 Parameters 

45 ---------- 

46 detector : `lsst.afw.cameraGeom.Detector`, optional 

47 Detector to use to pull coefficients from. 

48 nAmp : `int`, optional 

49 Number of amplifiers to initialize. 

50 log : `logging.Logger`, optional 

51 Log to write messages to. 

52 **kwargs : 

53 Parameters to pass to parent constructor. 

54 

55 Notes 

56 ----- 

57 The crosstalk attributes stored are: 

58 

59 hasCrosstalk : `bool` 

60 Whether there is crosstalk defined for this detector. 

61 nAmp : `int` 

62 Number of amplifiers in this detector. 

63 crosstalkShape : `tuple` [`int`, `int`] 

64 A tuple containing the shape of the ``coeffs`` matrix. This 

65 should be equivalent to (``nAmp``, ``nAmp``). 

66 coeffs : `numpy.ndarray` 

67 A matrix containing the crosstalk coefficients. coeff[i][j] 

68 contains the coefficients to calculate the contribution 

69 amplifier_j has on amplifier_i (each row[i] contains the 

70 corrections for detector_i). 

71 coeffErr : `numpy.ndarray`, optional 

72 A matrix (as defined by ``coeffs``) containing the standard 

73 distribution of the crosstalk measurements. 

74 coeffNum : `numpy.ndarray`, optional 

75 A matrix containing the number of pixel pairs used to measure 

76 the ``coeffs`` and ``coeffErr``. 

77 coeffValid : `numpy.ndarray`, optional 

78 A matrix of Boolean values indicating if the coefficient is 

79 valid, defined as abs(coeff) > coeffErr / sqrt(coeffNum). 

80 interChip : `dict` [`numpy.ndarray`] 

81 A dictionary keyed by detectorName containing ``coeffs`` 

82 matrices used to correct for inter-chip crosstalk with a 

83 source on the detector indicated. 

84 

85 """ 

86 _OBSTYPE = 'CROSSTALK' 

87 _SCHEMA = 'Gen3 Crosstalk' 

88 _VERSION = 1.0 

89 

90 def __init__(self, detector=None, nAmp=0, **kwargs): 

91 self.hasCrosstalk = False 

92 self.nAmp = nAmp if nAmp else 0 

93 self.crosstalkShape = (self.nAmp, self.nAmp) 

94 

95 self.coeffs = np.zeros(self.crosstalkShape) if self.nAmp else None 

96 self.coeffErr = np.zeros(self.crosstalkShape) if self.nAmp else None 

97 self.coeffNum = np.zeros(self.crosstalkShape, 

98 dtype=int) if self.nAmp else None 

99 self.coeffValid = np.zeros(self.crosstalkShape, 

100 dtype=bool) if self.nAmp else None 

101 self.interChip = {} 

102 

103 super().__init__(**kwargs) 

104 self.requiredAttributes.update(['hasCrosstalk', 'nAmp', 'coeffs', 

105 'coeffErr', 'coeffNum', 'coeffValid', 

106 'interChip']) 

107 if detector: 

108 self.fromDetector(detector) 

109 

110 def updateMetadata(self, setDate=False, **kwargs): 

111 """Update calibration metadata. 

112 

113 This calls the base class's method after ensuring the required 

114 calibration keywords will be saved. 

115 

116 Parameters 

117 ---------- 

118 setDate : `bool`, optional 

119 Update the CALIBDATE fields in the metadata to the current 

120 time. Defaults to False. 

121 kwargs : 

122 Other keyword parameters to set in the metadata. 

123 """ 

124 kwargs['DETECTOR'] = self._detectorId 

125 kwargs['DETECTOR_NAME'] = self._detectorName 

126 kwargs['DETECTOR_SERIAL'] = self._detectorSerial 

127 kwargs['HAS_CROSSTALK'] = self.hasCrosstalk 

128 kwargs['NAMP'] = self.nAmp 

129 self.crosstalkShape = (self.nAmp, self.nAmp) 

130 kwargs['CROSSTALK_SHAPE'] = self.crosstalkShape 

131 

132 super().updateMetadata(setDate=setDate, **kwargs) 

133 

134 def fromDetector(self, detector, coeffVector=None): 

135 """Set calibration parameters from the detector. 

136 

137 Parameters 

138 ---------- 

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

140 Detector to use to set parameters from. 

141 coeffVector : `numpy.array`, optional 

142 Use the detector geometry (bounding boxes and flip 

143 information), but use ``coeffVector`` instead of the 

144 output of ``detector.getCrosstalk()``. 

145 

146 Returns 

147 ------- 

148 calib : `lsst.ip.isr.CrosstalkCalib` 

149 The calibration constructed from the detector. 

150 

151 """ 

152 if detector.hasCrosstalk() or coeffVector: 

153 self._detectorId = detector.getId() 

154 self._detectorName = detector.getName() 

155 self._detectorSerial = detector.getSerial() 

156 

157 self.nAmp = len(detector) 

158 self.crosstalkShape = (self.nAmp, self.nAmp) 

159 

160 if coeffVector is not None: 

161 crosstalkCoeffs = coeffVector 

162 else: 

163 crosstalkCoeffs = detector.getCrosstalk() 

164 if len(crosstalkCoeffs) == 1 and crosstalkCoeffs[0] == 0.0: 

165 return self 

166 self.coeffs = np.array(crosstalkCoeffs).reshape(self.crosstalkShape) 

167 

168 if self.coeffs.shape != self.crosstalkShape: 

169 raise RuntimeError("Crosstalk coefficients do not match detector shape. " 

170 f"{self.crosstalkShape} {self.nAmp}") 

171 

172 self.coeffErr = np.zeros(self.crosstalkShape) 

173 self.coeffNum = np.zeros(self.crosstalkShape, dtype=int) 

174 self.coeffValid = np.ones(self.crosstalkShape, dtype=bool) 

175 self.interChip = {} 

176 

177 self.hasCrosstalk = True 

178 self.updateMetadata() 

179 return self 

180 

181 @classmethod 

182 def fromDict(cls, dictionary): 

183 """Construct a calibration from a dictionary of properties. 

184 

185 Must be implemented by the specific calibration subclasses. 

186 

187 Parameters 

188 ---------- 

189 dictionary : `dict` 

190 Dictionary of properties. 

191 

192 Returns 

193 ------- 

194 calib : `lsst.ip.isr.CalibType` 

195 Constructed calibration. 

196 

197 Raises 

198 ------ 

199 RuntimeError 

200 Raised if the supplied dictionary is for a different 

201 calibration. 

202 """ 

203 calib = cls() 

204 

205 if calib._OBSTYPE != dictionary['metadata']['OBSTYPE']: 

206 raise RuntimeError(f"Incorrect crosstalk supplied. Expected {calib._OBSTYPE}, " 

207 f"found {dictionary['metadata']['OBSTYPE']}") 

208 

209 calib.setMetadata(dictionary['metadata']) 

210 

211 if 'detectorName' in dictionary: 

212 calib._detectorName = dictionary.get('detectorName') 

213 elif 'DETECTOR_NAME' in dictionary: 

214 calib._detectorName = dictionary.get('DETECTOR_NAME') 

215 elif 'DET_NAME' in dictionary['metadata']: 

216 calib._detectorName = dictionary['metadata']['DET_NAME'] 

217 else: 

218 calib._detectorName = None 

219 

220 if 'detectorSerial' in dictionary: 

221 calib._detectorSerial = dictionary.get('detectorSerial') 

222 elif 'DETECTOR_SERIAL' in dictionary: 

223 calib._detectorSerial = dictionary.get('DETECTOR_SERIAL') 

224 elif 'DET_SER' in dictionary['metadata']: 

225 calib._detectorSerial = dictionary['metadata']['DET_SER'] 

226 else: 

227 calib._detectorSerial = None 

228 

229 if 'detectorId' in dictionary: 

230 calib._detectorId = dictionary.get('detectorId') 

231 elif 'DETECTOR' in dictionary: 

232 calib._detectorId = dictionary.get('DETECTOR') 

233 elif 'DETECTOR' in dictionary['metadata']: 

234 calib._detectorId = dictionary['metadata']['DETECTOR'] 

235 elif calib._detectorSerial: 

236 calib._detectorId = calib._detectorSerial 

237 else: 

238 calib._detectorId = None 

239 

240 if 'instrument' in dictionary: 

241 calib._instrument = dictionary.get('instrument') 

242 elif 'INSTRUME' in dictionary['metadata']: 

243 calib._instrument = dictionary['metadata']['INSTRUME'] 

244 else: 

245 calib._instrument = None 

246 

247 calib.hasCrosstalk = dictionary.get('hasCrosstalk', 

248 dictionary['metadata'].get('HAS_CROSSTALK', False)) 

249 if calib.hasCrosstalk: 

250 calib.nAmp = dictionary.get('nAmp', dictionary['metadata'].get('NAMP', 0)) 

251 calib.crosstalkShape = (calib.nAmp, calib.nAmp) 

252 calib.coeffs = np.array(dictionary['coeffs']).reshape(calib.crosstalkShape) 

253 if 'coeffErr' in dictionary: 

254 calib.coeffErr = np.array(dictionary['coeffErr']).reshape(calib.crosstalkShape) 

255 else: 

256 calib.coeffErr = np.zeros_like(calib.coeffs) 

257 if 'coeffNum' in dictionary: 

258 calib.coeffNum = np.array(dictionary['coeffNum']).reshape(calib.crosstalkShape) 

259 else: 

260 calib.coeffNum = np.zeros_like(calib.coeffs, dtype=int) 

261 if 'coeffValid' in dictionary: 

262 calib.coeffValid = np.array(dictionary['coeffValid']).reshape(calib.crosstalkShape) 

263 else: 

264 calib.coeffValid = np.ones_like(calib.coeffs, dtype=bool) 

265 

266 calib.interChip = dictionary.get('interChip', None) 

267 if calib.interChip: 

268 for detector in calib.interChip: 

269 coeffVector = calib.interChip[detector] 

270 calib.interChip[detector] = np.array(coeffVector).reshape(calib.crosstalkShape) 

271 

272 calib.updateMetadata() 

273 return calib 

274 

275 def toDict(self): 

276 """Return a dictionary containing the calibration properties. 

277 

278 The dictionary should be able to be round-tripped through 

279 `fromDict`. 

280 

281 Returns 

282 ------- 

283 dictionary : `dict` 

284 Dictionary of properties. 

285 """ 

286 self.updateMetadata() 

287 

288 outDict = {} 

289 metadata = self.getMetadata() 

290 outDict['metadata'] = metadata 

291 

292 outDict['hasCrosstalk'] = self.hasCrosstalk 

293 outDict['nAmp'] = self.nAmp 

294 outDict['crosstalkShape'] = self.crosstalkShape 

295 

296 ctLength = self.nAmp*self.nAmp 

297 outDict['coeffs'] = self.coeffs.reshape(ctLength).tolist() 

298 

299 if self.coeffErr is not None: 

300 outDict['coeffErr'] = self.coeffErr.reshape(ctLength).tolist() 

301 if self.coeffNum is not None: 

302 outDict['coeffNum'] = self.coeffNum.reshape(ctLength).tolist() 

303 if self.coeffValid is not None: 

304 outDict['coeffValid'] = self.coeffValid.reshape(ctLength).tolist() 

305 

306 if self.interChip: 

307 outDict['interChip'] = dict() 

308 for detector in self.interChip: 

309 outDict['interChip'][detector] = self.interChip[detector].reshape(ctLength).tolist() 

310 

311 return outDict 

312 

313 @classmethod 

314 def fromTable(cls, tableList): 

315 """Construct calibration from a list of tables. 

316 

317 This method uses the `fromDict` method to create the 

318 calibration, after constructing an appropriate dictionary from 

319 the input tables. 

320 

321 Parameters 

322 ---------- 

323 tableList : `list` [`lsst.afw.table.Table`] 

324 List of tables to use to construct the crosstalk 

325 calibration. 

326 

327 Returns 

328 ------- 

329 calib : `lsst.ip.isr.CrosstalkCalib` 

330 The calibration defined in the tables. 

331 

332 """ 

333 coeffTable = tableList[0] 

334 

335 metadata = coeffTable.meta 

336 inDict = dict() 

337 inDict['metadata'] = metadata 

338 inDict['hasCrosstalk'] = metadata['HAS_CROSSTALK'] 

339 inDict['nAmp'] = metadata['NAMP'] 

340 

341 inDict['coeffs'] = coeffTable['CT_COEFFS'] 

342 if 'CT_ERRORS' in coeffTable.columns: 

343 inDict['coeffErr'] = coeffTable['CT_ERRORS'] 

344 if 'CT_COUNTS' in coeffTable.columns: 

345 inDict['coeffNum'] = coeffTable['CT_COUNTS'] 

346 if 'CT_VALID' in coeffTable.columns: 

347 inDict['coeffValid'] = coeffTable['CT_VALID'] 

348 

349 if len(tableList) > 1: 

350 inDict['interChip'] = dict() 

351 interChipTable = tableList[1] 

352 for record in interChipTable: 

353 inDict['interChip'][record['IC_SOURCE_DET']] = record['IC_COEFFS'] 

354 

355 return cls().fromDict(inDict) 

356 

357 def toTable(self): 

358 """Construct a list of tables containing the information in this 

359 calibration. 

360 

361 The list of tables should create an identical calibration 

362 after being passed to this class's fromTable method. 

363 

364 Returns 

365 ------- 

366 tableList : `list` [`lsst.afw.table.Table`] 

367 List of tables containing the crosstalk calibration 

368 information. 

369 

370 """ 

371 tableList = [] 

372 self.updateMetadata() 

373 catalog = Table([{'CT_COEFFS': self.coeffs.reshape(self.nAmp*self.nAmp), 

374 'CT_ERRORS': self.coeffErr.reshape(self.nAmp*self.nAmp), 

375 'CT_COUNTS': self.coeffNum.reshape(self.nAmp*self.nAmp), 

376 'CT_VALID': self.coeffValid.reshape(self.nAmp*self.nAmp), 

377 }]) 

378 # filter None, because astropy can't deal. 

379 inMeta = self.getMetadata().toDict() 

380 outMeta = {k: v for k, v in inMeta.items() if v is not None} 

381 outMeta.update({k: "" for k, v in inMeta.items() if v is None}) 

382 catalog.meta = outMeta 

383 tableList.append(catalog) 

384 

385 if self.interChip: 

386 interChipTable = Table([{'IC_SOURCE_DET': sourceDet, 

387 'IC_COEFFS': self.interChip[sourceDet].reshape(self.nAmp*self.nAmp)} 

388 for sourceDet in self.interChip.keys()]) 

389 tableList.append(interChipTable) 

390 return tableList 

391 

392 # Implementation methods. 

393 @staticmethod 

394 def extractAmp(image, amp, ampTarget, isTrimmed=False): 

395 """Extract the image data from an amp, flipped to match ampTarget. 

396 

397 Parameters 

398 ---------- 

399 image : `lsst.afw.image.Image` or `lsst.afw.image.MaskedImage` 

400 Image containing the amplifier of interest. 

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

402 Amplifier on image to extract. 

403 ampTarget : `lsst.afw.cameraGeom.Amplifier` 

404 Target amplifier that the extracted image will be flipped 

405 to match. 

406 isTrimmed : `bool` 

407 The image is already trimmed. 

408 TODO : DM-15409 will resolve this. 

409 

410 Returns 

411 ------- 

412 output : `lsst.afw.image.Image` 

413 Image of the amplifier in the desired configuration. 

414 """ 

415 X_FLIP = {lsst.afw.cameraGeom.ReadoutCorner.LL: False, 

416 lsst.afw.cameraGeom.ReadoutCorner.LR: True, 

417 lsst.afw.cameraGeom.ReadoutCorner.UL: False, 

418 lsst.afw.cameraGeom.ReadoutCorner.UR: True} 

419 Y_FLIP = {lsst.afw.cameraGeom.ReadoutCorner.LL: False, 

420 lsst.afw.cameraGeom.ReadoutCorner.LR: False, 

421 lsst.afw.cameraGeom.ReadoutCorner.UL: True, 

422 lsst.afw.cameraGeom.ReadoutCorner.UR: True} 

423 

424 output = image[amp.getBBox() if isTrimmed else amp.getRawDataBBox()] 

425 thisAmpCorner = amp.getReadoutCorner() 

426 targetAmpCorner = ampTarget.getReadoutCorner() 

427 

428 # Flipping is necessary only if the desired configuration doesn't match 

429 # what we currently have. 

430 xFlip = X_FLIP[targetAmpCorner] ^ X_FLIP[thisAmpCorner] 

431 yFlip = Y_FLIP[targetAmpCorner] ^ Y_FLIP[thisAmpCorner] 

432 return lsst.afw.math.flipImage(output, xFlip, yFlip) 

433 

434 @staticmethod 

435 def calculateBackground(mi, badPixels=["BAD"]): 

436 """Estimate median background in image. 

437 

438 Getting a great background model isn't important for crosstalk 

439 correction, since the crosstalk is at a low level. The median should 

440 be sufficient. 

441 

442 Parameters 

443 ---------- 

444 mi : `lsst.afw.image.MaskedImage` 

445 MaskedImage for which to measure background. 

446 badPixels : `list` of `str` 

447 Mask planes to ignore. 

448 Returns 

449 ------- 

450 bg : `float` 

451 Median background level. 

452 """ 

453 mask = mi.getMask() 

454 stats = lsst.afw.math.StatisticsControl() 

455 stats.setAndMask(mask.getPlaneBitMask(badPixels)) 

456 return lsst.afw.math.makeStatistics(mi, lsst.afw.math.MEDIAN, stats).getValue() 

457 

458 def subtractCrosstalk(self, thisExposure, sourceExposure=None, crosstalkCoeffs=None, 

459 badPixels=["BAD"], minPixelToMask=45000, 

460 crosstalkStr="CROSSTALK", isTrimmed=False, 

461 backgroundMethod="None"): 

462 """Subtract the crosstalk from thisExposure, optionally using a 

463 different source. 

464 

465 We set the mask plane indicated by ``crosstalkStr`` in a target 

466 amplifier for pixels in a source amplifier that exceed 

467 ``minPixelToMask``. Note that the correction is applied to all pixels 

468 in the amplifier, but only those that have a substantial crosstalk 

469 are masked with ``crosstalkStr``. 

470 

471 The uncorrected image is used as a template for correction. This is 

472 good enough if the crosstalk is small (e.g., coefficients < ~ 1e-3), 

473 but if it's larger you may want to iterate. 

474 

475 Parameters 

476 ---------- 

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

478 Exposure for which to subtract crosstalk. 

479 sourceExposure : `lsst.afw.image.Exposure`, optional 

480 Exposure to use as the source of the crosstalk. If not set, 

481 thisExposure is used as the source (intra-detector crosstalk). 

482 crosstalkCoeffs : `numpy.ndarray`, optional. 

483 Coefficients to use to correct crosstalk. 

484 badPixels : `list` of `str` 

485 Mask planes to ignore. 

486 minPixelToMask : `float` 

487 Minimum pixel value (relative to the background level) in 

488 source amplifier for which to set ``crosstalkStr`` mask plane 

489 in target amplifier. 

490 crosstalkStr : `str` 

491 Mask plane name for pixels greatly modified by crosstalk 

492 (above minPixelToMask). 

493 isTrimmed : `bool` 

494 The image is already trimmed. 

495 This should no longer be needed once DM-15409 is resolved. 

496 backgroundMethod : `str` 

497 Method used to subtract the background. "AMP" uses 

498 amplifier-by-amplifier background levels, "DETECTOR" uses full 

499 exposure/maskedImage levels. Any other value results in no 

500 background subtraction. 

501 """ 

502 mi = thisExposure.getMaskedImage() 

503 mask = mi.getMask() 

504 detector = thisExposure.getDetector() 

505 if self.hasCrosstalk is False: 

506 self.fromDetector(detector, coeffVector=crosstalkCoeffs) 

507 

508 numAmps = len(detector) 

509 if numAmps != self.nAmp: 

510 raise RuntimeError(f"Crosstalk built for {self.nAmp} in {self._detectorName}, received " 

511 f"{numAmps} in {detector.getName()}") 

512 

513 if sourceExposure: 

514 source = sourceExposure.getMaskedImage() 

515 sourceDetector = sourceExposure.getDetector() 

516 else: 

517 source = mi 

518 sourceDetector = detector 

519 

520 if crosstalkCoeffs is not None: 

521 coeffs = crosstalkCoeffs 

522 else: 

523 coeffs = self.coeffs 

524 self.log.debug("CT COEFF: %s", coeffs) 

525 # Set background level based on the requested method. The 

526 # thresholdBackground holds the offset needed so that we only mask 

527 # pixels high relative to the background, not in an absolute 

528 # sense. 

529 thresholdBackground = self.calculateBackground(source, badPixels) 

530 

531 backgrounds = [0.0 for amp in sourceDetector] 

532 if backgroundMethod is None: 

533 pass 

534 elif backgroundMethod == "AMP": 

535 backgrounds = [self.calculateBackground(source[amp.getBBox()], badPixels) 

536 for amp in sourceDetector] 

537 elif backgroundMethod == "DETECTOR": 

538 backgrounds = [self.calculateBackground(source, badPixels) for amp in sourceDetector] 

539 

540 # Set the crosstalkStr bit for the bright pixels (those which will have 

541 # significant crosstalk correction) 

542 crosstalkPlane = mask.addMaskPlane(crosstalkStr) 

543 footprints = lsst.afw.detection.FootprintSet(source, 

544 lsst.afw.detection.Threshold(minPixelToMask 

545 + thresholdBackground)) 

546 footprints.setMask(mask, crosstalkStr) 

547 crosstalk = mask.getPlaneBitMask(crosstalkStr) 

548 

549 # Define a subtrahend image to contain all the scaled crosstalk signals 

550 subtrahend = source.Factory(source.getBBox()) 

551 subtrahend.set((0, 0, 0)) 

552 

553 coeffs = coeffs.transpose() 

554 for ii, iAmp in enumerate(sourceDetector): 

555 iImage = subtrahend[iAmp.getBBox() if isTrimmed else iAmp.getRawDataBBox()] 

556 for jj, jAmp in enumerate(detector): 

557 if coeffs[ii, jj] == 0.0: 

558 continue 

559 jImage = self.extractAmp(mi, jAmp, iAmp, isTrimmed) 

560 jImage.getMask().getArray()[:] &= crosstalk # Remove all other masks 

561 jImage -= backgrounds[jj] 

562 iImage.scaledPlus(coeffs[ii, jj], jImage) 

563 

564 # Set crosstalkStr bit only for those pixels that have been 

565 # significantly modified (i.e., those masked as such in 'subtrahend'), 

566 # not necessarily those that are bright originally. 

567 mask.clearMaskPlane(crosstalkPlane) 

568 mi -= subtrahend # also sets crosstalkStr bit for bright pixels 

569 

570 

571class CrosstalkConfig(Config): 

572 """Configuration for intra-detector crosstalk removal.""" 

573 minPixelToMask = Field( 

574 dtype=float, 

575 doc="Set crosstalk mask plane for pixels over this value.", 

576 default=45000 

577 ) 

578 crosstalkMaskPlane = Field( 

579 dtype=str, 

580 doc="Name for crosstalk mask plane.", 

581 default="CROSSTALK" 

582 ) 

583 crosstalkBackgroundMethod = ChoiceField( 

584 dtype=str, 

585 doc="Type of background subtraction to use when applying correction.", 

586 default="None", 

587 allowed={ 

588 "None": "Do no background subtraction.", 

589 "AMP": "Subtract amplifier-by-amplifier background levels.", 

590 "DETECTOR": "Subtract detector level background." 

591 }, 

592 ) 

593 useConfigCoefficients = Field( 

594 dtype=bool, 

595 doc="Ignore the detector crosstalk information in favor of CrosstalkConfig values?", 

596 default=False, 

597 ) 

598 crosstalkValues = ListField( 

599 dtype=float, 

600 doc=("Amplifier-indexed crosstalk coefficients to use. This should be arranged as a 1 x nAmp**2 " 

601 "list of coefficients, such that when reshaped by crosstalkShape, the result is nAmp x nAmp. " 

602 "This matrix should be structured so CT * [amp0 amp1 amp2 ...]^T returns the column " 

603 "vector [corr0 corr1 corr2 ...]^T."), 

604 default=[0.0], 

605 ) 

606 crosstalkShape = ListField( 

607 dtype=int, 

608 doc="Shape of the coefficient array. This should be equal to [nAmp, nAmp].", 

609 default=[1], 

610 ) 

611 

612 def getCrosstalk(self, detector=None): 

613 """Return a 2-D numpy array of crosstalk coefficients in the proper 

614 shape. 

615 

616 Parameters 

617 ---------- 

618 detector : `lsst.afw.cameraGeom.detector` 

619 Detector that is to be crosstalk corrected. 

620 

621 Returns 

622 ------- 

623 coeffs : `numpy.ndarray` 

624 Crosstalk coefficients that can be used to correct the detector. 

625 

626 Raises 

627 ------ 

628 RuntimeError 

629 Raised if no coefficients could be generated from this 

630 detector/configuration. 

631 """ 

632 if self.useConfigCoefficients is True: 

633 coeffs = np.array(self.crosstalkValues).reshape(self.crosstalkShape) 

634 if detector is not None: 

635 nAmp = len(detector) 

636 if coeffs.shape != (nAmp, nAmp): 

637 raise RuntimeError("Constructed crosstalk coeffients do not match detector shape. " 

638 f"{coeffs.shape} {nAmp}") 

639 return coeffs 

640 elif detector is not None and detector.hasCrosstalk() is True: 

641 # Assume the detector defines itself consistently. 

642 return detector.getCrosstalk() 

643 else: 

644 raise RuntimeError("Attempted to correct crosstalk without crosstalk coefficients") 

645 

646 def hasCrosstalk(self, detector=None): 

647 """Return a boolean indicating if crosstalk coefficients exist. 

648 

649 Parameters 

650 ---------- 

651 detector : `lsst.afw.cameraGeom.detector` 

652 Detector that is to be crosstalk corrected. 

653 

654 Returns 

655 ------- 

656 hasCrosstalk : `bool` 

657 True if this detector/configuration has crosstalk coefficients 

658 defined. 

659 """ 

660 if self.useConfigCoefficients is True and self.crosstalkValues is not None: 

661 return True 

662 elif detector is not None and detector.hasCrosstalk() is True: 

663 return True 

664 else: 

665 return False 

666 

667 

668class CrosstalkTask(Task): 

669 """Apply intra-detector crosstalk correction.""" 

670 ConfigClass = CrosstalkConfig 

671 _DefaultName = 'isrCrosstalk' 

672 

673 def run(self, exposure, crosstalk=None, 

674 crosstalkSources=None, isTrimmed=False, camera=None): 

675 """Apply intra-detector crosstalk correction 

676 

677 Parameters 

678 ---------- 

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

680 Exposure for which to remove crosstalk. 

681 crosstalkCalib : `lsst.ip.isr.CrosstalkCalib`, optional 

682 External crosstalk calibration to apply. Constructed from 

683 detector if not found. 

684 crosstalkSources : `defaultdict`, optional 

685 Image data for other detectors that are sources of 

686 crosstalk in exposure. The keys are expected to be names 

687 of the other detectors, with the values containing 

688 `lsst.afw.image.Exposure` at the same level of processing 

689 as ``exposure``. 

690 The default for intra-detector crosstalk here is None. 

691 isTrimmed : `bool`, optional 

692 The image is already trimmed. 

693 This should no longer be needed once DM-15409 is resolved. 

694 camera : `lsst.afw.cameraGeom.Camera`, optional 

695 Camera associated with this exposure. Only used for 

696 inter-chip matching. 

697 

698 Raises 

699 ------ 

700 RuntimeError 

701 Raised if called for a detector that does not have a 

702 crosstalk correction. Also raised if the crosstalkSource 

703 is not an expected type. 

704 """ 

705 if not crosstalk: 

706 crosstalk = CrosstalkCalib(log=self.log) 

707 crosstalk = crosstalk.fromDetector(exposure.getDetector(), 

708 coeffVector=self.config.crosstalkValues) 

709 if not crosstalk.log: 

710 crosstalk.log = self.log 

711 if not crosstalk.hasCrosstalk: 

712 raise RuntimeError("Attempted to correct crosstalk without crosstalk coefficients.") 

713 

714 else: 

715 self.log.info("Applying crosstalk correction.") 

716 crosstalk.subtractCrosstalk(exposure, crosstalkCoeffs=crosstalk.coeffs, 

717 minPixelToMask=self.config.minPixelToMask, 

718 crosstalkStr=self.config.crosstalkMaskPlane, isTrimmed=isTrimmed, 

719 backgroundMethod=self.config.crosstalkBackgroundMethod) 

720 

721 if crosstalk.interChip: 

722 if crosstalkSources: 

723 # Parse crosstalkSources: Identify which detectors we have 

724 # available 

725 if isinstance(crosstalkSources[0], lsst.afw.image.Exposure): 

726 # Received afwImage.Exposure 

727 sourceNames = [exp.getDetector().getName() for exp in crosstalkSources] 

728 elif isinstance(crosstalkSources[0], lsst.daf.butler.DeferredDatasetHandle): 

729 # Received dafButler.DeferredDatasetHandle 

730 detectorList = [source.dataId['detector'] for source in crosstalkSources] 

731 sourceNames = [camera[detector].getName() for detector in detectorList] 

732 else: 

733 raise RuntimeError("Unknown object passed as crosstalk sources.", 

734 type(crosstalkSources[0])) 

735 

736 for detName in crosstalk.interChip: 

737 if detName not in sourceNames: 

738 self.log.warning("Crosstalk lists %s, not found in sources: %s", 

739 detName, sourceNames) 

740 continue 

741 # Get the coefficients. 

742 interChipCoeffs = crosstalk.interChip[detName] 

743 

744 sourceExposure = crosstalkSources[sourceNames.index(detName)] 

745 if isinstance(sourceExposure, lsst.daf.butler.DeferredDatasetHandle): 

746 # Dereference the dafButler.DeferredDatasetHandle. 

747 sourceExposure = sourceExposure.get() 

748 if not isinstance(sourceExposure, lsst.afw.image.Exposure): 

749 raise RuntimeError("Unknown object passed as crosstalk sources.", 

750 type(sourceExposure)) 

751 

752 self.log.info("Correcting detector %s with ctSource %s", 

753 exposure.getDetector().getName(), 

754 sourceExposure.getDetector().getName()) 

755 crosstalk.subtractCrosstalk(exposure, sourceExposure=sourceExposure, 

756 crosstalkCoeffs=interChipCoeffs, 

757 minPixelToMask=self.config.minPixelToMask, 

758 crosstalkStr=self.config.crosstalkMaskPlane, 

759 isTrimmed=isTrimmed, 

760 backgroundMethod=self.config.crosstalkBackgroundMethod) 

761 else: 

762 self.log.warning("Crosstalk contains interChip coefficients, but no sources found!") 

763 

764 

765class NullCrosstalkTask(CrosstalkTask): 

766 def run(self, exposure, crosstalkSources=None): 

767 self.log.info("Not performing any crosstalk correction")