Coverage for python/lsst/meas/algorithms/brightStarStamps.py: 22%

140 statements  

« prev     ^ index     » next       coverage.py v7.2.1, created at 2023-03-12 03:05 -0700

1# This file is part of meas_algorithms. 

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"""Collection of small images (stamps), each centered on a bright star. 

23""" 

24 

25__all__ = ["BrightStarStamp", "BrightStarStamps"] 

26 

27from dataclasses import dataclass 

28from operator import ior 

29from functools import reduce 

30from typing import Optional 

31import numpy as np 

32 

33from lsst.afw.image import MaskedImageF 

34from lsst.afw import geom as afwGeom 

35from lsst.afw import math as afwMath 

36from lsst.afw import table as afwTable 

37from lsst.geom import Point2I 

38from .stamps import Stamps, AbstractStamp, readFitsWithOptions 

39 

40 

41@dataclass 

42class BrightStarStamp(AbstractStamp): 

43 """Single stamp centered on a bright star, normalized by its 

44 annularFlux. 

45 

46 Parameters 

47 ---------- 

48 stamp_im : `lsst.afw.image.MaskedImage` 

49 Pixel data for this postage stamp 

50 position : `lsst.geom.Point2I` 

51 Origin of the stamps in its origin exposure (pixels) 

52 gaiaGMag : `float` 

53 Gaia G magnitude for the object in this stamp 

54 gaiaId : `int` 

55 Gaia object identifier 

56 annularFlux : `Optional[float]` 

57 Flux in an annulus around the object 

58 """ 

59 stamp_im: MaskedImageF 

60 gaiaGMag: float 

61 gaiaId: int 

62 position: Point2I 

63 archive_element: Optional[afwTable.io.Persistable] = None 

64 annularFlux: Optional[float] = None 

65 

66 @classmethod 

67 def factory(cls, stamp_im, metadata, idx, archive_element=None): 

68 """This method is needed to service the FITS reader. 

69 We need a standard interface to construct objects like this. 

70 Parameters needed to construct this object are passed in via 

71 a metadata dictionary and then passed to the constructor of 

72 this class. This particular factory method requires keys: 

73 G_MAGS, GAIA_IDS, and ANNULAR_FLUXES. They should each 

74 point to lists of values. 

75 

76 Parameters 

77 ---------- 

78 stamp_im : `lsst.afw.image.MaskedImage` 

79 Pixel data to pass to the constructor 

80 metadata : `dict` 

81 Dictionary containing the information 

82 needed by the constructor. 

83 idx : `int` 

84 Index into the lists in ``metadata`` 

85 archive_element : `lsst.afwTable.io.Persistable`, optional 

86 Archive element (e.g. Transform or WCS) associated with this stamp. 

87 

88 Returns 

89 ------- 

90 brightstarstamp : `BrightStarStamp` 

91 An instance of this class 

92 """ 

93 if 'X0S' in metadata and 'Y0S' in metadata: 

94 x0 = metadata.getArray('X0S')[idx] 

95 y0 = metadata.getArray('Y0S')[idx] 

96 position = Point2I(x0, y0) 

97 else: 

98 position = None 

99 return cls(stamp_im=stamp_im, 

100 gaiaGMag=metadata.getArray('G_MAGS')[idx], 

101 gaiaId=metadata.getArray('GAIA_IDS')[idx], 

102 position=position, 

103 archive_element=archive_element, 

104 annularFlux=metadata.getArray('ANNULAR_FLUXES')[idx]) 

105 

106 def measureAndNormalize(self, annulus, statsControl=afwMath.StatisticsControl(), 

107 statsFlag=afwMath.stringToStatisticsProperty("MEAN"), 

108 badMaskPlanes=('BAD', 'SAT', 'NO_DATA')): 

109 """Compute "annularFlux", the integrated flux within an annulus 

110 around an object's center, and normalize it. 

111 

112 Since the center of bright stars are saturated and/or heavily affected 

113 by ghosts, we measure their flux in an annulus with a large enough 

114 inner radius to avoid the most severe ghosts and contain enough 

115 non-saturated pixels. 

116 

117 Parameters 

118 ---------- 

119 annulus : `lsst.afw.geom.spanSet.SpanSet` 

120 SpanSet containing the annulus to use for normalization. 

121 statsControl : `lsst.afw.math.statistics.StatisticsControl`, optional 

122 StatisticsControl to be used when computing flux over all pixels 

123 within the annulus. 

124 statsFlag : `lsst.afw.math.statistics.Property`, optional 

125 statsFlag to be passed on to ``afwMath.makeStatistics`` to compute 

126 annularFlux. Defaults to a simple MEAN. 

127 badMaskPlanes : `collections.abc.Collection` [`str`] 

128 Collection of mask planes to ignore when computing annularFlux. 

129 """ 

