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

455 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-17 09:10 +0000

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 

30import warnings 

31from astropy.table import Table 

32 

33import lsst.afw.math 

34import lsst.afw.detection 

35import lsst.daf.butler 

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

37from lsst.pipe.base import Task 

38 

39from .calibType import IsrCalib 

40from .isrFunctions import gainContext, isTrimmedImage 

41 

42 

43class CrosstalkCalib(IsrCalib): 

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

45 

46 Parameters 

47 ---------- 

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

49 Detector to use to pull coefficients from. 

50 nAmp : `int`, optional 

51 Number of amplifiers to initialize. 

52 log : `logging.Logger`, optional 

53 Log to write messages to. 

54 **kwargs : 

55 Parameters to pass to parent constructor. 

56 

57 Notes 

58 ----- 

59 The crosstalk attributes stored are: 

60 

61 hasCrosstalk : `bool` 

62 Whether there is crosstalk defined for this detector. 

63 nAmp : `int` 

64 Number of amplifiers in this detector. 

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

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

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

68 coeffs : `numpy.ndarray` 

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

70 contains the coefficients to calculate the contribution 

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

72 corrections for detector_i). 

73 coeffErr : `numpy.ndarray`, optional 

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

75 distribution of the crosstalk measurements. 

76 coeffNum : `numpy.ndarray`, optional 

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

78 the ``coeffs`` and ``coeffErr``. 

79 coeffValid : `numpy.ndarray`, optional 

80 A matrix of Boolean values indicating if the coefficient is 

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

82 coeffsSqr : `numpy.ndarray`, optional 

83 A matrix containing potential quadratic crosstalk coefficients 

84 (see e.g., Snyder+21, 2001.03223). coeffsSqr[i][j] 

85 contains the coefficients to calculate the contribution 

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

87 corrections for detector_i). 

88 coeffErrSqr : `numpy.ndarray`, optional 

89 A matrix (as defined by ``coeffsSqr``) containing the standard 

90 distribution of the quadratic term of the crosstalk measurements. 

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

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

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

94 source on the detector indicated. 

95 

96 Version 1.1 adds quadratic coefficients, a matrix with the ratios 

97 of amplifiers gains per detector, and a field to indicate the units 

98 of the numerator and denominator of the source and target signals, with 

99 "adu" meaning "ADU / ADU" and "electron" meaning "e- / e-". 

100 

101 Version 1.2 adds the original gains used in the crosstalk fit. 

102 """ 

103 _OBSTYPE = 'CROSSTALK' 

104 _SCHEMA = 'Gen3 Crosstalk' 

105 _VERSION = 1.2 

106 

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

108 self.hasCrosstalk = False 

109 self.nAmp = nAmp if nAmp else 0 

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

111 

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

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

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

115 dtype=int) if self.nAmp else None 

116 self.coeffValid = np.ones(self.crosstalkShape, 

117 dtype=bool) if self.nAmp else None 

118 # Quadratic terms, if any. 

119 self.coeffsSqr = np.zeros(self.crosstalkShape) if self.nAmp else None 

120 self.coeffErrSqr = np.zeros(self.crosstalkShape) if self.nAmp else None 

121 

122 # Gain ratios 

123 self.ampGainRatios = np.zeros(self.crosstalkShape) if self.nAmp else None 

124 

125 # Gains used for fit. 

126 self.fitGains = np.zeros(self.nAmp) if self.nAmp else None 

127 

128 # Units 

129 self.crosstalkRatiosUnits = 'adu' if self.nAmp else None 

130 

131 self.interChip = {} 

132 

133 super().__init__(**kwargs) 

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

135 'coeffErr', 'coeffNum', 'coeffValid', 

136 'coeffsSqr', 'coeffErrSqr', 

137 'ampGainRatios', 'crosstalkRatiosUnits', 

138 'fitGains', 'interChip']) 

139 if detector: 

140 self.fromDetector(detector) 

141 

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

143 """Update calibration metadata. 

144 

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

146 calibration keywords will be saved. 

147 

148 Parameters 

149 ---------- 

150 setDate : `bool`, optional 

151 Update the CALIBDATE fields in the metadata to the current 

152 time. Defaults to False. 

153 kwargs : 

154 Other keyword parameters to set in the metadata. 

155 """ 

156 kwargs['DETECTOR'] = self._detectorId 

157 kwargs['DETECTOR_NAME'] = self._detectorName 

158 kwargs['DETECTOR_SERIAL'] = self._detectorSerial 

159 kwargs['HAS_CROSSTALK'] = self.hasCrosstalk 

160 kwargs['NAMP'] = self.nAmp 

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

162 kwargs['CROSSTALK_SHAPE'] = self.crosstalkShape 

163 kwargs['CROSSTALK_RATIOS_UNITS'] = self.crosstalkRatiosUnits 

164 

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

166 

167 def fromDetector(self, detector, coeffVector=None, coeffSqrVector=None): 

168 """Set calibration parameters from the detector. 

169 

170 Parameters 

171 ---------- 

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

173 Detector to use to set parameters from. 

174 coeffVector : `numpy.array`, optional 

