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 obs_base. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://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 <http://www.gnu.org/licenses/>. 

21 

22__all__ = ("FitsExposureFormatter", "FitsImageFormatter", "FitsMaskFormatter", 

23 "FitsMaskedImageFormatter") 

24 

25from astro_metadata_translator import fix_header 

26from lsst.daf.base import PropertySet 

27from lsst.daf.butler import Formatter 

28# Do not use ExposureFitsReader.readMetadata because that strips 

29# out lots of headers and there is no way to recover them 

30from lsst.afw.fits import readMetadata 

31from lsst.afw.image import ExposureFitsReader, ImageFitsReader, MaskFitsReader, MaskedImageFitsReader 

32from lsst.afw.image import ExposureInfo 

33# Needed for ApCorrMap to resolve properly 

34from lsst.afw.math import BoundedField # noqa: F401 

35 

36 

37class FitsExposureFormatter(Formatter): 

38 """Interface for reading and writing Exposures to and from FITS files. 

39 

40 This Formatter supports write recipes. 

41 

42 Each ``FitsExposureFormatter`` recipe for FITS compression should 

43 define ``image``, ``mask`` and ``variance`` entries, each of which may 

44 contain ``compression`` and ``scaling`` entries. Defaults will be 

45 provided for any missing elements under ``compression`` and 

46 ``scaling``. 

47 

48 The allowed entries under ``compression`` are: 

49 

50 * ``algorithm`` (`str`): compression algorithm to use 

51 * ``rows`` (`int`): number of rows per tile (0 = entire dimension) 

52 * ``columns`` (`int`): number of columns per tile (0 = entire dimension) 

53 * ``quantizeLevel`` (`float`): cfitsio quantization level 

54 

55 The allowed entries under ``scaling`` are: 

56 

57 * ``algorithm`` (`str`): scaling algorithm to use 

58 * ``bitpix`` (`int`): bits per pixel (0,8,16,32,64,-32,-64) 

59 * ``fuzz`` (`bool`): fuzz the values when quantising floating-point values? 

60 * ``seed`` (`int`): seed for random number generator when fuzzing 

61 * ``maskPlanes`` (`list` of `str`): mask planes to ignore when doing 

62 statistics 

63 * ``quantizeLevel`` (`float`): divisor of the standard deviation for 

64 ``STDEV_*`` scaling 

65 * ``quantizePad`` (`float`): number of stdev to allow on the low side (for 

66 ``STDEV_POSITIVE``/``NEGATIVE``) 

67 * ``bscale`` (`float`): manually specified ``BSCALE`` 

68 (for ``MANUAL`` scaling) 

69 * ``bzero`` (`float`): manually specified ``BSCALE`` 

70 (for ``MANUAL`` scaling) 

71 

72 A very simple example YAML recipe: 

73 

74 .. code-block:: yaml 

75 

76 lsst.obs.base.fitsExposureFormatter.FitsExposureFormatter: 

77 default: 

78 image: &default 

79 compression: 

80 algorithm: GZIP_SHUFFLE 

81 mask: *default 

82 variance: *default 

83 

84 """ 

85 supportedExtensions = frozenset({".fits", ".fits.gz", ".fits.fz", ".fz", ".fit"}) 

86 extension = ".fits" 

87 _metadata = None 

88 supportedWriteParameters = frozenset({"recipe"}) 

89 _readerClass = ExposureFitsReader 

90 

91 unsupportedParameters = {} 

92 """Support all parameters.""" 

93 

94 @property 

95 def metadata(self): 

96 """The metadata read from this file. It will be stripped as 

97 components are extracted from it 

98 (`lsst.daf.base.PropertyList`). 

99 """ 

100 if self._metadata is None: 

101 self._metadata = self.readMetadata() 

102 return self._metadata 

103 

104 def readMetadata(self): 

