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

165 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-14 23:55 +0000

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 (postage stamps) centered on bright stars.""" 

23 

24from __future__ import annotations 

25 

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

27 

28from collections.abc import Sequence 

29from dataclasses import dataclass 

30 

31import numpy as np 

32 

33from lsst.afw.detection import Psf 

34from lsst.afw.fits import Fits, readMetadata 

35from lsst.afw.geom import SkyWcs 

36from lsst.afw.image import ImageFitsReader, MaskedImageF, MaskFitsReader 

37from lsst.afw.table.io import InputArchive, OutputArchive 

38from lsst.daf.base import PropertyList 

39from lsst.geom import Angle, Point2D, degrees 

40from lsst.meas.algorithms.stamps import AbstractStamp 

41from lsst.utils.introspection import get_full_type_name 

42 

43 

44@dataclass 

45class BrightStarStamp(AbstractStamp): 

46 """A single postage stamp centered on a bright star. 

47 

48 Attributes 

49 ---------- 

50 stamp_im : `~lsst.afw.image.MaskedImageF` 

51 The pixel data for this stamp. 

52 psf : `~lsst.afw.detection.Psf`, optional 

53 The point-spread function for this star. 

54 wcs : `~lsst.afw.geom.SkyWcs`, optional 

55 World coordinate system associated with the stamp. 

56 visit : `int`, optional 

57 Visit number of the observation. 

58 detector : `int`, optional 

59 Detector ID within the visit. 

60 ref_id : `int`, optional 

61 Reference catalog ID of the star. 

62 ref_mag : `float`, optional 

63 Reference catalog magnitude of the star. 

64 position : `~lsst.geom.Point2D`, optional 

65 Center position of the star on the detector in pixel coordinates. 

66 focal_plane_radius : `float`, optional 

67 Radial distance from the focal plane center in tangent-plane pixels. 

68 focal_plane_angle : `~lsst.geom.Angle`, optional 

69 Azimuthal angle on the focal plane (counterclockwise from +X). 

70 scale : `float`, optional 

71 Flux scaling factor applied to the PSF model. 

72 scale_err : `float`, optional 

73 Error in the flux scale. 

74 pedestal : `float`, optional 

75 Background pedestal level. 

76 pedestal_err : `float`, optional 

77 Error on the pedestal. 

78 pedestal_scale_cov : `float`, optional 

79 Covariance between pedestal and scale. 

80 gradient_x : `float`, optional 

81 Background gradient in the X direction. 

82 gradient_y : `float`, optional 

83 Background gradient in the Y direction. 

84 global_reduced_chi_squared : `float`, optional 

85 Reduced chi-squared for the global model fit. 

86 global_degrees_of_freedom : `int`, optional 

87 Degrees of freedom for the global model fit. 

88 psf_reduced_chi_squared : `float`, optional 

89 Reduced chi-squared for the PSF fit. 

90 psf_degrees_of_freedom : `int`, optional 

91 Degrees of freedom for the PSF fit. 

92 psf_masked_flux_fraction : `float`, optional 

93 Fraction of flux masked in the PSF. 

94 

95 Notes 

96 ----- 

97 This class is designed to be used with `BrightStarStamps`, which manages 

98 collections of these stamps and handles reading/writing them to FITS files. 

99 The `factory` class method provides a standard interface to construct 

100 instances from image data and metadata, while the `_getMetadata` method 

101 extracts metadata for storage in FITS headers. 