175 Use the detector geometry (bounding boxes and flip 

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

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

178 coeffSqrVector : `numpy.array`, optional 

179 Quadratic crosstalk coefficients. 

180 

181 Returns 

182 ------- 

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

184 The calibration constructed from the detector. 

185 

186 """ 

187 self._detectorId = detector.getId() 

188 self._detectorName = detector.getName() 

189 self._detectorSerial = detector.getSerial() 

190 

191 self.nAmp = len(detector) 

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

193 

194 if coeffVector is not None: 

195 crosstalkCoeffs = coeffVector 

196 else: 

197 crosstalkCoeffs = detector.getCrosstalk() 

198 if coeffSqrVector is not None: 

199 self.coeffsSqr = coeffSqrVector 

200 else: 

201 self.coeffsSqr = np.zeros(self.crosstalkShape) 

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

203 return self 

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

205 

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

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

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

209 # Set default as in __init__ 

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

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

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

213 self.coeffErrSqr = np.zeros(self.crosstalkShape) 

214 self.ampGainRatios = np.zeros(self.crosstalkShape) 

215 self.fitGains = np.zeros(self.nAmp) 

216 self.crosstalkRatiosUnits = 'adu' 

217 

218 self.interChip = {} 

219 

220 self.hasCrosstalk = True 

221 self.updateMetadata() 

222 

223 return self 

224 

225 @classmethod 

226 def fromDict(cls, dictionary): 

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

228 

229 Must be implemented by the specific calibration subclasses. 

230 

231 Parameters 

232 ---------- 

233 dictionary : `dict` 

234 Dictionary of properties. 

235 

236 Returns 

237 ------- 

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

239 Constructed calibration. 

240 

241 Raises 

242 ------ 

243 RuntimeError 

244 Raised if the supplied dictionary is for a different 

245 calibration. 

246 """ 

247 calib = cls() 

248 

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

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

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

252 

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

254 

255 if 'detectorName' in dictionary: 

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

257 elif 'DETECTOR_NAME' in dictionary: 

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

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

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

261 else: 

262 calib._detectorName = None 

263 

264 if 'detectorSerial' in dictionary: 

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

266 elif 'DETECTOR_SERIAL' in dictionary: 

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

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

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

270 else: 

271 calib._detectorSerial = None 

272 

273 if 'detectorId' in dictionary: 

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

275 elif 'DETECTOR' in dictionary: 

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

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

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

279 elif calib._detectorSerial: 

280 calib._detectorId = calib._detectorSerial 

281 else: 

282 calib._detectorId = None 

283 

284 if 'instrument' in dictionary: 

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

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

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

288 else: 

289 calib._instrument = None 

290 

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

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

293 if calib.hasCrosstalk: 

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

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

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

297 calib.crosstalkRatiosUnits = dictionary.get( 

298 'crosstalkRatiosUnits', 

299 dictionary['metadata'].get('CROSSTALK_RATIOS_UNITS', 'adu')) 

300 if 'coeffErr' in dictionary: 

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

302 else: 

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

304 if 'coeffNum' in dictionary: 

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

306 else: 

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

308 if 'coeffValid' in dictionary: 

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

310 else: 

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

312 if 'coeffsSqr' in dictionary: 

313 calib.coeffsSqr = np.array(dictionary['coeffsSqr']).reshape(calib.crosstalkShape) 

314 else: 

315 calib.coeffsSqr = np.zeros_like(calib.coeffs) 

316 if 'coeffErrSqr' in dictionary: 

317 calib.coeffErrSqr = np.array(dictionary['coeffErrSqr']).reshape(calib.crosstalkShape) 

318 else: 

319 calib.coeffErrSqr = np.zeros_like(calib.coeffs) 

320 if 'ampGainRatios' in dictionary: 

321 calib.ampGainRatios = np.array(dictionary['ampGainRatios']).reshape(calib.crosstalkShape) 

322 else: 

323 calib.ampGainRatios = np.zeros_like(calib.coeffs) 

324 if 'fitGains' in dictionary: 

325 # Compatibility for matrices that were stored with fitGains 

326 # of length 1. 

327 fitGains = np.array(dictionary['fitGains']) 

328 if len(fitGains) == 1: 

329 # Expand to the correct number of amps, all zero (unknown). 

330 calib.fitGains = np.zeros(calib.nAmp) 

331 else: 

332 calib.fitGains = np.array(dictionary['fitGains']).reshape(calib.nAmp) 

333 else: 

334 calib.fitGains = np.zeros(calib.nAmp) 

335 

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

337 if calib.interChip: 

338 for detector in calib.interChip: 

339 coeffVector = calib.interChip[detector] 

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

341 

342 calib.updateMetadata() 

343 return calib 

344 

345 def toDict(self): 

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

347 

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

349 `fromDict`. 

350 

351 Returns 

352 ------- 

353 dictionary : `dict` 

354 Dictionary of properties. 

355 """ 

356 self.updateMetadata() 

357 

358 outDict = {} 

359 metadata = self.getMetadata() 

360 outDict['metadata'] = metadata 

361 

362 outDict['hasCrosstalk'] = self.hasCrosstalk 

363 outDict['nAmp'] = self.nAmp 

364 outDict['crosstalkShape'] = self.crosstalkShape 

365 outDict['crosstalkRatiosUnits'] = self.crosstalkRatiosUnits 

366 

367 ctLength = self.nAmp*self.nAmp 

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

369 

370 if self.coeffErr is not None: 

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

372 if self.coeffNum is not None: 

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

374 if self.coeffValid is not None: 

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

376 if self.coeffsSqr is not None: 

377 outDict['coeffsSqr'] = self.coeffsSqr.reshape(ctLength).tolist() 

378 if self.coeffErrSqr is not None: 

379 outDict['coeffErrSqr'] = self.coeffErrSqr.reshape(ctLength).tolist() 

380 if self.ampGainRatios is not None: 

381 outDict['ampGainRatios'] = self.ampGainRatios.reshape(ctLength).tolist() 

382 if self.fitGains is not None: 

383 outDict['fitGains'] = self.fitGains.tolist() 

384 

385 if self.interChip: 

386 outDict['interChip'] = dict() 

387 for detector in self.interChip: 

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

389 

390 return outDict 

391 

392 @classmethod 

393 def fromTable(cls, tableList): 

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

395 

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

397 calibration, after constructing an appropriate dictionary from 

398 the input tables. 

399 

400 Parameters 

401 ---------- 

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

403 List of tables to use to construct the crosstalk 

404 calibration. 

405 

406 Returns 

407 ------- 

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

409 The calibration defined in the tables. 

410 

411 """ 

412 coeffTable = tableList[0] 

413 

414 metadata = coeffTable.meta 

415 inDict = dict() 

416 inDict['metadata'] = metadata 

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

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

419 calibVersion = metadata['CROSSTALK_VERSION'] 

420 if calibVersion < 1.1: 

421 inDict['crosstalkRatiosUnits'] = 'adu' 

422 else: 

423 inDict['crosstalkRatiosUnits'] = metadata['CROSSTALK_RATIOS_UNITS'] 

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

425 if 'CT_ERRORS' in coeffTable.columns: 

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

427 if 'CT_COUNTS' in coeffTable.columns: 

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

429 if 'CT_VALID' in coeffTable.columns: 

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

431 if 'CT_COEFFS_SQR' in coeffTable.columns: 

432 inDict['coeffsSqr'] = coeffTable['CT_COEFFS_SQR'] 

433 if 'CT_ERRORS_SQR' in coeffTable.columns: 

434 inDict['coeffErrSqr'] = coeffTable['CT_ERRORS_SQR'] 

435 if 'CT_AMP_GAIN_RATIOS' in coeffTable.columns: 

436 inDict['ampGainRatios'] = coeffTable['CT_AMP_GAIN_RATIOS'] 

437 if 'CT_FIT_GAINS' in coeffTable.columns: 

438 inDict['fitGains'] = coeffTable['CT_FIT_GAINS'] 

439 

440 if len(tableList) > 1: 

441 inDict['interChip'] = dict() 

442 interChipTable = tableList[1] 

443 for record in interChipTable: 

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

445 

446 return cls().fromDict(inDict) 

447 

448 def toTable(self): 

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

450 calibration. 

451 

452 The list of tables should create an identical calibration 

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

454 

455 Returns 

456 ------- 

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

458 List of tables containing the crosstalk calibration 

459 information. 

460 

461 """ 

462 tableList = [] 

463 self.updateMetadata() 

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

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

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

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

468 'CT_COEFFS_SQR': self.coeffsSqr.reshape(self.nAmp*self.nAmp), 

469 'CT_ERRORS_SQR': self.coeffErrSqr.reshape(self.nAmp*self.nAmp), 

470 'CT_AMP_GAIN_RATIOS': self.ampGainRatios.reshape(self.nAmp*self.nAmp), 

471 'CT_FIT_GAINS': self.fitGains, 

472 }]) 

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

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

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

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

477 catalog.meta = outMeta 

478 tableList.append(catalog) 

479 

480 if self.interChip: 

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

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

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

484 tableList.append(interChipTable) 

485 return tableList 

486 

487 # Implementation methods. 

488 @staticmethod 

489 def extractAmp(image, ampToFlip, ampTarget, isTrimmed=False, fullAmplifier=False, parallelOverscan=None): 

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

491 

492 Parameters 

493 ---------- 

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

495 Image containing the amplifier of interest. 

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

497 Amplifier on image to extract. 

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

499 Target amplifier that the extracted image will be flipped 

500 to match. 

501 isTrimmed : `bool`, optional 

502 The image is already trimmed. 

503 fullAmplifier : `bool`, optional 

504 Use full amplifier and not just imaging region. 

505 parallelOverscan : `bool`, optional 

506 This has been deprecated and is unused, and will be removed 

507 after v29. 

508 

509 Returns 

510 ------- 

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

512 Amplifier from image, flipped to desired configuration. 

513 This will always return a copy of the original data. 

514 """ 

515 if parallelOverscan is not None: 

516 warnings.warn( 

517 "The parallelOverscan option has been deprecated and will be removed after v29.", 

518 FutureWarning, 

519 ) 

520 

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

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

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

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

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

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

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

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

529 

530 bbox = CrosstalkCalib._getAppropriateBBox(ampToFlip, isTrimmed, fullAmplifier) 

531 output = image[bbox] 

532 

533 sourceAmpCorner = ampToFlip.getReadoutCorner() 

534 targetAmpCorner = ampTarget.getReadoutCorner() 

535 

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

537 # what we currently have. 

538 xFlip = X_FLIP[targetAmpCorner] ^ X_FLIP[sourceAmpCorner] 

539 yFlip = Y_FLIP[targetAmpCorner] ^ Y_FLIP[sourceAmpCorner] 

540 # This always makes a copy of the image. 

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

542 

543 @staticmethod 

544 def _getAppropriateBBox(amp, isTrimmed, fullAmplifier): 

545 """Get the appropriate bounding box from an amplifier. 

546 

547 Parameters 

548 ---------- 

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

550 Amplifier to get bounding box. 

551 isTrimmed : `bool` 

552 Is this a trimmed image? 

553 fullAmplifier : `bool` 

554 Extract full amplifier for an untrimmed image? 

555 """ 

556 if isTrimmed: 

557 return amp.getBBox() 

558 elif fullAmplifier and not isTrimmed: 

559 return amp.getRawBBox() 

560 else: 

561 return amp.getRawDataBBox() 

562 

563 @staticmethod 

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

565 """Estimate median background in image. 

566 

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

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

569 be sufficient. 

570 

571 Parameters 

572 ---------- 

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

574 MaskedImage for which to measure background. 

575 badPixels : `list` of `str` 

576 Mask planes to ignore. 

577 Returns 

578 ------- 

579 bg : `float` 

580 Median background level. 

581 """ 

582 mask = mi.getMask() 

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

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

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

586 

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

588 crosstalkCoeffsSqr=None, crosstalkCoeffsValid=None, 

589 badPixels=["BAD"], minPixelToMask=45000, doSubtrahendMasking=False, 

590 crosstalkStr="CROSSTALK", isTrimmed=None, 

591 backgroundMethod="None", doSqrCrosstalk=False, fullAmplifier=False, 

592 parallelOverscan=None, detectorConfig=None, badAmpDict=None, 

593 ignoreVariance=False): 

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

