Coverage for python/lsst/pipe/tasks/subtractBrightStars.py: 25%

204 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-11-29 10:45 +0000

1# This file is part of pipe_tasks. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

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

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22"""Retrieve extended PSF model and subtract bright stars at visit level.""" 

23 

24__all__ = ["SubtractBrightStarsConnections", "SubtractBrightStarsConfig", "SubtractBrightStarsTask"] 

25 

26import logging 

27from functools import reduce 

28from operator import ior 

29 

30import numpy as np 

31from lsst.afw.geom import SpanSet, Stencil 

32from lsst.afw.image import Exposure, ExposureF, MaskedImageF 

33from lsst.afw.math import ( 

34 StatisticsControl, 

35 WarpingControl, 

36 makeStatistics, 

37 rotateImageBy90, 

38 stringToStatisticsProperty, 

39 warpImage, 

40) 

41from lsst.geom import Box2I, Point2D, Point2I 

42from lsst.meas.algorithms import LoadReferenceObjectsConfig, ReferenceObjectLoader 

43from lsst.meas.algorithms.brightStarStamps import BrightStarStamp, BrightStarStamps 

44from lsst.pex.config import ChoiceField, ConfigField, Field, ListField 

45from lsst.pipe.base import PipelineTask, PipelineTaskConfig, PipelineTaskConnections, Struct 

46from lsst.pipe.base.connectionTypes import Input, Output, PrerequisiteInput 

47from lsst.pipe.tasks.processBrightStars import ProcessBrightStarsTask 

48 

49logger = logging.getLogger(__name__) 

50 

51 

52class SubtractBrightStarsConnections( 

53 PipelineTaskConnections, 

54 dimensions=("instrument", "visit", "detector"), 

55 defaultTemplates={ 

56 "outputExposureName": "brightStar_subtracted", 

57 "outputBackgroundName": "brightStars", 

58 "badStampsName": "brightStars", 

59 }, 

60): 

61 inputExposure = Input( 

62 doc="Input exposure from which to subtract bright star stamps.", 

63 name="calexp", 

64 storageClass="ExposureF", 

65 dimensions=( 

66 "visit", 

67 "detector", 

68 ), 

69 ) 

70 inputBrightStarStamps = Input( 

71 doc="Set of preprocessed postage stamps, each centered on a single bright star.", 

72 name="brightStarStamps", 

73 storageClass="BrightStarStamps", 

74 dimensions=( 

75 "visit", 

76 "detector", 

77 ), 

78 ) 

79 inputExtendedPsf = Input( 

80 doc="Extended PSF model.", 

81 name="extended_psf", 

82 storageClass="ExtendedPsf", 

83 dimensions=("band",), 

84 ) 

85 skyCorr = Input( 

86 doc="Input Sky Correction to be subtracted from the calexp if ``doApplySkyCorr``=True.", 

87 name="skyCorr", 

88 storageClass="Background", 

89 dimensions=( 

90 "instrument", 

91 "visit", 

92 "detector", 

93 ), 

94 ) 

95 refCat = PrerequisiteInput( 

96 doc="Reference catalog that contains bright star positions", 

97 name="gaia_dr2_20200414", 

98 storageClass="SimpleCatalog", 

99 dimensions=("skypix",), 

100 multiple=True, 

101 deferLoad=True, 

102 ) 

103 outputExposure = Output( 

104 doc="Exposure with bright stars subtracted.", 

105 name="{outputExposureName}_calexp", 

106 storageClass="ExposureF", 

107 dimensions=( 

108 "visit", 

109 "detector", 

110 ), 

111 ) 

112 outputBackgroundExposure = Output( 

113 doc="Exposure containing only the modelled bright stars.", 

114 name="{outputBackgroundName}_calexp_background", 

115 storageClass="ExposureF", 

116 dimensions=( 

117 "visit", 

118 "detector", 

119 ), 

120 ) 

121 outputBadStamps = Output( 

122 doc="The stamps that are not normalized and consequently not subtracted from the exposure.", 

123 name="{badStampsName}_unsubtracted_stamps", 

124 storageClass="BrightStarStamps", 

125 dimensions=( 

126 "visit", 

127 "detector", 

128 ), 

129 ) 

130 

131 def __init__(self, *, config=None): 

132 super().__init__(config=config) 