130 stampSize = self.stamp_im.getDimensions() 

131 # create image with the same pixel values within annulus, NO_DATA 

132 # elsewhere 

133 maskPlaneDict = self.stamp_im.mask.getMaskPlaneDict() 

134 annulusImage = MaskedImageF(stampSize, planeDict=maskPlaneDict) 

135 annulusMask = annulusImage.mask 

136 annulusMask.array[:] = 2**maskPlaneDict['NO_DATA'] 

137 annulus.copyMaskedImage(self.stamp_im, annulusImage) 

138 # set mask planes to be ignored 

139 andMask = reduce(ior, (annulusMask.getPlaneBitMask(bm) for bm in badMaskPlanes)) 

140 statsControl.setAndMask(andMask) 

141 # compute annularFlux 

142 annulusStat = afwMath.makeStatistics(annulusImage, statsFlag, statsControl) 

143 self.annularFlux = annulusStat.getValue() 

144 if np.isnan(self.annularFlux): 

145 raise RuntimeError("Annular flux computation failed, likely because no pixels were valid.") 

146 # normalize stamps 

147 self.stamp_im.image.array /= self.annularFlux 

148 return None 

149 

150 

151class BrightStarStamps(Stamps): 

152 """Collection of bright star stamps and associated metadata. 

153 

154 Parameters 

155 ---------- 

156 starStamps : `collections.abc.Sequence` [`BrightStarStamp`] 

157 Sequence of star stamps. Cannot contain both normalized and 

158 unnormalized stamps. 

159 innerRadius : `int`, optional 

160 Inner radius value, in pixels. This and ``outerRadius`` define the 

161 annulus used to compute the ``"annularFlux"`` values within each 

162 ``starStamp``. Must be provided if ``normalize`` is True. 

163 outerRadius : `int`, optional 

164 Outer radius value, in pixels. This and ``innerRadius`` define the 

165 annulus used to compute the ``"annularFlux"`` values within each 

166 ``starStamp``. Must be provided if ``normalize`` is True. 

167 nb90Rots : `int`, optional 

168 Number of 90 degree rotations required to compensate for detector 

169 orientation. 

170 metadata : `lsst.daf.base.PropertyList`, optional 

171 Metadata associated with the bright stars. 

172 use_mask : `bool` 

173 If `True` read and write mask data. Default `True`. 

174 use_variance : `bool` 

175 If ``True`` read and write variance data. Default ``False``. 

176 use_archive : `bool` 

177 If ``True`` read and write an Archive that contains a Persistable 

178 associated with each stamp. In the case of bright stars, this is 

179 usually a ``TransformPoint2ToPoint2``, used to warp each stamp 

180 to the same pixel grid before stacking. 

181 

182 Raises 

183 ------ 

184 ValueError 

185 Raised if one of the star stamps provided does not contain the 

186 required keys. 

187 AttributeError 

188 Raised if there is a mix-and-match of normalized and unnormalized 

189 stamps, stamps normalized with different annulus definitions, or if 

190 stamps are to be normalized but annular radii were not provided. 

191 

192 

193 Notes 

194 ----- 

195 A butler can be used to read only a part of the stamps, specified by a 

196 bbox: 

197 

198 >>> starSubregions = butler.get("brightStarStamps", dataId, parameters={'bbox': bbox}) 

199 """ 

200 

201 def __init__(self, starStamps, innerRadius=None, outerRadius=None, nb90Rots=None, 

202 metadata=None, use_mask=True, use_variance=False, use_archive=False): 

203 super().__init__(starStamps, metadata, use_mask, use_variance, use_archive) 

204 # Ensure stamps contain a flux measurement if and only if they are 

205 # already expected to be normalized 

206 self._checkNormalization(False, innerRadius, outerRadius) 

207 self._innerRadius, self._outerRadius = innerRadius, outerRadius 

208 if innerRadius is not None and outerRadius is not None: 

209 self.normalized = True 

210 else: 

211 self.normalized = False 

212 self.nb90Rots = nb90Rots 

213 

214 @classmethod 