102 """ 

103 

104 stamp_im: MaskedImageF 

105 psf: Psf | None 

106 wcs: SkyWcs | None 

107 visit: int | None 

108 detector: int | None 

109 ref_id: int | None 

110 ref_mag: float | None 

111 position: Point2D | None 

112 focal_plane_radius: float | None 

113 focal_plane_angle: Angle | None 

114 scale: float | None 

115 scale_err: float | None 

116 pedestal: float | None 

117 pedestal_err: float | None 

118 pedestal_scale_cov: float | None 

119 gradient_x: float | None 

120 gradient_y: float | None 

121 global_reduced_chi_squared: float | None 

122 global_degrees_of_freedom: int | None 

123 psf_reduced_chi_squared: float | None 

124 psf_degrees_of_freedom: int | None 

125 psf_masked_flux_fraction: float | None 

126 

127 # Mapping of metadata keys to attribute names 

128 _metadata_attribute_map = { 

129 "VISIT": "visit", 

130 "DETECTOR": "detector", 

131 "REF_ID": "ref_id", 

132 "REF_MAG": "ref_mag", 

133 "POSITION_X": "position.x", 

134 "POSITION_Y": "position.y", 

135 "FOCAL_PLANE_RADIUS": "focal_plane_radius", 

136 "FOCAL_PLANE_ANGLE_DEGREES": "focal_plane_angle", 

137 "SCALE": "scale", 

138 "SCALE_ERR": "scale_err", 

139 "PEDESTAL": "pedestal", 

140 "PEDESTAL_ERR": "pedestal_err", 

141 "PEDESTAL_SCALE_COV": "pedestal_scale_cov", 

142 "GRADIENT_X": "gradient_x", 

143 "GRADIENT_Y": "gradient_y", 

144 "GLOBAL_REDUCED_CHI_SQUARED": "global_reduced_chi_squared", 

145 "GLOBAL_DEGREES_OF_FREEDOM": "global_degrees_of_freedom", 

146 "PSF_REDUCED_CHI_SQUARED": "psf_reduced_chi_squared", 

147 "PSF_DEGREES_OF_FREEDOM": "psf_degrees_of_freedom", 

148 "PSF_MASKED_FLUX_FRACTION": "psf_masked_flux_fraction", 

149 } 

150 

151 def _getMetadata(self) -> PropertyList: 

152 """Extract metadata from the stamp's attributes. 

153 

154 This method constructs a `PropertyList` containing metadata 

155 extracted from the stamp's attributes. It is used when writing the 

156 stamp to a FITS file to store relevant metadata in the FITS headers. 

157 

158 Returns 

159 ------- 

160 metadata : `PropertyList` 

161 A `PropertyList` containing the metadata, or `None` if no 

162 metadata attributes are defined. 

163 """ 

164 metadata = PropertyList() 

165 for metadata_key, attribute_name in self._metadata_attribute_map.items(): 

166 if "." in attribute_name: 

167 top_attr, sub_attr = attribute_name.split(".") 

168 value = getattr(getattr(self, top_attr), sub_attr) 

169 elif metadata_key == "FOCAL_PLANE_ANGLE_DEGREES": 

170 value = getattr(self, attribute_name).asDegrees() 

171 else: 

172 value = getattr(self, attribute_name) 

173 metadata[metadata_key] = value 

174 return metadata 

175 

176 @property 

177 def metadata(self) -> PropertyList: 

178 """Return the stamp's metadata as a PropertyList.""" 

179 return self._getMetadata() 

180 

181 @classmethod 

182 def factory( 

183 cls, 

184 stamp_im: MaskedImageF, 

185 psf: Psf | None, 

186 wcs: SkyWcs | None, 

187 metadata: PropertyList, 

188 ) -> BrightStarStamp: 

189 """Construct a `BrightStarStamp` from image data and metadata. 

190 

191 This method provides a standard interface to create a `BrightStarStamp` 

192 from its image data, PSF, WCS, and associated metadata. 

193 It is used by the `BrightStarStamps.readFits` method to construct 

194 individual bright star stamps from FITS files. 

195 

196 Parameters 

197 ---------- 

198 stamp_im : `~lsst.afw.image.MaskedImageF` 

199 Masked image for the stamp. 

200 psf : `~lsst.afw.detection.Psf`, optional 

201 Point-spread function for the stamp. 

202 wcs : `~lsst.afw.geom.SkyWcs`, optional 

203 World coordinate system for the stamp. 

204 metadata : `PropertyList` 

205 Metadata associated with the stamp, containing keys for all 

206 required attributes. 

207 

208 Returns 

209 ------- 

210 brightStarStamp : `BrightStarStamp` 

211 The constructed `BrightStarStamp` instance. 

212 """ 

213 kwargs = {} 

214 

215 for metadata_key, attribute_name in cls._metadata_attribute_map.items(): 

216 if "." in attribute_name: # for nested attributes like position.x 

217 top_attr, sub_attr = attribute_name.split(".") 

218 if top_attr not in kwargs: # avoid overwriting position 

219 if top_attr == "position": # make an initial Point2D 