133 if not config.doApplySkyCorr: 

134 self.inputs.remove("skyCorr") 

135 

136 

137class SubtractBrightStarsConfig(PipelineTaskConfig, pipelineConnections=SubtractBrightStarsConnections): 

138 """Configuration parameters for SubtractBrightStarsTask""" 

139 

140 doWriteSubtractor = Field[bool]( 

141 doc="Should an exposure containing all bright star models be written to disk?", 

142 default=True, 

143 ) 

144 doWriteSubtractedExposure = Field[bool]( 

145 doc="Should an exposure with bright stars subtracted be written to disk?", 

146 default=True, 

147 ) 

148 magLimit = Field[float]( 

149 doc="Magnitude limit, in Gaia G; all stars brighter than this value will be subtracted", 

150 default=18, 

151 ) 

152 minValidAnnulusFraction = Field[float]( 

153 doc="Minimum number of valid pixels that must fall within the annulus for the bright star to be " 

154 "saved for subsequent generation of a PSF.", 

155 default=0.0, 

156 ) 

157 numSigmaClip = Field[float]( 

158 doc="Sigma for outlier rejection; ignored if annularFluxStatistic != 'MEANCLIP'.", 

159 default=4, 

160 ) 

161 numIter = Field[int]( 

162 doc="Number of iterations of outlier rejection; ignored if annularFluxStatistic != 'MEANCLIP'.", 

163 default=3, 

164 ) 

165 warpingKernelName = ChoiceField[str]( 

166 doc="Warping kernel", 

167 default="lanczos5", 

168 allowed={ 

169 "bilinear": "bilinear interpolation", 

170 "lanczos3": "Lanczos kernel of order 3", 

171 "lanczos4": "Lanczos kernel of order 4", 

172 "lanczos5": "Lanczos kernel of order 5", 

173 "lanczos6": "Lanczos kernel of order 6", 

174 "lanczos7": "Lanczos kernel of order 7", 

175 }, 

176 ) 

177 scalingType = ChoiceField[str]( 

178 doc="How the model should be scaled to each bright star; implemented options are " 

179 "`annularFlux` to reuse the annular flux of each stamp, or `leastSquare` to perform " 

180 "least square fitting on each pixel with no bad mask plane set.", 

181 default="leastSquare", 

182 allowed={ 

183 "annularFlux": "reuse BrightStarStamp annular flux measurement", 

184 "leastSquare": "find least square scaling factor", 

185 }, 

186 ) 

187 annularFluxStatistic = ChoiceField[str]( 

188 doc="Type of statistic to use to compute annular flux.", 

189 default="MEANCLIP", 

190 allowed={ 

191 "MEAN": "mean", 

192 "MEDIAN": "median", 

193 "MEANCLIP": "clipped mean", 

194 }, 

195 ) 

196 badMaskPlanes = ListField[str]( 

197 doc="Mask planes that, if set, lead to associated pixels not being included in the computation of " 

198 "the scaling factor (`BAD` should always be included). Ignored if scalingType is `annularFlux`, " 

199 "as the stamps are expected to already be normalized.", 

200 # Note that `BAD` should always be included, as secondary detected 

201 # sources (i.e., detected sources other than the primary source of 

202 # interest) also get set to `BAD`. 

203 default=("BAD", "CR", "CROSSTALK", "EDGE", "NO_DATA", "SAT", "SUSPECT", "UNMASKEDNAN"), 

204 ) 

205 subtractionBox = ListField[int]( 

206 doc="Size of the stamps to be extracted, in pixels.", 

207 default=(250, 250), 

208 ) 

209 subtractionBoxBuffer = Field[float]( 

210 doc=( 

211 "'Buffer' (multiplicative) factor to be applied to determine the size of the stamp the " 

212 "processed stars will be saved in. This is also the size of the extended PSF model. The buffer " 

213 "region is masked and contain no data and subtractionBox determines the region where contains " 

214 "the data." 

215 ), 

216 default=1.1, 

217 ) 

218 doApplySkyCorr = Field[bool]( 

219 doc="Apply full focal plane sky correction before extracting stars?", 

220 default=True, 

221 ) 

222 refObjLoader = ConfigField[LoadReferenceObjectsConfig]( 

223 doc="Reference object loader for astrometric calibration.", 

224 ) 

