Coverage for python/lsst/pipe/tasks/processBrightStars.py: 23%

188 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-19 05:09 -0700

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"""Extract bright star cutouts; normalize and warp to the same pixel grid.""" 

23 

24__all__ = ["ProcessBrightStarsConnections", "ProcessBrightStarsConfig", "ProcessBrightStarsTask"] 

25 

26import astropy.units as u 

27import numpy as np 

28from astropy.table import Table 

29from lsst.afw.cameraGeom import PIXELS, TAN_PIXELS 

30from lsst.afw.detection import FootprintSet, Threshold 

31from lsst.afw.geom.transformFactory import makeIdentityTransform, makeTransform 

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

33from lsst.afw.math import ( 

34 StatisticsControl, 

35 WarpingControl, 

36 rotateImageBy90, 

37 stringToStatisticsProperty, 

38 warpImage, 

39) 

40from lsst.geom import AffineTransform, Box2I, Extent2I, Point2D, Point2I, SpherePoint, radians 

41from lsst.meas.algorithms import LoadReferenceObjectsConfig, ReferenceObjectLoader 

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

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

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

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

46from lsst.utils.timer import timeMethod 

47 

48 

49class ProcessBrightStarsConnections(PipelineTaskConnections, dimensions=("instrument", "visit", "detector")): 

50 """Connections for ProcessBrightStarsTask.""" 

51 

52 inputExposure = Input( 

53 doc="Input exposure from which to extract bright star stamps.", 

54 name="calexp", 

55 storageClass="ExposureF", 

56 dimensions=("visit", "detector"), 

57 ) 

58 skyCorr = Input( 

59 doc="Input sky correction to be subtracted from the calexp if doApplySkyCorr=True.", 

60 name="skyCorr", 

61 storageClass="Background", 

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

63 ) 

64 refCat = PrerequisiteInput( 

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

66 name="gaia_dr3_20230707", 

67 storageClass="SimpleCatalog", 

68 dimensions=("skypix",), 

69 multiple=True, 

70 deferLoad=True, 

71 ) 

72 brightStarStamps = Output( 

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

74 name="brightStarStamps", 

75 storageClass="BrightStarStamps", 

76 dimensions=("visit", "detector"), 

77 ) 

78 

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

80 super().__init__(config=config) 

81 if not config.doApplySkyCorr: 

82 self.inputs.remove("skyCorr") 

83 

84 

85class ProcessBrightStarsConfig(PipelineTaskConfig, pipelineConnections=ProcessBrightStarsConnections): 

86 """Configuration parameters for ProcessBrightStarsTask.""" 

87 

88 magLimit = Field[float]( 

89 doc="Magnitude limit, in Gaia G; all stars brighter than this value will be processed.", 

90 default=18, 

91 ) 

92 stampSize = ListField[int]( 

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

94 default=(250, 250), 

95 ) 

96 modelStampBuffer = Field[float]( 

97 doc=( 

98 "'Buffer' factor to be applied to determine the size of the stamp the processed stars will be " 

99 "saved in. This will also be the size of the extended PSF model." 

100 ), 

101 default=1.1, 

102 ) 

103 doRemoveDetected = Field[bool]( 

104 doc="Whether secondary DETECTION footprints (i.e., footprints of objects other than the central " 

105 "primary object) should be changed to BAD.", 

106 default=True, 

107 ) 

108 doApplyTransform = Field[bool]( 

109 doc="Apply transform to bright star stamps to correct for optical distortions?", 

110 default=True, 

111 ) 

112 warpingKernelName = ChoiceField[str]( 

113 doc="Warping kernel.", 

114 default="lanczos5", 

115 allowed={ 

116 "bilinear": "bilinear interpolation", 

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

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

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

120 }, 

121 ) 

122 annularFluxRadii = ListField[int]( 

123 doc="Inner and outer radii of the annulus used to compute AnnularFlux for normalization, in pixels.", 

124 default=(70, 80), 

125 ) 

126 annularFluxStatistic = ChoiceField[str]( 

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

128 default="MEANCLIP", 

129 allowed={ 

130 "MEAN": "mean", 

131 "MEDIAN": "median", 

132 "MEANCLIP": "clipped mean", 

133 }, 

134 ) 