215 def initAndNormalize(cls, starStamps, innerRadius, outerRadius, nb90Rots=None, 

216 metadata=None, use_mask=True, use_variance=False, 

217 use_archive=False, imCenter=None, 

218 discardNanFluxObjects=True, 

219 statsControl=afwMath.StatisticsControl(), 

220 statsFlag=afwMath.stringToStatisticsProperty("MEAN"), 

221 badMaskPlanes=('BAD', 'SAT', 'NO_DATA')): 

222 """Normalize a set of bright star stamps and initialize a 

223 BrightStarStamps instance. 

224 

225 Since the center of bright stars are saturated and/or heavily affected 

226 by ghosts, we measure their flux in an annulus with a large enough 

227 inner radius to avoid the most severe ghosts and contain enough 

228 non-saturated pixels. 

229 

230 Parameters 

231 ---------- 

232 starStamps : `collections.abc.Sequence` [`BrightStarStamp`] 

233 Sequence of star stamps. Cannot contain both normalized and 

234 unnormalized stamps. 

235 innerRadius : `int` 

236 Inner radius value, in pixels. This and ``outerRadius`` define the 

237 annulus used to compute the ``"annularFlux"`` values within each 

238 ``starStamp``. 

239 outerRadius : `int` 

240 Outer radius value, in pixels. This and ``innerRadius`` define the 

241 annulus used to compute the ``"annularFlux"`` values within each 

242 ``starStamp``. 

243 nb90Rots : `int`, optional 

244 Number of 90 degree rotations required to compensate for detector 

245 orientation. 

246 metadata : `lsst.daf.base.PropertyList`, optional 

247 Metadata associated with the bright stars. 

248 use_mask : `bool` 

249 If `True` read and write mask data. Default `True`. 

250 use_variance : `bool` 

251 If ``True`` read and write variance data. Default ``False``. 

252 use_archive : `bool` 

253 If ``True`` read and write an Archive that contains a Persistable 

254 associated with each stamp. In the case of bright stars, this is 

255 usually a ``TransformPoint2ToPoint2``, used to warp each stamp 

256 to the same pixel grid before stacking. 

257 imCenter : `collections.abc.Sequence`, optional 

258 Center of the object, in pixels. If not provided, the center of the 

259 first stamp's pixel grid will be used. 

260 discardNanFluxObjects : `bool` 

261 Whether objects with NaN annular flux should be discarded. 

262 If False, these objects will not be normalized. 

263 statsControl : `lsst.afw.math.statistics.StatisticsControl`, optional 

264 StatisticsControl to be used when computing flux over all pixels 

265 within the annulus. 

266 statsFlag : `lsst.afw.math.statistics.Property`, optional 

267 statsFlag to be passed on to ``afwMath.makeStatistics`` to compute 

268 annularFlux. Defaults to a simple MEAN. 

269 badMaskPlanes : `collections.abc.Collection` [`str`] 

270 Collection of mask planes to ignore when computing annularFlux. 

271 

272 Raises 

273 ------ 

274 ValueError 

275 Raised if one of the star stamps provided does not contain the 

276 required keys. 

277 AttributeError 

278 Raised if there is a mix-and-match of normalized and unnormalized 

279 stamps, stamps normalized with different annulus definitions, or if 

280 stamps are to be normalized but annular radii were not provided. 

281 """ 

282 if imCenter is None: 

283 stampSize = starStamps[0].stamp_im.getDimensions() 

284 imCenter = stampSize[0]//2, stampSize[1]//2 

285 # Create SpanSet of annulus 

286 outerCircle = afwGeom.SpanSet.fromShape(outerRadius, afwGeom.Stencil.CIRCLE, offset=imCenter) 

287 innerCircle = afwGeom.SpanSet.fromShape(innerRadius, afwGeom.Stencil.CIRCLE, offset=imCenter) 

288 annulus = outerCircle.intersectNot(innerCircle) 

289 # Initialize (unnormalized) brightStarStamps instance 

290 bss = cls(starStamps, innerRadius=None, outerRadius=None, nb90Rots=nb90Rots, 

291 metadata=metadata, use_mask=use_mask, 

292 use_variance=use_variance, use_archive=use_archive) 

293 # Ensure no stamps had already been normalized 

294 bss._checkNormalization(True, innerRadius, outerRadius) 

295 bss._innerRadius, bss._outerRadius = innerRadius, outerRadius 

296 # Apply normalization 

297 for j, stamp in enumerate(bss._stamps): 

298 try: 

299 stamp.measureAndNormalize(annulus, statsControl=statsControl, statsFlag=statsFlag, 

300 badMaskPlanes=badMaskPlanes) 

301 except RuntimeError: 

302 # Optionally keep NaN flux objects, for bookkeeping purposes, 