225 

226 

227class SubtractBrightStarsTask(PipelineTask): 

228 """Use an extended PSF model to subtract bright stars from a calibrated 

229 exposure (i.e. at single-visit level). 

230 

231 This task uses both a set of bright star stamps produced by 

232 `~lsst.pipe.tasks.processBrightStars.ProcessBrightStarsTask` 

233 and an extended PSF model produced by 

234 `~lsst.pipe.tasks.extended_psf.MeasureExtendedPsfTask`. 

235 """ 

236 

237 ConfigClass = SubtractBrightStarsConfig 

238 _DefaultName = "subtractBrightStars" 

239 

240 def __init__(self, *args, **kwargs): 

241 super().__init__(*args, **kwargs) 

242 # Placeholders to set up Statistics if scalingType is leastSquare. 

243 self.statsControl, self.statsFlag = None, None 

244 # Warping control; only contains shiftingALg provided in config. 

245 self.warpControl = WarpingControl(self.config.warpingKernelName) 

246 

247 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

248 # Docstring inherited. 

249 inputs = butlerQC.get(inputRefs) 

250 dataId = butlerQC.quantum.dataId 

251 refObjLoader = ReferenceObjectLoader( 

252 dataIds=[ref.datasetRef.dataId for ref in inputRefs.refCat], 

253 refCats=inputs.pop("refCat"), 

254 name=self.config.connections.refCat, 

255 config=self.config.refObjLoader, 

256 ) 

257 subtractor, _, badStamps = self.run(**inputs, dataId=dataId, refObjLoader=refObjLoader) 

258 if self.config.doWriteSubtractedExposure: 

259 outputExposure = inputs["inputExposure"].clone() 

260 outputExposure.image -= subtractor.image 

261 else: 

262 outputExposure = None 

263 outputBackgroundExposure = subtractor if self.config.doWriteSubtractor else None 

264 # In its current state, the code produces outputBadStamps which are the 

265 # stamps of stars that have not been subtracted from the image for any 

266 # reason. If all the stars are subtracted from the calexp, the output 

267 # is an empty fits file. 

268 output = Struct( 

269 outputExposure=outputExposure, 

270 outputBackgroundExposure=outputBackgroundExposure, 

271 outputBadStamps=badStamps, 

272 ) 

273 butlerQC.put(output, outputRefs) 

274 

275 def run( 

276 self, inputExposure, inputBrightStarStamps, inputExtendedPsf, dataId, skyCorr=None, refObjLoader=None 

277 ): 

278 """Iterate over all bright stars in an exposure to scale the extended 

279 PSF model before subtracting bright stars. 

280 

281 Parameters 

282 ---------- 

283 inputExposure : `~lsst.afw.image.ExposureF` 

284 The image from which bright stars should be subtracted. 

285 inputBrightStarStamps : 

286 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamps` 

287 Set of stamps centered on each bright star to be subtracted, 

288 produced by running 

289 `~lsst.pipe.tasks.processBrightStars.ProcessBrightStarsTask`. 

290 inputExtendedPsf : `~lsst.pipe.tasks.extended_psf.ExtendedPsf` 

291 Extended PSF model, produced by 

292 `~lsst.pipe.tasks.extended_psf.MeasureExtendedPsfTask`. 

293 dataId : `dict` or `~lsst.daf.butler.DataCoordinate` 

294 The dataId of the exposure (and detector) bright stars should be 

295 subtracted from. 

296 skyCorr : `~lsst.afw.math.backgroundList.BackgroundList`, optional 

297 Full focal plane sky correction, obtained by running 

298 `~lsst.pipe.tasks.skyCorrection.SkyCorrectionTask`. If 

299 `doApplySkyCorr` is set to `True`, `skyCorr` cannot be `None`. 

300 refObjLoader : `~lsst.meas.algorithms.ReferenceObjectLoader`, optional 

301 Loader to find objects within a reference catalog. 

302 

303 Returns 

304 ------- 

305 subtractorExp : `~lsst.afw.image.ExposureF` 

306 An Exposure containing a scaled bright star model fit to every 

307 bright star profile; its image can then be subtracted from the 

308 input exposure. 

309 invImages : `list` [`~lsst.afw.image.MaskedImageF`] 

310 A list of small images ("stamps") containing the model, each scaled 

311 to its corresponding input bright star. 

312 """ 