220 kwargs[top_attr] = Point2D(0, 0) 

221 setattr(kwargs[top_attr], sub_attr, metadata[metadata_key]) 

222 elif attribute_name == "focal_plane_angle": 

223 kwargs[attribute_name] = Angle(metadata[metadata_key], degrees) 

224 else: 

225 kwargs[attribute_name] = metadata[metadata_key] 

226 

227 return cls(stamp_im=stamp_im, psf=psf, wcs=wcs, **kwargs) 

228 

229 

230class BrightStarStamps(Sequence[BrightStarStamp]): 

231 """A collection of bright star stamps. 

232 

233 Parameters 

234 ---------- 

235 brightStarStamps : `Iterable` [`BrightStarStamp`] 

236 Collection of `BrightStarStamp` instances. 

237 metadata : `~lsst.daf.base.PropertyList`, optional 

238 Global metadata associated with the collection. 

239 """ 

240 

241 def __init__( 

242 self, 

243 brightStarStamps: Sequence[BrightStarStamp], 

244 metadata: PropertyList | None = None, 

245 ): 

246 self._stamps = list(brightStarStamps) 

247 self._metadata = PropertyList() if metadata is None else metadata.deepCopy() 

248 self.by_ref_id = {stamp.ref_id: stamp for stamp in self} 

249 

250 def __len__(self): 

251 return len(self._stamps) 

252 

253 def __getitem__(self, index): 

254 if isinstance(index, slice): 

255 return BrightStarStamps(self._stamps[index], metadata=self._metadata) 

256 return self._stamps[index] 

257 

258 def __iter__(self): 

259 return iter(self._stamps) 

260 

261 @property 

262 def metadata(self): 

263 """Return the collection's global metadata as a PropertyList.""" 

264 return self._metadata 

265 

266 @classmethod 

267 def readFits(cls, filename: str) -> BrightStarStamps: 

268 """Make a `BrightStarStamps` object from a FITS file. 

269 

270 Parameters 

271 ---------- 

272 filename : `str` 

273 Name of the FITS file to read. 

274 

275 Returns 

276 ------- 

277 brightStarStamps : `BrightStarStamps` 

278 The constructed `BrightStarStamps` instance. 

279 """ 

280 return cls.readFitsWithOptions(filename, None) 

281 

282 @classmethod 

283 def readFitsWithOptions(cls, filename: str, options: PropertyList | None) -> BrightStarStamps: 

284 """Make a `BrightStarStamps` object from a FITS file, with options. 

285 

286 Parameters 

287 ---------- 

288 filename : `str` 

289 Name of the FITS file to read. 

290 options : `~lsst.daf.base.PropertyList`, optional 

291 Options for reading the FITS file. Not currently used. 

292 

293 Returns 

294 ------- 

295 brightStarStamps : `BrightStarStamps` 

296 The constructed `BrightStarStamps` instance. 

297 """ 

298 with Fits(filename, "r") as fits_file: 

299 stamp_planes = {} 

300 stamp_psf_ids = {} 

301 stamp_wcs_ids = {} 

302 stamp_metadata = {} 

303 archive = None 

304 

305 for hdu_num in range(1, fits_file.countHdus()): # Skip primary HDU 

306 metadata = readMetadata(filename, hdu=hdu_num) 

307 extname = metadata["EXTNAME"] 

308 stamp_id: int | None = metadata.get("EXTVER", None) 

309 

310 # Skip non-image BINTABLEs (except ARCHIVE_INDEX) 

311 if metadata["XTENSION"] == "BINTABLE" and not metadata.get("ZIMAGE", False): 

312 if extname != "ARCHIVE_INDEX": 

313 continue 

314 

315 # Handle the archive index separately 

316 if extname == "ARCHIVE_INDEX": 

317 fits_file.setHdu(hdu_num) 

318 archive = InputArchive.readFits(fits_file) 

319 continue 

320 elif metadata.get("EXTTYPE") == "ARCHIVE_DATA": 

321 continue 

322 

323 # Select reader and dtype 

324 if extname == "IMAGE": 

325 reader = ImageFitsReader(filename, hdu=hdu_num) 

326 dtype = np.dtype(MaskedImageF.dtype) 

327 stamp_psf_ids[stamp_id] = metadata.pop("PSF", None) 

