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# 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 .stamps import StampsBase, AbstractStamp, readFitsWithOptions 

38 

39 

40@dataclass 

41class BrightStarStamp(AbstractStamp): 

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

43 annularFlux. 

44 

45 Parameters 

46 ---------- 

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

48 Pixel data for this postage stamp 

49 gaiaGMag : `float` 

50 Gaia G magnitude for the object in this stamp 

51 gaiaId : `int` 

52 Gaia object identifier 

53 annularFlux : `Optional[float]` 

54 Flux in an annulus around the object 

55 """ 

56 stamp_im: MaskedImageF 

57 gaiaGMag: float 

58 gaiaId: int 

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

60 annularFlux: Optional[float] = None 

61 

62 @classmethod 

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

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

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

66 Parameters needed to construct this object are passed in via 

67 a metadata dictionary and then passed to the constructor of 

68 this class. This particular factory method requires keys: 

69 G_MAGS, GAIA_IDS, and ANNULAR_FLUXES. They should each 

70 point to lists of values. 

71 

72 Parameters 

73 ---------- 

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

75 Pixel data to pass to the constructor 

76 metadata : `dict` 

77 Dictionary containing the information 

78 needed by the constructor. 

79 idx : `int` 

80 Index into the lists in ``metadata`` 

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

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

83 

84 Returns 

85 ------- 

86 brightstarstamp : `BrightStarStamp` 

87 An instance of this class 

88 """ 