313 self.inputExpBBox = inputExposure.getBBox() 

314 if self.config.doApplySkyCorr and (skyCorr is not None): 

315 self.log.info( 

316 "Applying sky correction to exposure %s (exposure will be modified in-place).", dataId 

317 ) 

318 self.applySkyCorr(inputExposure, skyCorr) 

319 

320 # Create an empty image the size of the exposure. 

321 # TODO: DM-31085 (set mask planes). 

322 subtractorExp = ExposureF(bbox=inputExposure.getBBox()) 

323 subtractor = subtractorExp.maskedImage 

324 

325 # Make a copy of the input model. 

326 self.model = inputExtendedPsf(dataId["detector"]).clone() 

327 self.modelStampSize = self.model.getDimensions() 

328 # Number of 90 deg. rotations to reverse each stamp's rotation. 

329 self.inv90Rots = 4 - inputBrightStarStamps.nb90Rots % 4 

330 self.model = rotateImageBy90(self.model, self.inv90Rots) 

331 

332 brightStarList = self.makeBrightStarList(inputBrightStarStamps, inputExposure, refObjLoader) 

333 invImages = [] 

334 subtractor, invImages = self.buildSubtractor( 

335 inputBrightStarStamps, subtractor, invImages, multipleAnnuli=False 

336 ) 

337 if brightStarList: 

338 self.setMissedStarsStatsControl() 

339 # This may change when multiple star bins are used for PSF 

340 # creation. 

341 innerRadius = inputBrightStarStamps._innerRadius 

342 outerRadius = inputBrightStarStamps._outerRadius 

343 brightStarStamps, badStamps = BrightStarStamps.initAndNormalize( 

344 brightStarList, 

345 innerRadius=innerRadius, 

346 outerRadius=outerRadius, 

347 nb90Rots=self.warpOutputs.nb90Rots, 

348 imCenter=self.warper.modelCenter, 

349 use_archive=True, 

350 statsControl=self.missedStatsControl, 

351 statsFlag=self.missedStatsFlag, 

352 badMaskPlanes=self.warper.config.badMaskPlanes, 

353 discardNanFluxObjects=False, 

354 forceFindFlux=True, 

355 ) 

356 

357 self.psf_annular_fluxes = self.findPsfAnnularFluxes(brightStarStamps) 

358 subtractor, invImages = self.buildSubtractor( 

359 brightStarStamps, subtractor, invImages, multipleAnnuli=True 

360 ) 

361 else: 

362 badStamps = [] 

363 badStamps = BrightStarStamps(badStamps) 

364 

365 return subtractorExp, invImages, badStamps 

366 

367 def _setUpStatistics(self, exampleMask): 

368 """Configure statistics control and flag, for use if ``scalingType`` is 

369 `leastSquare`. 

370 """ 

371 if self.config.scalingType == "leastSquare": 

372 # Set the mask planes which will be ignored. 

373 andMask = reduce( 

374 ior, 

375 (exampleMask.getPlaneBitMask(bm) for bm in self.config.badMaskPlanes), 

376 ) 

377 self.statsControl = StatisticsControl( 

378 andMask=andMask, 

379 ) 

380 self.statsFlag = stringToStatisticsProperty("SUM") 

381 

382 def applySkyCorr(self, calexp, skyCorr): 

383 """Apply correction to the sky background level. 

384 Sky corrections can be generated via the SkyCorrectionTask within the 

385 pipe_tools module. Because the sky model used by that code extends over 

386 the entire focal plane, this can produce better sky subtraction. 

387 The calexp is updated in-place. 

388 

389 Parameters 

390 ---------- 

391 calexp : `~lsst.afw.image.Exposure` or `~lsst.afw.image.MaskedImage` 

392 Calibrated exposure. 

393 skyCorr : `~lsst.afw.math.backgroundList.BackgroundList` 

394 Full focal plane sky correction, obtained by running 

395 `~lsst.pipe.tasks.skyCorrection.SkyCorrectionTask`. 

396 """ 

397 if isinstance(calexp, Exposure): 

398 calexp = calexp.getMaskedImage() 

399 calexp -= skyCorr.getImage() 

400 

401 def scaleModel(self, model, star, inPlace=True, nb90Rots=0, psf_annular_flux=1.0): 