105 """Read all header metadata directly into a PropertyList. 

106 

107 Returns 

108 ------- 

109 metadata : `~lsst.daf.base.PropertyList` 

110 Header metadata. 

111 """ 

112 md = readMetadata(self.fileDescriptor.location.path) 

113 fix_header(md) 

114 return md 

115 

116 def stripMetadata(self): 

117 """Remove metadata entries that are parsed into components. 

118 

119 This is only called when just the metadata is requested; stripping 

120 entries there forces code that wants other components to ask for those 

121 components directly rather than trying to extract them from the 

122 metadata manually, which is fragile. This behavior is an intentional 

123 change from Gen2. 

124 

125 Parameters 

126 ---------- 

127 metadata : `~lsst.daf.base.PropertyList` 

128 Header metadata, to be modified in-place. 

129 """ 

130 # TODO: make sure this covers everything, by delegating to something 

131 # that doesn't yet exist in afw.image.ExposureInfo. 

132 from lsst.afw.image import bboxFromMetadata 

133 from lsst.afw.geom import makeSkyWcs 

134 

135 # Protect against the metadata being missing 

136 try: 

137 bboxFromMetadata(self.metadata) # always strips 

138 except LookupError: 

139 pass 

140 try: 

141 makeSkyWcs(self.metadata, strip=True) 

142 except Exception: 

143 pass 

144 

145 def readComponent(self, component, parameters=None): 

146 """Read a component held by the Exposure. 

147 

148 Parameters 

149 ---------- 

150 component : `str`, optional 

151 Component to read from the file. 

152 parameters : `dict`, optional 

153 If specified, a dictionary of slicing parameters that 

154 overrides those in ``fileDescriptor``. 

155 

156 Returns 

157 ------- 

158 obj : component-dependent 

159 In-memory component object. 

160 

161 Raises 

162 ------ 

163 KeyError 

164 Raised if the requested component cannot be handled. 

165 """ 

166 

167 # Metadata is handled explicitly elsewhere 

168 componentMap = {'wcs': ('readWcs', False, None), 

169 'coaddInputs': ('readCoaddInputs', False, None), 

170 'psf': ('readPsf', False, None), 

171 'image': ('readImage', True, None), 

172 'mask': ('readMask', True, None), 

173 'variance': ('readVariance', True, None), 

174 'photoCalib': ('readPhotoCalib', False, None), 

175 'bbox': ('readBBox', True, None), 

176 'dimensions': ('readBBox', True, None), 

177 'xy0': ('readXY0', True, None), 

178 # TODO: deprecate in DM-27170, remove in DM-27177 

179 'filter': ('readFilter', False, None), 

180 # TODO: deprecate in DM-27177, remove in DM-27811 

181 'filterLabel': ('readFilterLabel', False, None), 

182 'validPolygon': ('readValidPolygon', False, None), 

183 'apCorrMap': ('readApCorrMap', False, None), 

184 'visitInfo': ('readVisitInfo', False, None), 

185 'transmissionCurve': ('readTransmissionCurve', False, None), 

186 'detector': ('readDetector', False, None), 

187 'exposureInfo': ('readExposureInfo', False, None), 

188 'summaryStats': ('readComponent', False, ExposureInfo.KEY_SUMMARY_STATS), 

189 } 

190 method, hasParams, componentName = componentMap.get(component, (None, False, None)) 

191 

192 if method: 

193 # This reader can read standalone Image/Mask files as well 

194 # when dealing with components. 

195 reader = self._readerClass(self.fileDescriptor.location.path) 

196 caller = getattr(reader, method, None) 

197 

198 if caller: 

199 if parameters is None: 

200 parameters = self.fileDescriptor.parameters 

201 if parameters is None: 

202 parameters = {} 

203 self.fileDescriptor.storageClass.validateParameters(parameters) 

204 

205 if componentName is None: 

206 if hasParams and parameters: 

207 thisComponent = caller(**parameters) 

208 else: 

209 thisComponent = caller() 

210 else: 

