Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

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

25import numpy as np 

26from astropy.table import Table 

27 

28import lsst.afw.math 

29import lsst.afw.detection 

30import lsst.daf.butler 

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

32from lsst.pipe.base import Task 

33 

34from lsst.ip.isr import IsrCalib 

35 

36 

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

38 "NullCrosstalkTask"] 

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 : `lsst.log.Log`, 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 : `np.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 : `np.ndarray`, optional 

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

73 distribution of the crosstalk measurements. 

74 coeffNum : `np.ndarray`, optional 

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

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

77 coeffValid : `np.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` [`np.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 calibration. 

359 

360 The list of tables should create an identical calibration 

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

362 

363 Returns 

364 ------- 

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

366 List of tables containing the crosstalk calibration 

367 information. 

368 

369 """ 

370 tableList = [] 

371 self.updateMetadata() 

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

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

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

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

376 }]) 

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

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

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

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

381 catalog.meta = outMeta 

382 tableList.append(catalog) 

383 

384 if self.interChip: 

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

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

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

388 tableList.append(interChipTable) 

389 return tableList 

390 

391 # Implementation methods. 

392 @staticmethod 

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

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

395 

396 Parameters 

397 ---------- 

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

399 Image containing the amplifier of interest. 

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

401 Amplifier on image to extract. 

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

403 Target amplifier that the extracted image will be flipped 

404 to match. 

405 isTrimmed : `bool` 

406 The image is already trimmed. 

407 TODO : DM-15409 will resolve this. 

408 

409 Returns 

410 ------- 

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

412 Image of the amplifier in the desired configuration. 

413 """ 

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

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

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

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

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

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

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

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

422 

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

424 thisAmpCorner = amp.getReadoutCorner() 

425 targetAmpCorner = ampTarget.getReadoutCorner() 

426 

427 # Flipping is necessary only if the desired configuration doesn't match what we currently have 

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

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

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

431 

432 @staticmethod 

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

434 """Estimate median background in image. 

435 

436 Getting a great background model isn't important for crosstalk correction, 

437 since the crosstalk is at a low level. The median should be sufficient. 

438 

439 Parameters 

440 ---------- 

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

442 MaskedImage for which to measure background. 

443 badPixels : `list` of `str` 

444 Mask planes to ignore. 

445 Returns 

446 ------- 

447 bg : `float` 

448 Median background level. 

449 """ 

450 mask = mi.getMask() 

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

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

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

454 

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

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

457 crosstalkStr="CROSSTALK", isTrimmed=False, 

458 backgroundMethod="None"): 

459 """Subtract the crosstalk from thisExposure, optionally using a different source. 

460 

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

462 for pixels in a source amplifier that exceed ``minPixelToMask``. Note that 

463 the correction is applied to all pixels in the amplifier, but only those 

464 that have a substantial crosstalk are masked with ``crosstalkStr``. 

465 

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

467 enough if the crosstalk is small (e.g., coefficients < ~ 1e-3), but if it's 

468 larger you may want to iterate. 

469 

470 Parameters 

471 ---------- 

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

473 Exposure for which to subtract crosstalk. 

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

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

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

477 crosstalkCoeffs : `numpy.ndarray`, optional. 

478 Coefficients to use to correct crosstalk. 

479 badPixels : `list` of `str` 

480 Mask planes to ignore. 

481 minPixelToMask : `float` 

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

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

484 in target amplifier. 

485 crosstalkStr : `str` 

486 Mask plane name for pixels greatly modified by crosstalk 

487 (above minPixelToMask). 

488 isTrimmed : `bool` 

489 The image is already trimmed. 

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

491 backgroundMethod : `str` 

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

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

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

495 background subtraction. 

496 """ 

497 mi = thisExposure.getMaskedImage() 

498 mask = mi.getMask() 

499 detector = thisExposure.getDetector() 

500 if self.hasCrosstalk is False: 

501 self.fromDetector(detector, coeffVector=crosstalkCoeffs) 

502 

503 numAmps = len(detector) 

504 if numAmps != self.nAmp: 

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

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

507 

508 if sourceExposure: 

509 source = sourceExposure.getMaskedImage() 

510 sourceDetector = sourceExposure.getDetector() 

511 else: 

512 source = mi 

513 sourceDetector = detector 

514 

515 if crosstalkCoeffs is not None: 

516 coeffs = crosstalkCoeffs 

517 else: 

518 coeffs = self.coeffs 

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

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

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

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

523 # sense. 

524 thresholdBackground = self.calculateBackground(source, badPixels) 

525 

526 backgrounds = [0.0 for amp in sourceDetector] 

527 if backgroundMethod is None: 

528 pass 

529 elif backgroundMethod == "AMP": 

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

531 for amp in sourceDetector] 

532 elif backgroundMethod == "DETECTOR": 

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

534 

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

536 # significant crosstalk correction) 

537 crosstalkPlane = mask.addMaskPlane(crosstalkStr) 

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

539 lsst.afw.detection.Threshold(minPixelToMask 

540 + thresholdBackground)) 

541 footprints.setMask(mask, crosstalkStr) 

542 crosstalk = mask.getPlaneBitMask(crosstalkStr) 

543 

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

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

546 subtrahend.set((0, 0, 0)) 

547 

548 coeffs = coeffs.transpose() 

549 for ii, iAmp in enumerate(sourceDetector): 

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

551 for jj, jAmp in enumerate(detector): 

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

553 continue 

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

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

556 jImage -= backgrounds[jj] 

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

558 

559 # Set crosstalkStr bit only for those pixels that have been significantly modified (i.e., those 

560 # masked as such in 'subtrahend'), not necessarily those that are bright originally. 