402 """Compute scaling factor to be applied to the extended PSF so that its 

403 amplitude matches that of an individual star. 

404 

405 Parameters 

406 ---------- 

407 model : `~lsst.afw.image.MaskedImageF` 

408 The extended PSF model, shifted (and potentially warped) to match 

409 the bright star position. 

410 star : `~lsst.meas.algorithms.brightStarStamps.BrightStarStamp` 

411 A stamp centered on the bright star to be subtracted. 

412 inPlace : `bool` 

413 Whether the model should be scaled in place. Default is `True`. 

414 nb90Rots : `int` 

415 The number of 90-degrees rotations to apply to the star stamp. 

416 psf_annular_flux: `float`, optional 

417 The annular flux of the PSF model at the radius where the flux of 

418 the given star is determined. This is 1 for stars present in 

419 inputBrightStarStamps, but can be different for stars that are 

420 missing from inputBrightStarStamps. 

421 

422 Returns 

423 ------- 

424 scalingFactor : `float` 

425 The factor by which the model image should be multiplied for it 

426 to be scaled to the input bright star. 

427 """ 

428 if self.config.scalingType == "annularFlux": 

429 scalingFactor = star.annularFlux * psf_annular_flux 

430 elif self.config.scalingType == "leastSquare": 

431 if self.statsControl is None: 

432 self._setUpStatistics(star.stamp_im.mask) 

433 starIm = star.stamp_im.clone() 

434 # Rotate the star postage stamp. 

435 starIm = rotateImageBy90(starIm, nb90Rots) 

436 # Reverse the prior star flux normalization ("unnormalize"). 

437 starIm *= star.annularFlux 

438 # The estimator of the scalingFactor (f) that minimizes (Y-fX)^2 

439 # is E[XY]/E[XX]. 

440 xy = starIm.clone() 

441 xy.image.array *= model.image.array 

442 xx = starIm.clone() 

443 xx.image.array = model.image.array**2 

444 # Compute the least squares scaling factor. 

445 xySum = makeStatistics(xy, self.statsFlag, self.statsControl).getValue() 

446 xxSum = makeStatistics(xx, self.statsFlag, self.statsControl).getValue() 

447 scalingFactor = xySum / xxSum if xxSum else 1 

448 if inPlace: 

449 model.image *= scalingFactor 

450 return scalingFactor 

451 

452 def _overrideWarperConfig(self): 

453 """Override the warper config with the config of this task. 

454 

455 This override is necessary for stars that are missing from the 

456 inputBrightStarStamps object but still need to be subtracted. 

457 """ 

458 # TODO: Replace these copied values with a warperConfig. 

459 self.warper.config.minValidAnnulusFraction = self.config.minValidAnnulusFraction 

460 self.warper.config.numSigmaClip = self.config.numSigmaClip 

461 self.warper.config.numIter = self.config.numIter 

462 self.warper.config.annularFluxStatistic = self.config.annularFluxStatistic 

463 self.warper.config.badMaskPlanes = self.config.badMaskPlanes 

464 self.warper.config.stampSize = self.config.subtractionBox 

465 self.warper.modelStampBuffer = self.config.subtractionBoxBuffer 

466 self.warper.config.magLimit = self.config.magLimit 

467 self.warper.setModelStamp() 

468 

469 def setMissedStarsStatsControl(self): 

470 """Configure statistics control for processing missing stars from 

471 inputBrightStarStamps. 

472 """ 

473 self.missedStatsControl = StatisticsControl( 

474 numSigmaClip=self.warper.config.numSigmaClip, 

475 numIter=self.warper.config.numIter, 

476 ) 

477 self.missedStatsFlag = stringToStatisticsProperty(self.warper.config.annularFluxStatistic) 

478 

479 def setWarpTask(self): 

480 """Create an instance of ProcessBrightStarsTask that will be used to 

481 produce stamps of stars to be subtracted. 

482 """ 

483 self.warper = ProcessBrightStarsTask() 

484 self._overrideWarperConfig() 

485 self.warper.modelCenter = self.modelStampSize[0] // 2, self.modelStampSize[1] // 2 

486 

487 def makeBrightStarList(self, inputBrightStarStamps, inputExposure, refObjLoader): 