211 thisComponent = caller(componentName) 

212 

213 if component == "dimensions" and thisComponent is not None: 

214 thisComponent = thisComponent.getDimensions() 

215 return thisComponent 

216 else: 

217 raise KeyError(f"Unknown component requested: {component}") 

218 

219 def readFull(self, parameters=None): 

220 """Read the full Exposure object. 

221 

222 Parameters 

223 ---------- 

224 parameters : `dict`, optional 

225 If specified a dictionary of slicing parameters that overrides 

226 those in ``fileDescriptor``. 

227 

228 Returns 

229 ------- 

230 exposure : `~lsst.afw.image.Exposure` 

231 Complete in-memory exposure. 

232 """ 

233 fileDescriptor = self.fileDescriptor 

234 if parameters is None: 

235 parameters = fileDescriptor.parameters 

236 if parameters is None: 

237 parameters = {} 

238 fileDescriptor.storageClass.validateParameters(parameters) 

239 reader = self._readerClass(fileDescriptor.location.path) 

240 return reader.read(**parameters) 

241 

242 def read(self, component=None): 

243 """Read data from a file. 

244 

245 Parameters 

246 ---------- 

247 component : `str`, optional 

248 Component to read from the file. Only used if the `StorageClass` 

249 for reading differed from the `StorageClass` used to write the 

250 file. 

251 

252 Returns 

253 ------- 

254 inMemoryDataset : `object` 

255 The requested data as a Python object. The type of object 

256 is controlled by the specific formatter. 

257 

258 Raises 

259 ------ 

260 ValueError 

261 Component requested but this file does not seem to be a concrete 

262 composite. 

263 KeyError 

264 Raised when parameters passed with fileDescriptor are not 

265 supported. 

266 """ 

267 fileDescriptor = self.fileDescriptor 

268 if fileDescriptor.readStorageClass != fileDescriptor.storageClass: 

269 if component == "metadata": 

270 self.stripMetadata() 

271 return self.metadata 

272 elif component is not None: 

273 return self.readComponent(component) 

274 else: 

275 raise ValueError("Storage class inconsistency ({} vs {}) but no" 

276 " component requested".format(fileDescriptor.readStorageClass.name, 

277 fileDescriptor.storageClass.name)) 

278 return self.readFull() 

279 

280 def write(self, inMemoryDataset): 

281 """Write a Python object to a file. 

282 

283 Parameters 

284 ---------- 

285 inMemoryDataset : `object` 

286 The Python object to store. 

287 """ 

288 # Update the location with the formatter-preferred file extension 

289 self.fileDescriptor.location.updateExtension(self.extension) 

290 outputPath = self.fileDescriptor.location.path 

291 

292 # check to see if we have a recipe requested 

293 recipeName = self.writeParameters.get("recipe") 

294 recipe = self.getImageCompressionSettings(recipeName) 

295 if recipe: 

296 # Can not construct a PropertySet from a hierarchical 

297 # dict but can update one. 

298 ps = PropertySet() 

299 ps.update(recipe) 

300 inMemoryDataset.writeFitsWithOptions(outputPath, options=ps) 

301 else: 

302 inMemoryDataset.writeFits(outputPath) 

303 

304 def getImageCompressionSettings(self, recipeName): 

305 """Retrieve the relevant compression settings for this recipe. 

306 

307 Parameters 

308 ---------- 

309 recipeName : `str` 

310 Label associated with the collection of compression parameters 

311 to select. 

312 

313 Returns 

314 ------- 

315 settings : `dict` 

316 The selected settings. 

317 """ 

318 # if no recipe has been provided and there is no default 

319 # return immediately 

320 if not recipeName: 

321 if "default" not in self.writeRecipes: 

322 return {} 

323 recipeName = "default" 

324 

325 if recipeName not in self.writeRecipes: 

326 raise RuntimeError(f"Unrecognized recipe option given for compression: {recipeName}") 

327 