561 mask.clearMaskPlane(crosstalkPlane) 

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

563 

564 

565class CrosstalkConfig(Config): 

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

567 minPixelToMask = Field( 

568 dtype=float, 

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

570 default=45000 

571 ) 

572 crosstalkMaskPlane = Field( 

573 dtype=str, 

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

575 default="CROSSTALK" 

576 ) 

577 crosstalkBackgroundMethod = ChoiceField( 

578 dtype=str, 

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

580 default="None", 

581 allowed={ 

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

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

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

585 }, 

586 ) 

587 useConfigCoefficients = Field( 

588 dtype=bool, 

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

590 default=False, 

591 ) 

592 crosstalkValues = ListField( 

593 dtype=float, 

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

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

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

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

598 default=[0.0], 

599 ) 

600 crosstalkShape = ListField( 

601 dtype=int, 

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

603 default=[1], 

604 ) 

605 

606 def getCrosstalk(self, detector=None): 

607 """Return a 2-D numpy array of crosstalk coefficients in the proper shape. 

608 

609 Parameters 

610 ---------- 

611 detector : `lsst.afw.cameraGeom.detector` 

612 Detector that is to be crosstalk corrected. 

613 

614 Returns 

615 ------- 

616 coeffs : `numpy.ndarray` 

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

618 

619 Raises 

620 ------ 

621 RuntimeError 

622 Raised if no coefficients could be generated from this detector/configuration. 

623 """ 

624 if self.useConfigCoefficients is True: 

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

626 if detector is not None: 

627 nAmp = len(detector) 

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

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

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

631 return coeffs 

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

633 # Assume the detector defines itself consistently. 

634 return detector.getCrosstalk() 

635 else: 

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

637 

638 def hasCrosstalk(self, detector=None): 

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

640 

641 Parameters 

642 ---------- 

643 detector : `lsst.afw.cameraGeom.detector` 

644 Detector that is to be crosstalk corrected. 

645 

646 Returns 

647 ------- 

648 hasCrosstalk : `bool` 

649 True if this detector/configuration has crosstalk coefficients defined. 

650 """ 

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

652 return True 

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

654 return True 

655 else: 

656 return False 

657 

658 

659class CrosstalkTask(Task): 

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

661 ConfigClass = CrosstalkConfig 

662 _DefaultName = 'isrCrosstalk' 

663 

664 def prepCrosstalk(self, dataRef, crosstalk=None): 

665 """Placeholder for crosstalk preparation method, e.g., for inter-detector crosstalk. 

666 

667 Parameters 

668 ---------- 

669 dataRef : `daf.persistence.butlerSubset.ButlerDataRef` 

670 Butler reference of the detector data to be processed. 

671 crosstalk : `~lsst.ip.isr.CrosstalkConfig` 

672 Crosstalk calibration that will be used. 

673 

674 See also 

675 -------- 

676 lsst.obs.decam.crosstalk.DecamCrosstalkTask.prepCrosstalk 

677 """ 

678 return 

679 

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

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

682 """Apply intra-detector crosstalk correction 

683 

684 Parameters 

685 ---------- 

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

687 Exposure for which to remove crosstalk. 

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

689 External crosstalk calibration to apply. Constructed from 

690 detector if not found. 

691 crosstalkSources : `defaultdict`, optional 

692 Image data for other detectors that are sources of 

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

694 of the other detectors, with the values containing 

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

696 as ``exposure``. 

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

698 isTrimmed : `bool`, optional 

699 The image is already trimmed. 

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

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

702 Camera associated with this exposure. Only used for 

703 inter-chip matching. 

704 

705 Raises 

706 ------ 

707 RuntimeError 

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

709 crosstalk correction. Also raised if the crosstalkSource 

710 is not an expected type. 

711 """ 

712 if not crosstalk: 

713 crosstalk = CrosstalkCalib(log=self.log) 

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

715 coeffVector=self.config.crosstalkValues) 

716 if not crosstalk.log: 

717 crosstalk.log = self.log 

718 if not crosstalk.hasCrosstalk: 

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

720 

721 else: 

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

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

724 minPixelToMask=self.config.minPixelToMask, 

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

726 backgroundMethod=self.config.crosstalkBackgroundMethod) 

727 

728 if crosstalk.interChip: 

729 if crosstalkSources: 

730 # Parse crosstalkSources: Identify which detectors we have available 

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

732 # Received afwImage.Exposure 

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

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

735 # Received dafButler.DeferredDatasetHandle 

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

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

738 else: 

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

740 type(crosstalkSources[0])) 

741 

742 for detName in crosstalk.interChip: 

743 if detName not in sourceNames: 

744 self.log.warn("Crosstalk lists %s, not found in sources: %s", 

745 detName, sourceNames) 

746 continue 

747 # Get the coefficients. 

748 interChipCoeffs = crosstalk.interChip[detName] 

749 

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

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

752 # Dereference the dafButler.DeferredDatasetHandle. 

753 sourceExposure = sourceExposure.get() 

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

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

756 type(sourceExposure)) 

757 

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

759 exposure.getDetector().getName(), 

760 sourceExposure.getDetector().getName()) 

761 crosstalk.subtractCrosstalk(exposure, sourceExposure=sourceExposure, 

762 crosstalkCoeffs=interChipCoeffs, 

763 minPixelToMask=self.config.minPixelToMask, 

764 crosstalkStr=self.config.crosstalkMaskPlane, 

765 isTrimmed=isTrimmed, 

766 backgroundMethod=self.config.crosstalkBackgroundMethod) 

767 else: 

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

769 

770 

771class NullCrosstalkTask(CrosstalkTask): 

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

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