488 """Make a list of bright stars that are missing from 

489 inputBrightStarStamps to be subtracted. 

490 

491 Parameters 

492 ---------- 

493 inputBrightStarStamps : 

494 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamps` 

495 Set of stamps centered on each bright star to be subtracted, 

496 produced by running 

497 `~lsst.pipe.tasks.processBrightStars.ProcessBrightStarsTask`. 

498 inputExposure : `~lsst.afw.image.ExposureF` 

499 The image from which bright stars should be subtracted. 

500 refObjLoader : `~lsst.meas.algorithms.ReferenceObjectLoader`, optional 

501 Loader to find objects within a reference catalog. 

502 

503 Returns 

504 ------- 

505 brightStarList: 

506 A list containing 

507 `lsst.meas.algorithms.brightStarStamps.BrightStarStamp` of stars to 

508 be subtracted. 

509 """ 

510 self.setWarpTask() 

511 missedStars = self.warper.extractStamps( 

512 inputExposure, refObjLoader=refObjLoader, inputBrightStarStamps=inputBrightStarStamps 

513 ) 

514 if missedStars.starStamps: 

515 self.warpOutputs = self.warper.warpStamps(missedStars.starStamps, missedStars.pixCenters) 

516 brightStarList = [ 

517 BrightStarStamp( 

518 stamp_im=warp, 

519 archive_element=transform, 

520 position=self.warpOutputs.xy0s[j], 

521 gaiaGMag=missedStars.gMags[j], 

522 gaiaId=missedStars.gaiaIds[j], 

523 minValidAnnulusFraction=self.warper.config.minValidAnnulusFraction, 

524 ) 

525 for j, (warp, transform) in enumerate( 

526 zip(self.warpOutputs.warpedStars, self.warpOutputs.warpTransforms) 

527 ) 

528 ] 

529 else: 

530 brightStarList = [] 

531 return brightStarList 

532 

533 def initAnnulusImage(self): 

534 """Initialize an annulus image of the given star. 

535 

536 Returns 

537 ------- 

538 annulusImage : `~lsst.afw.image.MaskedImageF` 

539 The initialized annulus image. 

540 """ 

541 maskPlaneDict = self.model.mask.getMaskPlaneDict() 

542 annulusImage = MaskedImageF(self.modelStampSize, planeDict=maskPlaneDict) 

543 annulusImage.mask.array[:] = 2 ** maskPlaneDict["NO_DATA"] 

544 return annulusImage 

545 

546 def createAnnulus(self, brightStarStamp): 

547 """Create a circular annulus around the given star. 

548 

549 The circular annulus is set based on the inner and outer optimal radii. 

550 These radii describe the annulus where the flux of the star is found. 

551 The aim is to create the same annulus for the PSF model, eventually 

552 measuring the model flux around that annulus. 

553 An optimal radius usually differs from the radius where the PSF model 

554 is normalized. 

555 

556 Parameters 

557 ---------- 

558 brightStarStamp : 

559 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamp` 

560 A stamp of a bright star to be subtracted. 

561 

562 Returns 

563 ------- 

564 annulus : `~lsst.afw.image.MaskedImageF` 

565 An annulus of the given star. 

566 """ 

567 # Create SpanSet of annulus. 

568 outerCircle = SpanSet.fromShape( 

569 brightStarStamp.optimalOuterRadius, Stencil.CIRCLE, offset=self.warper.modelCenter 

570 ) 

571 innerCircle = SpanSet.fromShape( 

572 brightStarStamp.optimalInnerRadius, Stencil.CIRCLE, offset=self.warper.modelCenter 

573 ) 

574 annulus = outerCircle.intersectNot(innerCircle) 

575 return annulus 

576 

577 def applyStatsControl(self, annulusImage): 

578 """Apply statistics control to the PSF annulus image. 

579 

580 Parameters 

581 ---------- 

582 annulusImage : `~lsst.afw.image.MaskedImageF` 

583 An image containing an annulus of the given model. 

584 

585 Returns 

586 ------- 

587 annularFlux: float 

588 The annular flux of the PSF model at the radius where the flux of 

589 the given star is determined. 

590 """ 

591 andMask = reduce( 

592 ior, (annulusImage.mask.getPlaneBitMask(bm) for bm in self.warper.config.badMaskPlanes) 

593 ) 