89 return cls(stamp_im=stamp_im, 

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

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

92 archive_element=archive_element, 

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

94 

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

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

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

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

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

100 

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

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

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

104 non-saturated pixels. 

105 

106 Parameters 

107 ---------- 

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

109 SpanSet containing the annulus to use for normalization. 

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

111 StatisticsControl to be used when computing flux over all pixels 

112 within the annulus. 

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

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

115 annularFlux. Defaults to a simple MEAN. 

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

117 Collection of mask planes to ignore when computing annularFlux. 

118 """ 

119 stampSize = self.stamp_im.getDimensions() 

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

121 # elsewhere 

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

123 annulusImage = MaskedImageF(stampSize, planeDict=maskPlaneDict) 

124 annulusMask = annulusImage.mask 

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

126 annulus.copyMaskedImage(self.stamp_im, annulusImage) 

127 # set mask planes to be ignored 

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

129 statsControl.setAndMask(andMask) 

130 # compute annularFlux 

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

132 self.annularFlux = annulusStat.getValue() 

133 if np.isnan(self.annularFlux): 

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

135 # normalize stamps 

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

137 return None 

138 

139 

140class BrightStarStamps(StampsBase): 

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

142 

143 Parameters 

144 ---------- 

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

146 Sequence of star stamps. Cannot contain both normalized and 

147 unnormalized stamps. 

148 innerRadius : `int`, optional 

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

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

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

152 outerRadius : `int`, optional 

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

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

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

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

157 Metadata associated with the bright stars. 

158 use_mask : `bool` 

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

160 use_variance : `bool` 

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

162 use_archive : `bool` 

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

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

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

166 to the same pixel grid before stacking. 

167 

168 Raises 

169 ------ 

170 ValueError 

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

172 required keys. 

173 AttributeError 

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

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

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

177 

178 

179 Notes 

180 ----- 

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

182 bbox: 

183 

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

185 """ 

186 

187 def __init__(self, starStamps, innerRadius=None, outerRadius=None, 

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

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

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

191 # already expected to be normalized 

192 self._checkNormalization(False, innerRadius, outerRadius) 

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

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

195 self.normalized = True 

196 else: 

197 self.normalized = False 

198 

199 @classmethod 

200 def initAndNormalize(cls, starStamps, innerRadius, outerRadius, 

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

202 imCenter=None, discardNanFluxObjects=True, 

203 statsControl=afwMath.StatisticsControl(), 

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

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

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

207 BrightStarStamps instance. 

208 

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

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

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

212 non-saturated pixels. 

213 

214 Parameters 

215 ---------- 

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

217 Sequence of star stamps. Cannot contain both normalized and 

218 unnormalized stamps. 

219 innerRadius : `int` 

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

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

222 ``starStamp``. 

223 outerRadius : `int` 

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

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

226 ``starStamp``. 

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

228 Metadata associated with the bright stars. 

229 use_mask : `bool` 

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

231 use_variance : `bool` 

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

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

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

235 first stamp's pixel grid will be used. 

236 discardNanFluxObjects : `bool` 

237 Whether objects with NaN annular flux should be discarded. 

238 If False, these objects will not be normalized. 

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

240 StatisticsControl to be used when computing flux over all pixels 

241 within the annulus. 

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

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

244 annularFlux. Defaults to a simple MEAN. 

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

246 Collection of mask planes to ignore when computing annularFlux. 

247 

248 Raises 

249 ------ 

250 ValueError 

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

252 required keys. 

253 AttributeError 

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

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

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

257 """ 

258 if imCenter is None: 

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

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

261 # Create SpanSet of annulus 

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

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

264 annulus = outerCircle.intersectNot(innerCircle) 

265 # Initialize (unnormalized) brightStarStamps instance 

266 bss = cls(starStamps, innerRadius=None, outerRadius=None, 

267 metadata=metadata, use_mask=use_mask, 

268 use_variance=use_variance) 

269 # Ensure no stamps had already been normalized 

270 bss._checkNormalization(True, innerRadius, outerRadius) 

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

272 # Apply normalization 

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

274 try: 

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

276 badMaskPlanes=badMaskPlanes) 

277 except RuntimeError: 

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

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

280 # steps needed before bright stars can be subtracted. 

281 if discardNanFluxObjects: 

282 bss._stamps.pop(j) 

283 else: 

284 stamp.annularFlux = np.nan 

285 bss.normalized = True 

286 return bss 

287 

288 def _refresh_metadata(self): 

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

290 out. 

291 """ 

292 # add full list of Gaia magnitudes, IDs and annularFlxes to shared 

293 # metadata 

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

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

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

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

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

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

300 return None 

301 

302 @classmethod 

303 def readFits(cls, filename): 

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

305 

306 Parameters 

307 ---------- 

308 filename : `str` 

309 Name of the file to read 

310 """ 

311 return cls.readFitsWithOptions(filename, None) 

312 

313 @classmethod 

314 def readFitsWithOptions(cls, filename, options): 

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

316 

317 Parameters 

318 ---------- 

319 filename : `str` 

320 Name of the file to read 

321 options : `PropertyList` 

322 Collection of metadata parameters 

323 """ 

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

325 if metadata["NORMALIZED"]: 

326 return cls(stamps, 

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

328 metadata=metadata, use_mask=metadata['HAS_MASK'], 

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

330 else: 

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

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

333 

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

335 """Add an additional bright star stamp. 

336 

337 Parameters 

338 ---------- 

339 item : `BrightStarStamp` 

340 Bright star stamp to append. 

341 innerRadius : `int`, optional 

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

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

344 ``BrightStarStamp``. 

345 outerRadius : `int`, optional 

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

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

348 ``BrightStarStamp``. 

349 """ 

350 if not isinstance(item, BrightStarStamp): 

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

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

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

354 "instance, or vice-versa.") 

355 else: 

356 self._checkRadius(innerRadius, outerRadius) 

357 self._stamps.append(item) 

358 return None 

359 

360 def extend(self, bss): 

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

362 instance. 

363 

364 Parameters 

365 ---------- 

366 bss : `BrightStarStamps` 

367 Other instance to concatenate. 

368 """ 

369 if not isinstance(bss, BrightStarStamps): 

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

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

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

373 self._stamps += bss._stamps 

374 

375 def getMagnitudes(self): 

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

377 

378 Returns 

379 ------- 

380 gaiaGMags : `list` [`float`] 

381 """ 

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

383 

384 def getGaiaIds(self): 

385 """Retrieve Gaia IDs for each star. 

386 

387 Returns 

388 ------- 

389 gaiaIds : `list` [`int`] 

390 """ 

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

392 

393 def getAnnularFluxes(self): 

394 """Retrieve normalization factors for each star. 

395 

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

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

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

399 recovered from the metadata. 

400 

401 Returns 

402 ------- 

403 annularFluxes : `list` [`float`] 

404 """ 

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

406 

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

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

409 magnitude cuts (in Gaia G). 

410 

411 Parameters 

412 ---------- 

413 magMin : `float`, optional 

414 Keep only stars fainter than this value. 

415 magMax : `float`, optional 

416 Keep only stars brighter than this value. 

417 """ 

418 subset = [stamp for stamp in self._stamps 

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

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

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

422 # it is already guaranteed to be the correct type 

423 instance = BrightStarStamps((), 

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

425 metadata=self._metadata) 

426 instance._stamps = subset 

427 return instance 

428 

429 def _checkRadius(self, innerRadius, outerRadius): 

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

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

432 """ 

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

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

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

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

437 

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

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

440 that, if requested, normalization can be performed. 

441 """ 

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

443 nStamps = len(self) 

444 nFluxVals = nStamps - noneFluxCount 

445 if noneFluxCount and noneFluxCount < nStamps: 

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

447 # normalized), but not all of them do 

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

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

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

451 elif normalize: 

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

453 # and they have no annularFlux 

454 if innerRadius is None or outerRadius is None: 

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

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

457 elif noneFluxCount < nStamps: 

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

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

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

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

462 # already contain annularFluxes 

463 if noneFluxCount: 

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

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

466 "normalize to True.") 

467 else: 

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

469 # already been normalized 

470 if nFluxVals: 

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

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

473 "be provided.") 

474 return None