595 different source. 

596 

597 We set the mask plane indicated by ``crosstalkStr`` in a 

598 target amplifier for pixels in a source amplifier that exceed 

599 ``minPixelToMask``, if ``doSubtrahendMasking`` is False. With 

600 that enabled, the mask is only set if the absolute value of 

601 the correction applied exceeds ``minPixelToMask``. Note that 

602 the correction is applied to all pixels in the amplifier, but 

603 only those that have a substantial crosstalk are masked with 

604 ``crosstalkStr``. 

605 

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

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

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

609 

610 Parameters 

611 ---------- 

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

613 Exposure for which to subtract crosstalk. 

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

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

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

617 crosstalkCoeffs : `numpy.ndarray`, optional. 

618 Coefficients to use to correct crosstalk. 

619 crosstalkCoeffsSqr : `numpy.ndarray`, optional. 

620 Quadratic coefficients to use to correct crosstalk. 

621 crosstalkCoeffsValid : `numpy.ndarray`, optional 

622 Boolean array that is True where coefficients are valid. 

623 badPixels : `list` of `str`, optional 

624 Mask planes to ignore. 

625 minPixelToMask : `float`, optional 

626 Minimum pixel value to set the ``crosstalkStr`` mask 

627 plane. If doSubtrahendMasking is True, this is calculated 