594 self.missedStatsControl.setAndMask(andMask) 

595 annulusStat = makeStatistics(annulusImage, self.missedStatsFlag, self.missedStatsControl) 

596 return annulusStat.getValue() 

597 

598 def findPsfAnnularFlux(self, brightStarStamp, maskedModel): 

599 """Find the annular flux of the PSF model within a specified annulus. 

600 

601 This flux will be used for re-scaling the PSF to the level of stars 

602 with bad stamps. Stars with bad stamps are those without a flux within 

603 the normalization annulus. 

604 

605 Parameters 

606 ---------- 

607 brightStarStamp : 

608 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamp` 

609 A stamp of a bright star to be subtracted. 

610 maskedModel : `~lsst.afw.image.MaskedImageF` 

611 A masked image of the PSF model. 

612 

613 Returns 

614 ------- 

615 annularFlux: float (between 0 and 1) 

616 The annular flux of the PSF model at the radius where the flux of 

617 the given star is determined. 

618 """ 

619 annulusImage = self.initAnnulusImage() 

620 annulus = self.createAnnulus(brightStarStamp) 

621 annulus.copyMaskedImage(maskedModel, annulusImage) 

622 annularFlux = self.applyStatsControl(annulusImage) 

623 return annularFlux 

624 

625 def findPsfAnnularFluxes(self, brightStarStamps): 

626 """Find the annular fluxes of the given PSF model. 

627 

628 Parameters 

629 ---------- 

630 brightStarStamps : 

631 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamps` 

632 The stamps of stars that will be subtracted from the exposure. 

633 

634 Returns 

635 ------- 

636 PsfAnnularFluxes: numpy.array 

637 A two column numpy.array containing annular fluxes of the PSF at 

638 radii where the flux for stars exist (could be found). 

639 

640 Notes 

641 ----- 

642 While the PSF model is normalized at a certain radius, the annular flux 

643 of a star around that radius might be impossible to find. Therefore, we 

644 have to scale the PSF model considering a radius where the star has an 

645 identified flux. To do that, the flux of the model should be found and 

646 used to adjust the scaling step. 

647 """ 

648 outerRadii = [] 

649 annularFluxes = [] 

650 maskedModel = MaskedImageF(self.model.image) 

651 # The model has wrong bbox values. Should be fixed in extended_psf.py? 

652 maskedModel.setXY0(0, 0) 

653 for star in brightStarStamps: 

654 if star.optimalOuterRadius not in outerRadii: 

655 annularFlux = self.findPsfAnnularFlux(star, maskedModel) 

656 outerRadii.append(star.optimalOuterRadius) 

657 annularFluxes.append(annularFlux) 

658 return np.array([outerRadii, annularFluxes]).T 

659 

660 def preparePlaneModelStamp(self, brightStarStamp): 

661 """Prepare the PSF plane model stamp. 

662 

663 It is called PlaneModel because, while it is a PSF model stamp that is 

664 warped and rotated to the same orientation of a chosen star, it is not 

665 yet scaled to the brightness level of the star. 

666 

667 Parameters 

668 ---------- 

669 brightStarStamp : 

670 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamp` 

671 The stamp of the star to which the PSF model will be scaled. 

672 

673 Returns 

674 ------- 

675 bbox: `~lsst.geom.Box2I` 

676 Contains the corner coordination and the dimensions of the model 

677 stamp. 

678 

679 invImage: `~lsst.afw.image.MaskedImageF` 

680 The extended PSF model, shifted (and potentially warped and 

681 rotated) to match the bright star position. 

682 

683 Raises 

684 ------ 

685 RuntimeError 

686 Raised if warping of the model failed. 

687 

688 Notes 

689 ----- 

690 Since detectors have different orientations, the PSF model should be 

691 rotated to match the orientation of the detectors in some cases. To do 

692 that, the code uses the inverse of the transform that is applied to the 

693 bright star stamp to match the orientation of the detector. 

694 """ 

695 # Set the origin. 

696 self.model.setXY0(brightStarStamp.position) 

697 # Create an empty destination image. 

698 invTransform = brightStarStamp.archive_element.inverted() 

699 invOrigin = Point2I(invTransform.applyForward(Point2D(brightStarStamp.position))) 

700 bbox = Box2I(corner=invOrigin, dimensions=self.modelStampSize) 