328 stamp_wcs_ids[stamp_id] = metadata.pop("WCS", None) 

329 stamp_metadata[stamp_id] = metadata 

330 elif extname == "MASK": 

331 reader = MaskFitsReader(filename, hdu=hdu_num) 

332 dtype = None 

333 elif extname == "VARIANCE": 

334 reader = ImageFitsReader(filename, hdu=hdu_num) 

335 dtype = np.dtype("float32") 

336 else: 

337 raise ValueError(f"Unknown extension type: {extname}") 

338 

339 if stamp_id is not None: 

340 stamp_planes.setdefault(stamp_id, {})[extname.lower()] = reader.read(dtype=dtype) 

341 

342 primary_metadata = readMetadata(filename, hdu=0) 

343 num_stamps = primary_metadata["N_STAMPS"] 

344 

345 if len(stamp_planes) != num_stamps: 

346 raise ValueError( 

347 f"Number of stamps read ({len(stamp_planes)}) does not agree with the " 

348 f"number of stamps recorded in the primary HDU metadata ({num_stamps})." 

349 ) 

350 if archive is None: 

351 raise ValueError("No archive index was found in the FITS file; cannot read PSF or WCS.") 

352 

353 brightStarStamps = [] 

354 for stamp_id in range(1, num_stamps + 1): # Need to increment by one as EXTVER starts at 1 

355 stamp = MaskedImageF(**stamp_planes[stamp_id]) 

356 psf = archive.get(stamp_psf_ids[stamp_id]) 

357 wcs = archive.get(stamp_wcs_ids[stamp_id]) 

358 brightStarStamps.append(BrightStarStamp.factory(stamp, psf, wcs, stamp_metadata[stamp_id])) 

359 

360 return cls(brightStarStamps, primary_metadata) 

361 

362 def writeFits(self, filename: str): 

363 """Write this `BrightStarStamps` object to a FITS file. 

364 

365 Parameters 

366 ---------- 

367 filename : `str` 

368 Name of the FITS file to write. 

369 """ 

370 metadata = self._metadata.deepCopy() 

371 

372 # Store metadata in the primary HDU 

373 metadata["N_STAMPS"] = len(self._stamps) 

374 metadata["VERSION"] = 2 # Record version number in case of future code changes 

375 metadata["STAMPCLS"] = get_full_type_name(self) 

376 

377 # Create and write to the FITS file within a context manager 

378 with Fits(filename, "w") as fits_file: 

379 fits_file.createEmpty() 

380 

381 # Store Persistables in an OutputArchive 

382 output_archive = OutputArchive() 

383 stamp_psf_ids = [] 

384 stamp_wcs_ids = [] 

385 for stamp in self._stamps: 

386 stamp_psf_ids.append(output_archive.put(stamp.psf)) 

387 stamp_wcs_ids.append(output_archive.put(stamp.wcs)) 

388 

389 # Write to the FITS file 

390 fits_file.writeMetadata(metadata) 

391 del metadata 

392 output_archive.writeFits(fits_file) 

393 

394 # Add all pixel data to extension HDUs; note: EXTVER should be 1-based 

395 for stamp_id, (stamp, stamp_psf_id, stamp_wcs_id) in enumerate( 

396 zip(self._stamps, stamp_psf_ids, stamp_wcs_ids), 

397 start=1, 

398 ): 

399 metadata = PropertyList() 

400 metadata.update({"EXTVER": stamp_id, "EXTNAME": "IMAGE"}) 

401 if stamp_metadata := stamp._getMetadata(): 

402 metadata.update(stamp_metadata) 

403 metadata["PSF"] = stamp_psf_id 

404 metadata["WCS"] = stamp_wcs_id 

405 stamp.stamp_im.getImage().writeFits(filename, metadata=metadata, mode="a") 

406 

407 metadata = PropertyList() 

408 metadata.update({"EXTVER": stamp_id, "EXTNAME": "MASK"}) 

409 stamp.stamp_im.getMask().writeFits(filename, metadata=metadata, mode="a") 

410 

411 metadata = PropertyList() 

412 metadata.update({"EXTVER": stamp_id, "EXTNAME": "VARIANCE"}) 

413 stamp.stamp_im.getVariance().writeFits(filename, metadata=metadata, mode="a")