628 from the absolute magnitude of the subtrahend image. 

629 Otherwise, this sets the minimum source value to use to 

630 set that mask. 

631 doSubtrahendMasking : `bool`, optional 

632 If true, the mask is calculated from the properties of the 

633 subtrahend image, not from the brightness of the source 

634 pixel. 

635 crosstalkStr : `str`, optional 

636 Mask plane name for pixels greatly modified by crosstalk 

637 (above minPixelToMask). 

638 isTrimmed : `bool`, optional 

639 This option has been deprecated and does not do anything. 

640 It will be removed after v29. 

641 backgroundMethod : `str`, optional 

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

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

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

645 background subtraction. 

646 doSqrCrosstalk: `bool`, optional 

647 Should the quadratic crosstalk coefficients be used for the 

648 crosstalk correction? 

649 fullAmplifier : `bool`, optional 

650 Use full amplifier and not just imaging region. 

651 parallelOverscan : `bool`, optional 

652 This option is deprecated and will be removed after v29. 

653 detectorConfig : `lsst.ip.isr.overscanDetectorConfig`, optional 

654 Per-amplifier configs to use if parallelOverscan is True. 

655 This option is deprecated and will be removed after v29. 

656 badAmpDict : `dict` [`str`, `bool`], optional 

657 Dictionary to identify bad amplifiers that should not be 

658 source or target for crosstalk correction. 

659 ignoreVariance : `bool`, optional 

660 Ignore the variance plane when doing crosstalk correction? 

661 

662 Notes 

663 ----- 

664 

665 For a given image I, we want to find the crosstalk subtrahend 

666 image CT, such that 

667 I_corrected = I - CT 

668 The subtrahend image is the sum of all crosstalk contributions 

669 that appear in I, so we can build it up by amplifier. Each 

670 amplifier A in image I sees the contributions from all other 

671 amplifiers B_v != A. For the current linear model, we set `sImage` 

672 equal to the segment of the subtrahend image CT corresponding to 

673 amplifier A, and then build it up as: 

674 simage_linear = sum_v coeffsA_v * (B_v - bkg_v) where coeffsA_v 

675 is the vector of crosstalk coefficients for sources that cause 

676 images in amplifier A. The bkg_v term in this equation is 

677 identically 0.0 for all cameras except obs_subaru (and is only 

678 non-zero there for historical reasons). 

679 To include the non-linear term, we can again add to the subtrahend 

680 image using the same loop, as: 

681 

682 simage_nonlinear = sum_v (coeffsA_v * B_v) + (NLcoeffsA_v * B_v * B_v) 

683 = sum_v linear_term_v + nonlinear_term_v 

684 