135 numSigmaClip = Field[float]( 

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

137 default=4, 

138 ) 

139 numIter = Field[int]( 

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

141 default=3, 

142 ) 

143 badMaskPlanes = ListField[str]( 

144 doc="Mask planes that identify pixels to not include in the computation of the annular flux.", 

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

146 ) 

147 minValidAnnulusFraction = Field[float]( 

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

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

150 default=0.0, 

151 ) 

152 doApplySkyCorr = Field[bool]( 

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

154 default=True, 

155 ) 

156 discardNanFluxStars = Field[bool]( 

157 doc="Should stars with NaN annular flux be discarded?", 

158 default=False, 

159 ) 

160 refObjLoader = ConfigField[LoadReferenceObjectsConfig]( 

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

162 ) 

163 

164 

165class ProcessBrightStarsTask(PipelineTask): 

166 """Extract bright star cutouts; normalize and warp to the same pixel grid. 

167 

168 This task is used to extract, process, and store small image cut-outs 

169 (or "postage stamps") around bright stars. It relies on three methods, 

170 called in succession: 

171 

172 `extractStamps` 

173 Find bright stars within the exposure using a reference catalog and 

174 extract a stamp centered on each. 

175 `warpStamps` 

176 Shift and warp each stamp to remove optical distortions and sample all 

177 stars on the same pixel grid. 

178 `measureAndNormalize` 

179 Compute the flux of an object in an annulus and normalize it. This is 

180 required to normalize each bright star stamp as their central pixels 

181 are likely saturated and/or contain ghosts, and cannot be used. 

182 """ 

183 

184 ConfigClass = ProcessBrightStarsConfig 

185 _DefaultName = "processBrightStars" 

186 

187 def __init__(self, initInputs=None, *args, **kwargs): 

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

189 self.setModelStamp() 

190 

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

192 inputs = butlerQC.get(inputRefs) 

193 inputs["dataId"] = str(butlerQC.quantum.dataId) 

194 refObjLoader = ReferenceObjectLoader( 

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

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

197 name=self.config.connections.refCat, 

198 config=self.config.refObjLoader, 

199 ) 

200 output = self.run(**inputs, refObjLoader=refObjLoader) 

201 # Only ingest stamp if it exists; prevent ingesting an empty FITS file. 

202 if output: 

203 butlerQC.put(output, outputRefs) 

204 

205 @timeMethod 

206 def run(self, inputExposure, refObjLoader=None, dataId=None, skyCorr=None): 

207 """Identify bright stars within an exposure using a reference catalog, 

208 extract stamps around each, then preprocess them. 

209 

210 Bright star preprocessing steps are: shifting, warping and potentially 

211 rotating them to the same pixel grid; computing their annular flux, 

212 and; normalizing them. 

213 

214 Parameters 

215 ---------- 

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

217 The image from which bright star stamps should be extracted. 

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

219 Loader to find objects within a reference catalog. 

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

221 The dataId of the exposure (including detector) that bright stars 

222 should be extracted from. 

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

224 Full focal plane sky correction obtained by `SkyCorrectionTask`. 

225 

226 Returns 

227 ------- 

228 brightStarResults : `~lsst.pipe.base.Struct` 

229 Results as a struct with attributes: 

230 

231 ``brightStarStamps`` 

232 (`~lsst.meas.algorithms.brightStarStamps.BrightStarStamps`) 

233 """ 

234 if self.config.doApplySkyCorr: 

235 self.log.info("Applying sky correction to exposure %s (exposure modified in-place).", dataId) 

236 self.applySkyCorr(inputExposure, skyCorr) 

237 

238 self.log.info("Extracting bright stars from exposure %s", dataId) 

239 # Extract stamps around bright stars. 

240 extractedStamps = self.extractStamps(inputExposure, refObjLoader=refObjLoader) 

241 if not extractedStamps.starStamps: 

242 self.log.info("No suitable bright star found.") 

243 return None 

244 # Warp (and shift, and potentially rotate) them. 

245 self.log.info( 

246 "Applying warp and/or shift to %i star stamps from exposure %s.", 

247 len(extractedStamps.starStamps), 

248 dataId, 

249 ) 

250 warpOutputs = self.warpStamps(extractedStamps.starStamps, extractedStamps.pixCenters) 