701 invImage = MaskedImageF(bbox) 

702 # Apply inverse transform. 

703 goodPix = warpImage(invImage, self.model, invTransform, self.warpControl) 

704 if not goodPix: 

705 # Do we want to find another way or just subtract the non-warped 

706 # scaled model? 

707 # Currently the code just leaves the failed ones un-subtracted. 

708 raise RuntimeError( 

709 f"Warping of a model failed for star {brightStarStamp.gaiaId}: no good pixel in output." 

710 ) 

711 return bbox, invImage 

712 

713 def addScaledModel(self, subtractor, brightStarStamp, multipleAnnuli=False): 

714 """Add the scaled model of the given star to the subtractor plane. 

715 

716 Parameters 

717 ---------- 

718 subtractor : `~lsst.afw.image.MaskedImageF` 

719 The full image containing the scaled model of bright stars to be 

720 subtracted from the input exposure. 

721 brightStarStamp : 

722 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamp` 

723 The stamp of the star of which the PSF model will be scaled and 

724 added to the subtractor. 

725 multipleAnnuli : bool, optional 

726 If true, the model should be scaled based on a flux at a radius 

727 other than its normalization radius. 

728 

729 Returns 

730 ------- 

731 subtractor : `~lsst.afw.image.MaskedImageF` 

732 The input subtractor full image with the added scaled model at the 

733 given star's location in the exposure. 

734 invImage: `~lsst.afw.image.MaskedImageF` 

735 The extended PSF model, shifted (and potentially warped) to match 

736 the bright star position. 

737 """ 

738 bbox, invImage = self.preparePlaneModelStamp(brightStarStamp) 

739 bbox.clip(self.inputExpBBox) 

740 if bbox.getArea() > 0: 

741 if multipleAnnuli: 

742 cond = self.psf_annular_fluxes[:, 0] == brightStarStamp.optimalOuterRadius 

743 psf_annular_flux = self.psf_annular_fluxes[cond, 1][0] 

744 self.scaleModel( 

745 invImage, 

746 brightStarStamp, 

747 inPlace=True, 

748 nb90Rots=self.inv90Rots, 

749 psf_annular_flux=psf_annular_flux, 

750 ) 

751 else: 

752 self.scaleModel(invImage, brightStarStamp, inPlace=True, nb90Rots=self.inv90Rots) 

753 # Replace NaNs before subtraction (all NaNs have the NO_DATA flag). 

754 invImage.image.array[np.isnan(invImage.image.array)] = 0 

755 subtractor[bbox] += invImage[bbox] 

756 return subtractor, invImage 

757 

758 def buildSubtractor(self, brightStarStamps, subtractor, invImages, multipleAnnuli=False): 

759 """Build an image containing potentially multiple scaled PSF models, 

760 each at the location of a given bright star. 

761 

762 Parameters 

763 ---------- 

764 brightStarStamps : 

765 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamps` 

766 Set of stamps centered on each bright star to be subtracted, 

767 produced by running 

768 `~lsst.pipe.tasks.processBrightStars.ProcessBrightStarsTask`. 

769 subtractor : `~lsst.afw.image.MaskedImageF` 

770 The Exposure that will contain the scaled model of bright stars to 

771 be subtracted from the exposure. 

772 invImages : `list` 

773 A list containing extended PSF models, shifted (and potentially 

774 warped) to match the bright stars positions. 

775 multipleAnnuli : bool, optional 

776 This will be passed to addScaledModel method, by default False. 

777 

778 Returns 

779 ------- 

780 subtractor : `~lsst.afw.image.MaskedImageF` 

781 An Exposure containing a scaled bright star model fit to every 

782 bright star profile; its image can then be subtracted from the 

783 input exposure. 

784 invImages: list 

785 A list containing the extended PSF models, shifted (and potentially 

786 warped) to match bright stars' positions. 

787 """ 

788 for star in brightStarStamps: 

789 if star.gaiaGMag < self.config.magLimit: 

790 try: 

791 # Add the scaled model at the star location to subtractor. 

792 subtractor, invImage = self.addScaledModel(subtractor, star, multipleAnnuli) 

793 invImages.append(invImage) 

794 except RuntimeError as err: 

795 logger.error(err) 

796 return subtractor, invImages