685 where coeffsA_v is the linear term, and NLcoeffsA_v are the quadratic 

686 component. For LSSTCam, it has been observed that the linear_term_v >> 

687 nonlinear_term_v. 

688 """ 

689 targetMaskedImage = thisExposure.maskedImage 

690 targetDetector = thisExposure.getDetector() 

691 if self.hasCrosstalk is False: 

692 self.fromDetector(targetDetector, coeffVector=crosstalkCoeffs) 

693 

694 # TODO: Remove on DM-48394 

695 if isTrimmed is not None: 

696 warnings.warn( 

697 "The isTrimmed option has been deprecated and will be removed after v29.", 

698 FutureWarning, 

699 ) 

700 isTrimmed = isTrimmedImage(targetMaskedImage, targetDetector) 

701 

702 # TODO: Remove on DM-48394 

703 if parallelOverscan is not None: 

704 warnings.warn( 

705 "The parallelOverscan option has been deprecated and will be removed after v29.", 

706 FutureWarning, 

707 ) 

708 if detectorConfig is not None: 

709 warnings.warn( 

710 "The detectorConfig option has been deprecated and will be removed after v29.", 

711 FutureWarning, 

712 ) 

713 

714 numAmps = len(targetDetector) 

715 if numAmps != self.nAmp: 

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

717 f"{numAmps} in {targetDetector.getName()}") 

718 

719 if doSqrCrosstalk and crosstalkCoeffsSqr is None: 

720 raise RuntimeError("Attempted to perform NL crosstalk correction without NL " 

721 "crosstalk coefficients.") 

722 

723 if fullAmplifier and (backgroundMethod != "None"): 

724 raise RuntimeError("Cannot do full amplifier with background subtraction.") 

725 

726 if sourceExposure: 

727 sourceMaskedImage = sourceExposure.maskedImage 

728 sourceDetector = sourceExposure.getDetector() 

729 else: 

730 sourceMaskedImage = targetMaskedImage 

731 sourceDetector = targetDetector 

732 

733 if crosstalkCoeffs is not None: 

734 coeffs = np.asarray(crosstalkCoeffs).copy() 

735 else: 

736 coeffs = np.asarray(self.coeffs).copy() 

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

738 

739 if doSqrCrosstalk: 

740 coeffsSqr = np.asarray(crosstalkCoeffsSqr).copy() 

741 self.log.debug("CT COEFF SQR: %s", coeffsSqr) 

742 else: 

743 coeffsSqr = np.zeros_like(coeffs) 

744 

745 # Check for valid values; set to 0 otherwise. 

746 badCoeffs = ~np.isfinite(coeffs) | (coeffs == 0.0) 

747 coeffs[badCoeffs] = 0.0 

748 coeffsSqr[badCoeffs] = 0.0 

749 if crosstalkCoeffsValid is not None: 

750 coeffs[~crosstalkCoeffsValid] = 0.0 

751 coeffsSqr[~crosstalkCoeffsValid] = 0.0 

752 

753 if badAmpDict: 

754 for index, amp in enumerate(sourceDetector): 

755 if badAmpDict[amp.getName()]: 

756 coeffs[index, :] = 0.0 

757 coeffs[:, index] = 0.0 

758 

759 # Compute backgrounds if requested. 

760 backgrounds = {amp.getName(): 0.0 for amp in sourceDetector} 

761 if backgroundMethod == "AMP": 

762 backgrounds = { 

763 amp.getName(): self.calculateBackground( 

764 sourceMaskedImage[self._getAppropriateBBox( 

765 amp, 

766 isTrimmed, 

767 False, 

768 )], badPixels) 

769 for amp in sourceDetector 

770 } 

771 elif backgroundMethod == "DETECTOR": 

772 background = self.calculateBackground(sourceMaskedImage, badPixels) 

773 backgrounds = {amp.getName(): background for amp in sourceDetector} 

774 

775 # Add the crosstalk mask plane to the target mask. 

776 sourceCrosstalkPlane = sourceMaskedImage.mask.addMaskPlane(crosstalkStr) 

777 if sourceExposure is not None: 

778 targetCrosstalkPlane = targetMaskedImage.mask.addMaskPlane(crosstalkStr) 

779 else: 

780 targetCrosstalkPlane = sourceCrosstalkPlane 

781 

782 # If we are not doing subtrahend masking, the CROSSTALK mask bit 

783 # is set according to the counts in the source pixel (above 

784 # background), regardless of the crosstalk coefficient value. 

785 if not doSubtrahendMasking: 

786 # When we are not using subtrahend masking, we set the mask. 

787 thresholdBackground = self.calculateBackground(sourceMaskedImage, badPixels) 

788 toMask = (sourceMaskedImage.image.array >= (minPixelToMask + thresholdBackground)) 

789 sourceMaskedImage.mask.array[toMask] |= sourceMaskedImage.mask.getPlaneBitMask(crosstalkStr) 

790 

791 crosstalk = sourceMaskedImage.mask.getPlaneBitMask(crosstalkStr) 

792 

793 # Define a subtrahend image to contain all the scaled crosstalk 

794 # signals. These will be applied to the target image. 

795 subtrahend = targetMaskedImage.Factory(targetMaskedImage.getBBox()) 

796 subtrahend.set((0, 0, 0)) 

797 

798 # If we are ignoring variance and doing subtrahend masking, then 

799 # we can work on only the image plane (3x speed increase). 

800 imageOnly = ignoreVariance and doSubtrahendMasking 

801 

802 for sourceIndex, sourceAmp in enumerate(sourceDetector): 

803 for targetIndex, targetAmp in enumerate(targetDetector): 

804 coeff = coeffs[sourceIndex, targetIndex] 

805 coeffSqr = coeffsSqr[sourceIndex, targetIndex] 

806 

807 if coeff == 0.0: 

808 continue 

809 

810 targetBBox = self._getAppropriateBBox(targetAmp, isTrimmed, fullAmplifier) 

811 

812 # The extractAmp() method will always make a copy of the source 

813 # amplifier data. 

814 if imageOnly: 

815 sourceImage = self.extractAmp( 

816 sourceMaskedImage.image, 

817 sourceAmp, 

818 targetAmp, 

819 isTrimmed=isTrimmed, 

820 fullAmplifier=fullAmplifier, 

821 ) 

822 targetImage = subtrahend[targetBBox].image 

823 else: 

824 sourceImage = self.extractAmp( 

825 sourceMaskedImage, 

826 sourceAmp, 

827 targetAmp, 

828 isTrimmed=isTrimmed, 

829 fullAmplifier=fullAmplifier, 

830 ) 

831 targetImage = subtrahend[targetBBox] 

832 

833 # Remove all other masks from copied sourceImage. 

834 sourceImage.mask.array[:] &= crosstalk 

835 

836 if backgrounds[sourceAmp.getName()] != 0.0: 

837 sourceImage -= backgrounds[sourceAmp.getName()] 

838 

839 # This operation will also transfer the CROSSTALK mask bit from 

840 # above to the target (subtrahend) image if we are using a 

841 # masked image. 

842 targetImage.scaledPlus(coeff, sourceImage) 

843 if coeffSqr != 0.0: 

844 sourceImage.scaledMultiplies(1.0, sourceImage) 

845 targetImage.scaledPlus(coeffSqr, sourceImage) 

846 

847 # Clear the mask in the target image, because the subtrahend image 

848 # contains the crosstalk mask. 

849 sourceMaskedImage.mask.clearMaskPlane(sourceCrosstalkPlane) 

850 if sourceExposure is not None: 

851 targetMaskedImage.mask.clearMaskPlane(targetCrosstalkPlane) 

852 

853 if doSubtrahendMasking: 

854 # Set crosstalkStr bit only for those pixels that have 

855 # been significantly modified (i.e., those masked as such 

856 # in 'subtrahend'), not necessarily those that are bright 

857 # originally. 

858 

859 # The existing mask in the subtrahend comes from the 

860 # threshold set above. It should be cleared so we can 

861 # recalculate it. 

862 subtrahend.mask.clearMaskPlane(targetCrosstalkPlane) 

863 

864 # For masking purposes, we only really care when the 

865 # correction is significantly different than the median 

866 # value on that amplifier (which includes the contribution 

867 # of every other amplifier background that crosstalks onto 

868 # that amplifier). Subtract and save this "background" 

869 # level, so we can threshold to set the mask relative to 

870 # that background, but still include that contribution in 

871 # the correction we're applying. 

872 subtrahendBackgrounds = {} 

873 for amp in targetDetector: 

874 # Note that we never want the full amplifier for background 

875 # calculations. 

876 bbox = self._getAppropriateBBox(amp, isTrimmed, False) 

877 ampData = subtrahend[bbox] 

878 background = np.median(ampData.image.array) 

879 subtrahendBackgrounds[amp.getName()] = background 

880 ampData.image.array[:, :] -= background 

881 self.log.debug(f"Subtrahend background level: {amp.getName()} {background}") 

882 

883 toMask = (subtrahend.image.array >= minPixelToMask) | (subtrahend.image.array <= -minPixelToMask) 

884 subtrahend.mask.array[toMask] |= subtrahend.mask.getPlaneBitMask(crosstalkStr) 

885 

886 # Put the backgrounds back. 

887 for amp in targetDetector: 

888 bbox = self._getAppropriateBBox(amp, isTrimmed, False) 

889 ampData = subtrahend[bbox] 

890 background = subtrahendBackgrounds[amp.getName()] 

891 ampData.image.array[:, :] += background 

892 

893 # Subtract subtrahend from input. The mask plane is fully 

894 # populated, so this operation also sets the ``crosstalkStr`` 

895 # bit where applicable. 

896 targetMaskedImage -= subtrahend 

897 

898 

899class CrosstalkConfig(Config): 

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

901 minPixelToMask = Field( 

902 dtype=float, 

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

904 default=45000 

905 ) 

906 crosstalkMaskPlane = Field( 

907 dtype=str, 

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

909 default="CROSSTALK" 

910 ) 

911 doSubtrahendMasking = Field( 

912 dtype=bool, 

913 doc="Use subtrahend image thresholding instead of input image thesholding to set crosstalk mask?", 

914 default=False, 

915 ) 

916 crosstalkBackgroundMethod = ChoiceField( 

917 dtype=str, 

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

919 default="None", 

920 allowed={ 

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

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

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

924 }, 

925 ) 

926 useConfigCoefficients = Field( 

927 dtype=bool, 

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

929 default=False, 

930 ) 

931 crosstalkValues = ListField( 

932 dtype=float, 

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

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

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

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

937 default=[0.0], 

938 ) 

939 crosstalkShape = ListField( 

940 dtype=int, 

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

942 default=[1], 

943 ) 

944 doQuadraticCrosstalkCorrection = Field( 

945 dtype=bool, 

946 doc="Use quadratic crosstalk coefficients in the crosstalk correction", 

947 default=False, 

948 ) 

949 

950 def getCrosstalk(self, detector=None): 

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

952 shape. 

953 

954 Parameters 

955 ---------- 

956 detector : `lsst.afw.cameraGeom.detector` 

957 Detector that is to be crosstalk corrected. 

958 

959 Returns 

960 ------- 

961 coeffs : `numpy.ndarray` 

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

963 

964 Raises 

965 ------ 

966 RuntimeError 

967 Raised if no coefficients could be generated from this 

968 detector/configuration. 

969 """ 