251 warpedStars = warpOutputs.warpedStars 

252 xy0s = warpOutputs.xy0s 

253 brightStarList = [ 

254 BrightStarStamp( 

255 stamp_im=warp, 

256 archive_element=transform, 

257 position=xy0s[j], 

258 gaiaGMag=extractedStamps.gMags[j], 

259 gaiaId=extractedStamps.gaiaIds[j], 

260 minValidAnnulusFraction=self.config.minValidAnnulusFraction, 

261 ) 

262 for j, (warp, transform) in enumerate(zip(warpedStars, warpOutputs.warpTransforms)) 

263 ] 

264 # Compute annularFlux and normalize 

265 self.log.info( 

266 "Computing annular flux and normalizing %i bright stars from exposure %s.", 

267 len(warpedStars), 

268 dataId, 

269 ) 

270 # annularFlux statistic set-up, excluding mask planes 

271 statsControl = StatisticsControl( 

272 numSigmaClip=self.config.numSigmaClip, 

273 numIter=self.config.numIter, 

274 ) 

275 

276 innerRadius, outerRadius = self.config.annularFluxRadii 

277 statsFlag = stringToStatisticsProperty(self.config.annularFluxStatistic) 

278 brightStarStamps = BrightStarStamps.initAndNormalize( 

279 brightStarList, 

280 innerRadius=innerRadius, 

281 outerRadius=outerRadius, 

282 nb90Rots=warpOutputs.nb90Rots, 

283 imCenter=self.modelCenter, 

284 use_archive=True, 

285 statsControl=statsControl, 

286 statsFlag=statsFlag, 

287 badMaskPlanes=self.config.badMaskPlanes, 

288 discardNanFluxObjects=(self.config.discardNanFluxStars), 

289 ) 

290 # Store the count number of valid stars that overlap the exposure. 

291 self.metadata["validStarCount"] = len(brightStarStamps) 

292 # Do not create empty FITS files if there aren't any normalized stamps. 

293 if not brightStarStamps._stamps: 

294 self.log.info("No normalized stamps exist for this exposure.") 

295 return None 

296 return Struct(brightStarStamps=brightStarStamps) 

297 

298 def applySkyCorr(self, calexp, skyCorr): 

299 """Apply sky correction to the input exposure. 

300 

301 Sky corrections can be generated using the 

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

303 As the sky model generated via that task extends over the full focal 

304 plane, this should produce a more optimal sky subtraction solution. 

305 

306 Parameters 

307 ---------- 

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

309 Calibrated exposure to correct. 

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

311 Full focal plane sky correction from ``SkyCorrectionTask``. 

312 

313 Notes 

314 ----- 

315 This method modifies the input ``calexp`` in-place. 

316 """ 

317 if isinstance(calexp, Exposure): 

318 calexp = calexp.getMaskedImage() 

319 calexp -= skyCorr.getImage() 

320 

321 def extractStamps( 

322 self, inputExposure, filterName="phot_g_mean", refObjLoader=None, inputBrightStarStamps=None 

323 ): 

324 """Identify the positions of bright stars within an input exposure using 

325 a reference catalog and extract them. 

326 

327 Parameters 

328 ---------- 

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

330 The image to extract bright star stamps from. 

331 filterName : `str`, optional 

332 Name of the camera filter to use for reference catalog filtering. 

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

334 Loader to find objects within a reference catalog. 

335 inputBrightStarStamps: 

336 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamps`, optional 

337 Provides information about the stars that have already been 

338 extracted from the inputExposure in other steps of the pipeline. 

339 For example, this is used in the `SubtractBrightStarsTask` to avoid 

340 extracting stars that already have been extracted when running 

341 `ProcessBrightStarsTask` to produce brightStarStamps. 

342 

343 Returns 

344 ------- 

345 result : `~lsst.pipe.base.Struct` 

346 Results as a struct with attributes: 

347 

348 ``starStamps`` 

349 Postage stamps (`list`). 

350 ``pixCenters`` 

351 Corresponding coords to each star's center, in pixels (`list`). 

352 ``gMags`` 

353 Corresponding (Gaia) G magnitudes (`list`). 

354 ``gaiaIds`` 

355 Corresponding unique Gaia identifiers (`np.ndarray`). 

356 """ 