303 # and to avoid having to re-find and redo the preprocessing 

304 # steps needed before bright stars can be subtracted. 

305 if discardNanFluxObjects: 

306 bss._stamps.pop(j) 

307 else: 

308 stamp.annularFlux = np.nan 

309 bss.normalized = True 

310 return bss 

311 

312 def _refresh_metadata(self): 

313 """Refresh the metadata. Should be called before writing this object 

314 out. 

315 """ 

316 # add full list of positions, Gaia magnitudes, IDs and annularFlxes to 

317 # shared metadata 

318 self._metadata["G_MAGS"] = self.getMagnitudes() 

319 self._metadata["GAIA_IDS"] = self.getGaiaIds() 

320 positions = self.getPositions() 

321 self._metadata["X0S"] = [xy0[0] for xy0 in positions] 

322 self._metadata["Y0S"] = [xy0[1] for xy0 in positions] 

323 self._metadata["ANNULAR_FLUXES"] = self.getAnnularFluxes() 

324 self._metadata["NORMALIZED"] = self.normalized 

325 self._metadata["INNER_RADIUS"] = self._innerRadius 

326 self._metadata["OUTER_RADIUS"] = self._outerRadius 

327 if self.nb90Rots is not None: 

328 self._metadata["NB_90_ROTS"] = self.nb90Rots 

329 return None 

330 

331 @classmethod 

332 def readFits(cls, filename): 

333 """Build an instance of this class from a file. 

334 

335 Parameters 

336 ---------- 

337 filename : `str` 

338 Name of the file to read 

339 """ 

340 return cls.readFitsWithOptions(filename, None) 

341 

342 @classmethod 

343 def readFitsWithOptions(cls, filename, options): 

344 """Build an instance of this class with options. 

345 

346 Parameters 

347 ---------- 

348 filename : `str` 

349 Name of the file to read 

350 options : `PropertyList` 

351 Collection of metadata parameters 

352 """ 

353 stamps, metadata = readFitsWithOptions(filename, BrightStarStamp.factory, options) 

354 nb90Rots = metadata["NB_90_ROTS"] if "NB_90_ROTS" in metadata else None 

355 if metadata["NORMALIZED"]: 

356 return cls(stamps, 

357 innerRadius=metadata["INNER_RADIUS"], outerRadius=metadata["OUTER_RADIUS"], 

358 nb90Rots=nb90Rots, metadata=metadata, use_mask=metadata['HAS_MASK'], 

359 use_variance=metadata['HAS_VARIANCE'], use_archive=metadata['HAS_ARCHIVE']) 

360 else: 

361 return cls(stamps, nb90Rots=nb90Rots, metadata=metadata, use_mask=metadata['HAS_MASK'], 

362 use_variance=metadata['HAS_VARIANCE'], use_archive=metadata['HAS_ARCHIVE']) 

363 

364 def append(self, item, innerRadius=None, outerRadius=None): 

365 """Add an additional bright star stamp. 

366 

367 Parameters 

368 ---------- 

369 item : `BrightStarStamp` 

370 Bright star stamp to append. 

371 innerRadius : `int`, optional 

372 Inner radius value, in pixels. This and ``outerRadius`` define the 

373 annulus used to compute the ``"annularFlux"`` values within each 

374 ``BrightStarStamp``. 

375 outerRadius : `int`, optional 

376 Outer radius value, in pixels. This and ``innerRadius`` define the 

377 annulus used to compute the ``"annularFlux"`` values within each 

378 ``BrightStarStamp``. 

379 """ 

380 if not isinstance(item, BrightStarStamp): 

381 raise ValueError(f"Can only add instances of BrightStarStamp, got {type(item)}.") 

382 if (item.annularFlux is None) == self.normalized: 

383 raise AttributeError("Trying to append an unnormalized stamp to a normalized BrightStarStamps " 

384 "instance, or vice-versa.") 

385 else: 

386 self._checkRadius(innerRadius, outerRadius) 

387 self._stamps.append(item) 

388 return None 

389 

390 def extend(self, bss): 

391 """Extend BrightStarStamps instance by appending elements from another 

392 instance. 

393 

394 Parameters 

395 ---------- 

396 bss : `BrightStarStamps` 

397 Other instance to concatenate. 

398 """ 

399 if not isinstance(bss, BrightStarStamps): 

400 raise ValueError('Can only extend with a BrightStarStamps object. ' 

401 f'Got {type(bss)}.') 

402 self._checkRadius(bss._innerRadius, bss._outerRadius) 

403 self._stamps += bss._stamps 

404 