970 if self.useConfigCoefficients is True: 

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

972 if detector is not None: 

973 nAmp = len(detector) 

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

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

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

977 return coeffs 

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

979 # Assume the detector defines itself consistently. 

980 return detector.getCrosstalk() 

981 else: 

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

983 

984 def hasCrosstalk(self, detector=None): 

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

986 

987 Parameters 

988 ---------- 

989 detector : `lsst.afw.cameraGeom.detector` 

990 Detector that is to be crosstalk corrected. 

991 

992 Returns 

993 ------- 

994 hasCrosstalk : `bool` 

995 True if this detector/configuration has crosstalk coefficients 

996 defined. 

997 """ 

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

999 return True 

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

1001 return True 

1002 else: 

1003 return False 

1004 

1005 

1006class CrosstalkTask(Task): 

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

1008 ConfigClass = CrosstalkConfig 

1009 _DefaultName = 'isrCrosstalk' 

1010 

1011 def run( 

1012 self, 

1013 exposure, 

1014 crosstalk=None, 

1015 crosstalkSources=None, 

1016 isTrimmed=None, 

1017 camera=None, 

1018 parallelOverscanRegion=None, 

1019 detectorConfig=None, 

1020 fullAmplifier=False, 

1021 gains=None, 

1022 badAmpDict=None, 

1023 ignoreVariance=False, 

1024 ): 

1025 """Apply intra-detector crosstalk correction 