328 recipe = self.writeRecipes[recipeName] 

329 

330 # Set the seed based on dataId 

331 seed = hash(tuple(self.dataId.items())) % 2**31 

332 for plane in ("image", "mask", "variance"): 

333 if plane in recipe and "scaling" in recipe[plane]: 

334 scaling = recipe[plane]["scaling"] 

335 if "seed" in scaling and scaling["seed"] == 0: 

336 scaling["seed"] = seed 

337 

338 return recipe 

339 

340 @classmethod 

341 def validateWriteRecipes(cls, recipes): 

342 """Validate supplied recipes for this formatter. 

343 

344 The recipes are supplemented with default values where appropriate. 

345 

346 TODO: replace this custom validation code with Cerberus (DM-11846) 

347 

348 Parameters 

349 ---------- 

350 recipes : `dict` 

351 Recipes to validate. Can be empty dict or `None`. 

352 

353 Returns 

354 ------- 

355 validated : `dict` 

356 Validated recipes. Returns what was given if there are no 

357 recipes listed. 

358 

359 Raises 

360 ------ 

361 RuntimeError 

362 Raised if validation fails. 

363 """ 

364 # Schemas define what should be there, and the default values (and by 

365 # the default value, the expected type). 

366 compressionSchema = { 

367 "algorithm": "NONE", 

368 "rows": 1, 

369 "columns": 0, 

370 "quantizeLevel": 0.0, 

371 } 

372 scalingSchema = { 

373 "algorithm": "NONE", 

374 "bitpix": 0, 

375 "maskPlanes": ["NO_DATA"], 

376 "seed": 0, 

377 "quantizeLevel": 4.0, 

378 "quantizePad": 5.0, 

379 "fuzz": True, 

380 "bscale": 1.0, 

381 "bzero": 0.0, 

382 } 

383 

384 if not recipes: 

385 # We can not insist on recipes being specified 

386 return recipes 

387 

388 def checkUnrecognized(entry, allowed, description): 

389 """Check to see if the entry contains unrecognised keywords""" 

390 unrecognized = set(entry) - set(allowed) 

391 if unrecognized: 

392 raise RuntimeError( 

393 f"Unrecognized entries when parsing image compression recipe {description}: " 

394 f"{unrecognized}") 

395 

396 validated = {} 

397 for name in recipes: 

398 checkUnrecognized(recipes[name], ["image", "mask", "variance"], name) 

399 validated[name] = {} 

400 for plane in ("image", "mask", "variance"): 

401 checkUnrecognized(recipes[name][plane], ["compression", "scaling"], 

402 f"{name}->{plane}") 

403 

404 np = {} 

405 validated[name][plane] = np 

406 for settings, schema in (("compression", compressionSchema), 

407 ("scaling", scalingSchema)): 

408 np[settings] = {} 

409 if settings not in recipes[name][plane]: 

410 for key in schema: 

411 np[settings][key] = schema[key] 

412 continue 

413 entry = recipes[name][plane][settings] 

414 checkUnrecognized(entry, schema.keys(), f"{name}->{plane}->{settings}") 

415 for key in schema: 

416 value = type(schema[key])(entry[key]) if key in entry else schema[key] 

417 np[settings][key] = value 

418 return validated 

419 

420 

421class FitsImageFormatter(FitsExposureFormatter): 

422 """Specialisation for `~lsst.afw.image.Image` reading. 

423 """ 

424 

425 _readerClass = ImageFitsReader 

426 

427 

428class FitsMaskFormatter(FitsExposureFormatter): 

429 """Specialisation for `~lsst.afw.image.Mask` reading. 

430 """ 

431 

432 _readerClass = MaskFitsReader 

433 

434 

435class FitsMaskedImageFormatter(FitsExposureFormatter): 

436 """Specialisation for `~lsst.afw.image.MaskedImage` reading. 

437 """ 

438 

439 _readerClass = MaskedImageFitsReader