405 def getMagnitudes(self): 

406 """Retrieve Gaia G magnitudes for each star. 

407 

408 Returns 

409 ------- 

410 gaiaGMags : `list` [`float`] 

411 """ 

412 return [stamp.gaiaGMag for stamp in self._stamps] 

413 

414 def getGaiaIds(self): 

415 """Retrieve Gaia IDs for each star. 

416 

417 Returns 

418 ------- 

419 gaiaIds : `list` [`int`] 

420 """ 

421 return [stamp.gaiaId for stamp in self._stamps] 

422 

423 def getAnnularFluxes(self): 

424 """Retrieve normalization factors for each star. 

425 

426 These are computed by integrating the flux in annulus centered on the 

427 bright star, far enough from center to be beyond most severe ghosts and 

428 saturation. The inner and outer radii that define the annulus can be 

429 recovered from the metadata. 

430 

431 Returns 

432 ------- 

433 annularFluxes : `list` [`float`] 

434 """ 

435 return [stamp.annularFlux for stamp in self._stamps] 

436 

437 def selectByMag(self, magMin=None, magMax=None): 

438 """Return the subset of bright star stamps for objects with specified 

439 magnitude cuts (in Gaia G). 

440 

441 Parameters 

442 ---------- 

443 magMin : `float`, optional 

444 Keep only stars fainter than this value. 

445 magMax : `float`, optional 

446 Keep only stars brighter than this value. 

447 """ 

448 subset = [stamp for stamp in self._stamps 

449 if (magMin is None or stamp.gaiaGMag > magMin) 

450 and (magMax is None or stamp.gaiaGMag < magMax)] 

451 # This is an optimization to save looping over the init argument when 

452 # it is already guaranteed to be the correct type 

453 instance = BrightStarStamps((), 

454 innerRadius=self._innerRadius, outerRadius=self._outerRadius, 

455 metadata=self._metadata) 

456 instance._stamps = subset 

457 return instance 

458 

459 def _checkRadius(self, innerRadius, outerRadius): 

460 """Ensure provided annulus radius is consistent with that already 

461 present in the instance, or with arguments passed on at initialization. 

462 """ 

463 if innerRadius != self._innerRadius or outerRadius != self._outerRadius: 

464 raise AttributeError("Trying to mix stamps normalized with annulus radii " 

465 f"{innerRadius, outerRadius} with those of BrightStarStamp instance\n" 

466 f"(computed with annular radii {self._innerRadius, self._outerRadius}).") 

467 

468 def _checkNormalization(self, normalize, innerRadius, outerRadius): 

469 """Ensure there is no mixing of normalized and unnormalized stars, and 

470 that, if requested, normalization can be performed. 

471 """ 

472 noneFluxCount = self.getAnnularFluxes().count(None) 

473 nStamps = len(self) 

474 nFluxVals = nStamps - noneFluxCount 

475 if noneFluxCount and noneFluxCount < nStamps: 

476 # at least one stamp contains an annularFlux value (i.e. has been 

477 # normalized), but not all of them do 

478 raise AttributeError(f"Only {nFluxVals} stamps contain an annularFlux value.\nAll stamps in a " 

479 "BrightStarStamps instance must either be normalized with the same annulus " 

480 "definition, or none of them can contain an annularFlux value.") 

481 elif normalize: 

482 # stamps are to be normalized; ensure annular radii are specified 

483 # and they have no annularFlux 

484 if innerRadius is None or outerRadius is None: 

485 raise AttributeError("For stamps to be normalized (normalize=True), please provide a valid " 

486 "value (in pixels) for both innerRadius and outerRadius.") 

487 elif noneFluxCount < nStamps: 

488 raise AttributeError(f"{nFluxVals} stamps already contain an annularFlux value. For stamps to" 

489 " be normalized, all their annularFlux must be None.") 

490 elif innerRadius is not None and outerRadius is not None: 

491 # Radii provided, but normalize=False; check that stamps 

492 # already contain annularFluxes 

493 if noneFluxCount: 

494 raise AttributeError(f"{noneFluxCount} stamps contain no annularFlux, but annular radius " 

495 "values were provided and normalize=False.\nTo normalize stamps, set " 

496 "normalize to True.") 

497 else: 

498 # At least one radius value is missing; ensure no stamps have 

499 # already been normalized 

500 if nFluxVals: 

501 raise AttributeError(f"{nFluxVals} stamps contain an annularFlux value. If stamps have " 

502 "been normalized, the innerRadius and outerRadius values used must " 

503 "be provided.") 

504 return None