1026 

1027 Parameters 

1028 ---------- 

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

1030 Exposure for which to remove crosstalk. 

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

1032 External crosstalk calibration to apply. Constructed from 

1033 detector if not found. 

1034 crosstalkSources : `defaultdict`, optional 

1035 Image data for other detectors that are sources of 

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

1037 of the other detectors, with the values containing 

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

1039 as ``exposure``. 

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

1041 isTrimmed : `bool`, optional 

1042 This option has been deprecated and does not do anything. 

1043 It will be removed after v29. 

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

1045 Camera associated with this exposure. Only used for 

1046 inter-chip matching. 

1047 parallelOverscanRegion : `bool`, optional 

1048 This option has been deprecated and will be removed after v29. 

1049 detectorConfig : `lsst.ip.isr.OverscanDetectorConfig`, optional 

1050 This option has been deprecated and will be removed after v29. 

1051 fullAmplifier : `bool`, optional 

1052 Use full amplifier and not just imaging region. 

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

1054 Dictionary of amp name to gain. Required if there is a unit 

1055 mismatch between the exposure and the crosstalk matrix. 

1056 badAmpDict : `dict` [`str`, `bool`], optional 

1057 Dictionary to identify bad amplifiers that should not be 

1058 source or target for crosstalk correction. 

1059 ignoreVariance : `bool`, optional 

1060 Ignore variance plane when applying crosstalk? 

1061 

1062 Raises 

1063 ------ 

1064 RuntimeError 

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

1066 crosstalk correction. Also raised if the crosstalkSource 

1067 is not an expected type. 