357 if refObjLoader is None: 

358 refObjLoader = self.refObjLoader 

359 

360 wcs = inputExposure.getWcs() 

361 inputBBox = inputExposure.getBBox() 

362 

363 # Trim the reference catalog to only those objects within the exposure 

364 # bounding box dilated by half the bright star stamp size. This ensures 

365 # all stars that overlap the exposure are included. 

366 dilatationExtent = Extent2I(np.array(self.config.stampSize) // 2) 

367 withinExposure = refObjLoader.loadPixelBox( 

368 inputBBox.dilatedBy(dilatationExtent), wcs, filterName=filterName 

369 ) 

370 refCat = withinExposure.refCat 

371 fluxField = withinExposure.fluxField 

372 

373 # Define ref cat bright subset: objects brighter than the mag limit. 

374 fluxLimit = ((self.config.magLimit * u.ABmag).to(u.nJy)).to_value() # AB magnitudes. 

375 refCatBright = Table( 

376 refCat.extract("id", "coord_ra", "coord_dec", fluxField, where=refCat[fluxField] > fluxLimit) 

377 ) 

378 refCatBright["mag"] = (refCatBright[fluxField][:] * u.nJy).to(u.ABmag).to_value() # AB magnitudes. 

379 

380 # Remove input bright stars (if provided) from the bright subset. 

381 if inputBrightStarStamps is not None: 

382 # Extract the IDs of stars that have already been extracted. 

383 existing = np.isin(refCatBright["id"][:], inputBrightStarStamps.getGaiaIds()) 

384 refCatBright = refCatBright[~existing] 

385 

386 # Loop over each reference bright star, extract a stamp around it. 

387 pixCenters = [] 

388 starStamps = [] 

389 badRows = [] 

390 for row, object in enumerate(refCatBright): 

391 coordSky = SpherePoint(object["coord_ra"], object["coord_dec"], radians) 

392 coordPix = wcs.skyToPixel(coordSky) 

393 # TODO: Replace this method with exposure getCutout after DM-40042. 

394 starStamp = self._getCutout(inputExposure, coordPix, self.config.stampSize.list()) 

395 if not starStamp: 

396 badRows.append(row) 

397 continue 

398 if self.config.doRemoveDetected: 

399 self._replaceSecondaryFootprints(starStamp, coordPix, object["id"]) 

400 starStamps.append(starStamp) 

401 pixCenters.append(coordPix) 

402 

403 # Remove bad rows from the reference catalog; set up return data. 

404 refCatBright.remove_rows(badRows) 

405 gMags = list(refCatBright["mag"][:]) 

406 ids = list(refCatBright["id"][:]) 

407 

408 # Store the count number of all stars (within the given magnitude 

409 # range) that overlap the exposure. 

410 # TODO: Make sure self._getCutout only misses stars that don't have any 

411 # valid pixel overlapped with the exposure. 

412 self.metadata["allStarCount"] = len(starStamps) 

413 return Struct(starStamps=starStamps, pixCenters=pixCenters, gMags=gMags, gaiaIds=ids) 

414 

415 def _getCutout(self, inputExposure, coordPix: Point2D, stampSize: list[int]): 

416 """Get a cutout from an input exposure, handling edge cases. 

417 

418 Generate a cutout from an input exposure centered on a given position 

419 and with a given size. 

420 If any part of the cutout is outside the input exposure bounding box, 

421 the cutout is padded with NaNs. 

422 

423 Parameters 

424 ---------- 

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

426 The image to extract bright star stamps from. 

427 coordPix : `~lsst.geom.Point2D` 

428 Center of the cutout in pixel space. 

429 stampSize : `list` [`int`] 

430 Size of the cutout, in pixels. 

431 

432 Returns 

433 ------- 

434 stamp : `~lsst.afw.image.ExposureF` or `None` 

435 The cutout, or `None` if the cutout is entirely outside the input 

436 exposure bounding box. 

437 

438 Notes 

439 ----- 

440 This method is a short-term workaround until DM-40042 is implemented. 

441 At that point, it should be replaced by a call to the Exposure method 

442 ``getCutout``, which will handle edge cases automatically. 

443 """ 

444 # TODO: Replace this method with exposure getCutout after DM-40042. 

445 corner = Point2I(np.array(coordPix) - np.array(stampSize) / 2) 

446 dimensions = Extent2I(stampSize) 

447 stampBBox = Box2I(corner, dimensions) 

448 overlapBBox = Box2I(stampBBox) 

449 overlapBBox.clip(inputExposure.getBBox()) 

450 if overlapBBox.getArea() > 0: 

451 # Create full-sized stamp with pixels initially flagged as NO_DATA. 

452 stamp = ExposureF(bbox=stampBBox) 

453 stamp.image[:] = np.nan 

454 stamp.mask.set(inputExposure.mask.getPlaneBitMask("NO_DATA")) 

455 # Restore pixels which overlap the input exposure. 

456 inputMI = inputExposure.maskedImage 

457 overlap = inputMI.Factory(inputMI, overlapBBox) 

458 stamp.maskedImage[overlapBBox] = overlap 

459 # Set detector and WCS. 

460 stamp.setDetector(inputExposure.getDetector()) 

461 stamp.setWcs(inputExposure.getWcs()) 

462 else: 

463 stamp = None 

464 return stamp 

465 

466 def _replaceSecondaryFootprints(self, stamp, coordPix, objectId, find="DETECTED", replace="BAD"): 

467 """Replace all secondary footprints in a stamp with another mask flag. 

468 

469 This method identifies all secondary footprints in a stamp as those 

470 whose ``find`` footprints do not overlap the given pixel coordinates. 

471 If then sets these secondary footprints to the ``replace`` flag. 

472 

473 Parameters 

474 ---------- 

475 stamp : `~lsst.afw.image.ExposureF` 

476 The postage stamp to modify. 

477 coordPix : `~lsst.geom.Point2D` 

478 The pixel coordinates of the central primary object. 

479 objectId : `int` 

480 The unique identifier of the central primary object. 

481 find : `str`, optional 

482 The mask plane to use to identify secondary footprints. 

483 replace : `str`, optional 

484 The mask plane to set secondary footprints to. 

485 

486 Notes 

487 ----- 

488 This method modifies the input ``stamp`` in-place. 

489 """ 

490 # Find a FootprintSet given an Image and a threshold. 

491 detThreshold = Threshold(stamp.mask.getPlaneBitMask(find), Threshold.BITMASK) 

492 footprintSet = FootprintSet(stamp.mask, detThreshold) 

493 allFootprints = footprintSet.getFootprints() 

494 # Identify secondary objects (i.e., not the central primary object). 

495 secondaryFootprints = [] 

496 for footprint in allFootprints: 

497 if not footprint.contains(Point2I(coordPix)): 

498 secondaryFootprints.append(footprint) 

499 # Set secondary object footprints to BAD. 

500 # Note: the value of numPrimaryFootprints can only be 0 or 1. If it is 

501 # 0, then the primary object was not found overlapping a footprint. 

502 # This can occur for low-S/N stars, for example. Processing can still 

503 # continue beyond this point in an attempt to utilize this faint flux. 

504 if (numPrimaryFootprints := len(allFootprints) - len(secondaryFootprints)) == 0: 

505 self.log.info( 

506 "Could not uniquely identify central %s footprint for star %s; " 

507 "found %d footprints instead.", 

508 find, 

509 objectId, 

510 numPrimaryFootprints, 

511 ) 

512 footprintSet.setFootprints(secondaryFootprints) 

513 footprintSet.setMask(stamp.mask, replace) 

514 

515 def warpStamps(self, stamps, pixCenters): 

516 """Warps and shifts all given stamps so they are sampled on the same 

517 pixel grid and centered on the central pixel. This includes rotating 

518 the stamp depending on detector orientation. 

519 

520 Parameters 

521 ---------- 

522 stamps : `Sequence` [`~lsst.afw.image.ExposureF`] 

523 Image cutouts centered on a single object. 

524 pixCenters : `Sequence` [`~lsst.geom.Point2D`] 

525 Positions of each object's center (from the refCat) in pixels. 

526 

527 Returns 

528 ------- 

529 result : `~lsst.pipe.base.Struct` 

530 Results as a struct with attributes: 

531 

532 ``warpedStars`` 

533 Stamps of warped stars. 

534 (`list` [`~lsst.afw.image.MaskedImage`]) 

535 ``warpTransforms`` 

536 The corresponding Transform from the initial star stamp 

537 to the common model grid. 

538 (`list` [`~lsst.afw.geom.TransformPoint2ToPoint2`]) 

539 ``xy0s`` 

540 Coordinates of the bottom-left pixels of each stamp, 

541 before rotation. 

542 (`list` [`~lsst.geom.Point2I`]) 

543 ``nb90Rots`` 

544 The number of 90 degrees rotations required to compensate for 

545 detector orientation. 

546 (`int`) 

547 """ 

548 # warping control; only contains shiftingALg provided in config 

549 warpCont = WarpingControl(self.config.warpingKernelName) 

550 # Compare model to star stamp sizes 

551 bufferPix = ( 

552 self.modelStampSize[0] - self.config.stampSize[0], 

553 self.modelStampSize[1] - self.config.stampSize[1], 

554 ) 

555 # Initialize detector instance (note all stars were extracted from an 

556 # exposure from the same detector) 

557 det = stamps[0].getDetector() 

558 # Define correction for optical distortions 

559 if self.config.doApplyTransform: 

560 pixToTan = det.getTransform(PIXELS, TAN_PIXELS) 

561 else: 

562 pixToTan = makeIdentityTransform() 

563 # Array of all possible rotations for detector orientation: 

564 possibleRots = np.array([k * np.pi / 2 for k in range(4)]) 

565 # determine how many, if any, rotations are required 

566 yaw = det.getOrientation().getYaw() 

567 nb90Rots = np.argmin(np.abs(possibleRots - float(yaw))) 

568 

569 # apply transformation to each star 

570 warpedStars, warpTransforms, xy0s = [], [], [] 

571 for star, cent in zip(stamps, pixCenters): 

572 # (re)create empty destination image 

573 destImage = MaskedImageF(*self.modelStampSize) 

574 bottomLeft = Point2D(star.image.getXY0()) 

575 newBottomLeft = pixToTan.applyForward(bottomLeft) 

576 newBottomLeft.setX(newBottomLeft.getX() - bufferPix[0] / 2) 

577 newBottomLeft.setY(newBottomLeft.getY() - bufferPix[1] / 2) 

578 # Convert to int 

579 newBottomLeft = Point2I(newBottomLeft) 

580 # Set origin and save it 

581 destImage.setXY0(newBottomLeft) 

582 xy0s.append(newBottomLeft) 

583 

584 # Define linear shifting to recenter stamps 

585 newCenter = pixToTan.applyForward(cent) # center of warped star 

586 shift = ( 

587 self.modelCenter[0] + newBottomLeft[0] - newCenter[0], 

588 self.modelCenter[1] + newBottomLeft[1] - newCenter[1], 

589 ) 

590 affineShift = AffineTransform(shift) 

591 shiftTransform = makeTransform(affineShift) 

592 

593 # Define full transform (warp and shift) 

594 starWarper = pixToTan.then(shiftTransform) 

595 

596 # Apply it 

597 goodPix = warpImage(destImage, star.getMaskedImage(), starWarper, warpCont) 

598 if not goodPix: 

599 self.log.debug("Warping of a star failed: no good pixel in output") 

600 

601 # Arbitrarily set origin of shifted star to 0 

602 destImage.setXY0(0, 0) 

603 

604 # Apply rotation if appropriate 

605 if nb90Rots: 

606 destImage = rotateImageBy90(destImage, nb90Rots) 

607 warpedStars.append(destImage.clone()) 

608 warpTransforms.append(starWarper) 

609 return Struct(warpedStars=warpedStars, warpTransforms=warpTransforms, xy0s=xy0s, nb90Rots=nb90Rots) 

610 

611 def setModelStamp(self): 

612 """Compute (model) stamp size depending on provided buffer value.""" 

613 self.modelStampSize = [ 

614 int(self.config.stampSize[0] * self.config.modelStampBuffer), 

615 int(self.config.stampSize[1] * self.config.modelStampBuffer), 

616 ] 

617 # Force stamp to be odd-sized so we have a central pixel. 

618 if not self.modelStampSize[0] % 2: 

619 self.modelStampSize[0] += 1 

620 if not self.modelStampSize[1] % 2: 

621 self.modelStampSize[1] += 1 

622 # Central pixel. 

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