1068 """ 

1069 # TODO: Remove on DM-48394 

1070 if isTrimmed is not None: 

1071 warnings.warn( 

1072 "The isTrimmed option has been deprecated and will be removed after v29.", 

1073 FutureWarning, 

1074 ) 

1075 isTrimmed = isTrimmedImage(exposure.maskedImage, exposure.getDetector()) 

1076 

1077 # TODO: Remove on DM-48394 

1078 if parallelOverscanRegion is not None: 

1079 warnings.warn( 

1080 "The parallelOverscanRegion option has been deprecated and will be removed after v29.", 

1081 FutureWarning, 

1082 ) 

1083 if detectorConfig is not None: 

1084 warnings.warn( 

1085 "The detectorConfig option has been deprecated and will be removed after v29.", 

1086 FutureWarning, 

1087 ) 

1088 

1089 if not crosstalk: 

1090 crosstalk = CrosstalkCalib(log=self.log) 

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

1092 coeffVector=self.config.crosstalkValues) 

1093 if not crosstalk.log: 

1094 crosstalk.log = self.log 

1095 

1096 doSqrCrosstalk = self.config.doQuadraticCrosstalkCorrection 

1097 

1098 if doSqrCrosstalk and crosstalk.coeffsSqr is None: 

1099 raise RuntimeError("Attempted to perform NL crosstalk correction without NL " 

1100 "crosstalk coefficients.") 

1101 if doSqrCrosstalk: 

1102 crosstalkCoeffsSqr = crosstalk.coeffsSqr 

1103 else: 

1104 crosstalkCoeffsSqr = None 

1105 

1106 if not crosstalk.hasCrosstalk: 

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

1108 

1109 # Get the exposure units; defaults to adu. 

1110 exposureUnits = exposure.metadata.get("LSST ISR UNITS", "adu") 

1111 invertGains = False 

1112 gainApply = False 

1113 if crosstalk.crosstalkRatiosUnits != exposureUnits: 

1114 detector = exposure.getDetector() 

1115 

1116 if gains is None and np.all(crosstalk.fitGains == 0.0): 

1117 raise RuntimeError( 

1118 f"Unit mismatch between exposure ({exposureUnits}) and " 

1119 f"crosstalk ratios ({crosstalk.crosstalkRatiosUnits}) and " 

1120 "no gains were supplied or available in crosstalk calibration.", 

1121 ) 

1122 elif gains is None: 

1123 self.log.info("Using crosstalk calib fitGains for gain corrections.") 

1124 gains = {} 

1125 for i, amp in enumerate(detector): 

1126 gains[amp.getName()] = crosstalk.fitGains[i] 

1127 

1128 # Check individual gains for finite-ness. 

1129 gainArray = np.zeros(len(detector)) 

1130 for i, amp in enumerate(detector): 

1131 gainArray[i] = gains[amp.getName()] 

1132 badGains = (~np.isfinite(gainArray) | (gainArray == 0.0)) 

1133 if np.any(badGains): 

1134 if np.all(badGains): 

1135 raise RuntimeError("No valid (finite, non-zero) gains found for crosstalk correction.") 

1136 

1137 badAmpNames = [amp.getName() for i, amp in enumerate(detector) if badGains[i]] 

1138 

1139 self.log.warning("Illegal gain value found for %d amplifiers %r in crosstalk correction; " 

1140 "substituting with median gain.", badGains.sum(), badAmpNames) 

1141 medGain = np.median(gainArray[~badGains]) 

1142 gainArray[badGains] = medGain 

1143 for i, amp in enumerate(detector): 

1144 gains[amp.getName()] = gainArray[i] 

1145 

1146 gainApply = True 

1147 

1148 if crosstalk.crosstalkRatiosUnits == "adu": 

1149 invertGains = True 

1150 

1151 if exposureUnits == "adu": 

1152 self.log.info("Temporarily applying gains to perform crosstalk correction " 

1153 "because crosstalk is in electron units and exposure is in " 

1154 "adu units.") 

1155 else: 

1156 self.log.info("Temporarily un-applying gains to perform crosstalk correction " 

1157 "because crosstalk is in adu units and exposure is in " 

1158 "electron units.") 

1159 

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

1161 

1162 with gainContext( 

1163 exposure, 

1164 exposure.image, 

1165 gainApply, 

1166 gains=gains, 

1167 invert=invertGains, 

1168 isTrimmed=isTrimmed, 

1169 ): 

1170 crosstalk.subtractCrosstalk( 

1171 exposure, 

1172 crosstalkCoeffs=crosstalk.coeffs, 

1173 crosstalkCoeffsSqr=crosstalkCoeffsSqr, 

1174 crosstalkCoeffsValid=crosstalk.coeffValid, 

1175 minPixelToMask=self.config.minPixelToMask, 

1176 doSubtrahendMasking=self.config.doSubtrahendMasking, 

1177 crosstalkStr=self.config.crosstalkMaskPlane, 

1178 backgroundMethod=self.config.crosstalkBackgroundMethod, 

1179 doSqrCrosstalk=doSqrCrosstalk, 

1180 fullAmplifier=fullAmplifier, 

1181 badAmpDict=badAmpDict, 

1182 ignoreVariance=ignoreVariance, 

1183 ) 

1184 

1185 if crosstalk.interChip: 

1186 if crosstalkSources: 

1187 # Parse crosstalkSources: Identify which detectors we have 

1188 # available 

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

1190 # Received afwImage.Exposure 

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

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

1193 # Received dafButler.DeferredDatasetHandle 

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

1195 sourceNames = [camera[detectorId].getName() for detectorId in detectorList] 

1196 else: 

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

1198 type(crosstalkSources[0])) 

1199 

1200 for detName in crosstalk.interChip: 

1201 if detName not in sourceNames: 

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

1203 detName, sourceNames) 

1204 continue 

1205 # Get the coefficients. 

1206 interChipCoeffs = crosstalk.interChip[detName] 

1207 

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

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

1210 # Dereference the dafButler.DeferredDatasetHandle. 

1211 sourceExposure = sourceExposure.get() 

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

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

1214 type(sourceExposure)) 

1215 

1216 if sourceExposure.getBBox() != exposure.getBBox(): 

1217 raise RuntimeError( 

1218 "Mis-match between exposure bounding box and crosstalk source " 

1219 "exposure bounding box. Were these run with the same trim state?", 

1220 ) 

1221 

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

1223 exposure.getDetector().getName(), 

1224 sourceExposure.getDetector().getName()) 

1225 crosstalk.subtractCrosstalk( 

1226 exposure, 

1227 sourceExposure=sourceExposure, 

1228 crosstalkCoeffs=interChipCoeffs, 

1229 minPixelToMask=self.config.minPixelToMask, 

1230 crosstalkStr=self.config.crosstalkMaskPlane, 

1231 backgroundMethod=self.config.crosstalkBackgroundMethod, 

1232 ignoreVariance=ignoreVariance, 

1233 ) 

1234 else: 

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

1236 

1237 

1238class NullCrosstalkTask(CrosstalkTask): 

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

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