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 

25import warnings 

26 

27from astro_metadata_translator import fix_header 

28from lsst.daf.base import PropertySet 

29from lsst.daf.butler import Formatter 

30# Do not use ExposureFitsReader.readMetadata because that strips 

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

32from lsst.afw.fits import readMetadata 

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

34from lsst.afw.image import ExposureInfo, FilterLabel 

35# Needed for ApCorrMap to resolve properly 

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

37 

38 

39class FitsImageFormatterBase(Formatter): 

40 """Base class interface for reading and writing afw images to and from 

41 FITS files. 

42 

43 This Formatter supports write recipes. 

44 

45 Each ``FitsImageFormatterBase`` recipe for FITS compression should 

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

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

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

49 ``scaling``. 

50 

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

52 

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

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

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

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

57 

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

59 

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

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

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

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

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

65 statistics 

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

67 ``STDEV_*`` scaling 

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

69 ``STDEV_POSITIVE``/``NEGATIVE``) 

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

71 (for ``MANUAL`` scaling) 

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

73 (for ``MANUAL`` scaling) 

74 

75 A very simple example YAML recipe (for the ``Exposure`` specialization): 

76 

77 .. code-block:: yaml 

78 

79 lsst.obs.base.fitsExposureFormatter.FitsExposureFormatter: 

80 default: 

81 image: &default 

82 compression: 

83 algorithm: GZIP_SHUFFLE 

84 mask: *default 

85 variance: *default 

86 

87 """ 

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

89 extension = ".fits" 

90 _metadata = None 

91 supportedWriteParameters = frozenset({"recipe"}) 

92 _readerClass: type # must be set by concrete subclasses 

93 

94 unsupportedParameters = {} 

95 """Support all parameters.""" 

96 

97 @property 

98 def metadata(self): 

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

100 components are extracted from it 

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

102 """ 

103 if self._metadata is None: 

104 self._metadata = self.readMetadata() 

105 return self._metadata 

106 

107 def readMetadata(self): 

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

109 

110 Returns 

111 ------- 

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

113 Header metadata. 

114 """ 

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

116 fix_header(md) 

117 return md 

118 

119 def stripMetadata(self): 

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

121 

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

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

124 components directly rather than trying to extract them from the 

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

126 change from Gen2. 

127 

128 Parameters 

129 ---------- 

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

131 Header metadata, to be modified in-place. 

132 """ 

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

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

135 from lsst.afw.image import bboxFromMetadata 

136 from lsst.afw.geom import makeSkyWcs 

137 

138 # Protect against the metadata being missing 

139 try: 

140 bboxFromMetadata(self.metadata) # always strips 

141 except LookupError: 

142 pass 

143 try: 

144 makeSkyWcs(self.metadata, strip=True) 

145 except Exception: 

146 pass 

147 

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

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

150 

151 Parameters 

152 ---------- 

153 component : `str`, optional 

154 Component to read from the file. 

155 parameters : `dict`, optional 

156 If specified, a dictionary of slicing parameters that 

157 overrides those in ``fileDescriptor``. 

158 

159 Returns 

160 ------- 

161 obj : component-dependent 

162 In-memory component object. 

163 

164 Raises 

165 ------ 

166 KeyError 

167 Raised if the requested component cannot be handled. 

168 """ 

169 

170 # Metadata is handled explicitly elsewhere 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

192 } 

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

194 

195 if method: 

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

197 # when dealing with components. 

198 self._reader = self._readerClass(self.fileDescriptor.location.path) 

199 caller = getattr(self._reader, method, None) 

200 

201 if caller: 

202 if parameters is None: 

203 parameters = self.fileDescriptor.parameters 

204 if parameters is None: 

205 parameters = {} 

206 self.fileDescriptor.storageClass.validateParameters(parameters) 

207 

208 if componentName is None: 

209 if hasParams and parameters: 

210 thisComponent = caller(**parameters) 

211 else: 

212 thisComponent = caller() 

213 else: 

214 thisComponent = caller(componentName) 

215 

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

217 thisComponent = thisComponent.getDimensions() 

218 

219 return thisComponent 

220 else: 

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

222 

223 def readFull(self, parameters=None): 

224 """Read the full Exposure object. 

225 

226 Parameters 

227 ---------- 

228 parameters : `dict`, optional 

229 If specified a dictionary of slicing parameters that overrides 

230 those in ``fileDescriptor``. 

231 

232 Returns 

233 ------- 

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

235 Complete in-memory exposure. 

236 """ 

237 fileDescriptor = self.fileDescriptor 

238 if parameters is None: 

239 parameters = fileDescriptor.parameters 

240 if parameters is None: 

241 parameters = {} 

242 fileDescriptor.storageClass.validateParameters(parameters) 

243 self._reader = self._readerClass(fileDescriptor.location.path) 

244 return self._reader.read(**parameters) 

245 

246 def read(self, component=None): 

247 """Read data from a file. 

248 

249 Parameters 

250 ---------- 

251 component : `str`, optional 

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

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

254 file. 

255 

256 Returns 

257 ------- 

258 inMemoryDataset : `object` 

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

260 is controlled by the specific formatter. 

261 

262 Raises 

263 ------ 

264 ValueError 

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

266 composite. 

267 KeyError 

268 Raised when parameters passed with fileDescriptor are not 

269 supported. 

270 """ 

271 fileDescriptor = self.fileDescriptor 

272 if fileDescriptor.readStorageClass != fileDescriptor.storageClass: 

273 if component == "metadata": 

274 self.stripMetadata() 

275 return self.metadata 

276 elif component is not None: 

277 return self.readComponent(component) 

278 else: 

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

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

281 fileDescriptor.storageClass.name)) 

282 return self.readFull() 

283 

284 def write(self, inMemoryDataset): 

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

286 

287 Parameters 

288 ---------- 

289 inMemoryDataset : `object` 

290 The Python object to store. 

291 """ 

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

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

294 outputPath = self.fileDescriptor.location.path 

295 

296 # check to see if we have a recipe requested 

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

298 recipe = self.getImageCompressionSettings(recipeName) 

299 if recipe: 

300 # Can not construct a PropertySet from a hierarchical 

301 # dict but can update one. 

302 ps = PropertySet() 

303 ps.update(recipe) 

304 inMemoryDataset.writeFitsWithOptions(outputPath, options=ps) 

305 else: 

306 inMemoryDataset.writeFits(outputPath) 

307 

308 def getImageCompressionSettings(self, recipeName): 

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

310 

311 Parameters 

312 ---------- 

313 recipeName : `str` 

314 Label associated with the collection of compression parameters 

315 to select. 

316 

317 Returns 

318 ------- 

319 settings : `dict` 

320 The selected settings. 

321 """ 

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

323 # return immediately 

324 if not recipeName: 

325 if "default" not in self.writeRecipes: 

326 return {} 

327 recipeName = "default" 

328 

329 if recipeName not in self.writeRecipes: 

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

331 

332 recipe = self.writeRecipes[recipeName] 

333 

334 # Set the seed based on dataId 

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

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

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

338 scaling = recipe[plane]["scaling"] 

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

340 scaling["seed"] = seed 

341 

342 return recipe 

343 

344 @classmethod 

345 def validateWriteRecipes(cls, recipes): 

346 """Validate supplied recipes for this formatter. 

347 

348 The recipes are supplemented with default values where appropriate. 

349 

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

351 

352 Parameters 

353 ---------- 

354 recipes : `dict` 

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

356 

357 Returns 

358 ------- 

359 validated : `dict` 

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

361 recipes listed. 

362 

363 Raises 

364 ------ 

365 RuntimeError 

366 Raised if validation fails. 

367 """ 

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

369 # the default value, the expected type). 

370 compressionSchema = { 

371 "algorithm": "NONE", 

372 "rows": 1, 

373 "columns": 0, 

374 "quantizeLevel": 0.0, 

375 } 

376 scalingSchema = { 

377 "algorithm": "NONE", 

378 "bitpix": 0, 

379 "maskPlanes": ["NO_DATA"], 

380 "seed": 0, 

381 "quantizeLevel": 4.0, 

382 "quantizePad": 5.0, 

383 "fuzz": True, 

384 "bscale": 1.0, 

385 "bzero": 0.0, 

386 } 

387 

388 if not recipes: 

389 # We can not insist on recipes being specified 

390 return recipes 

391 

392 def checkUnrecognized(entry, allowed, description): 

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

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

395 if unrecognized: 

396 raise RuntimeError( 

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

398 f"{unrecognized}") 

399 

400 validated = {} 

401 for name in recipes: 

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

403 validated[name] = {} 

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

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

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

407 

408 np = {} 

409 validated[name][plane] = np 

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

411 ("scaling", scalingSchema)): 

412 np[settings] = {} 

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

414 for key in schema: 

415 np[settings][key] = schema[key] 

416 continue 

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

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

419 for key in schema: 

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

421 np[settings][key] = value 

422 return validated 

423 

424 

425class FitsExposureFormatter(FitsImageFormatterBase): 

426 """Specialization for `~lsst.afw.image.Exposure` reading. 

427 """ 

428 

429 _readerClass = ExposureFitsReader 

430 

431 def _fixFilterLabels(self, file_filter_label, should_be_standardized=None): 

432 """Compare the filter label read from the file with the one in the 

433 data ID. 

434 

435 Parameters 

436 ---------- 

437 file_filter_label : `lsst.afw.image.FilterLabel` or `None` 

438 Filter label read from the file, if there was one. 

439 should_be_standardized : `bool`, optional 

440 If `True`, expect ``file_filter_label`` to be consistent with the 

441 data ID and warn only if it is not. If `False`, expect it to be 

442 inconsistent and warn only if the data ID is incomplete and hence 

443 the `FilterLabel` cannot be fixed. If `None` (default) guess 

444 whether the file should be standardized by looking at the 

445 serialization version number in file, which requires this method to 

446 have been run after `readFull` or `readComponent`. 

447 

448 Returns 

449 ------- 

450 filter_label : `lsst.afw.image.FilterLabel` or `None` 

451 The preferred filter label; may be the given one or one built from 

452 the data ID. `None` is returned if there should never be any 

453 filters associated with this dataset type. 

454 

455 Notes 

456 ----- 

457 Most test coverage for this method is in ci_hsc_gen3, where we have 

458 much easier access to test data that exhibits the problems it attempts 

459 to solve. 

460 """ 

461 # Remember filter data ID keys that weren't in this particular data ID, 

462 # so we can warn about them later. 

463 missing = [] 

464 band = None 

465 physical_filter = None 

466 if "band" in self.dataId.graph.dimensions.names: 

467 band = self.dataId.get("band") 

468 # band isn't in the data ID; is that just because this data ID 

469 # hasn't been filled in with everything the Registry knows, or 

470 # because this dataset is never associated with a band? 

471 if band is None and not self.dataId.hasFull() and "band" in self.dataId.graph.implied.names: 

472 missing.append("band") 

473 if "physical_filter" in self.dataId.graph.dimensions.names: 

474 physical_filter = self.dataId.get("physical_filter") 

475 # Same check as above for band, but for physical_filter. 

476 if (physical_filter is None and not self.dataId.hasFull() 

477 and "physical_filter" in self.dataId.graph.implied.names): 

478 missing.append("physical_filter") 

479 if should_be_standardized is None: 

480 version = self._reader.readSerializationVersion() 

481 should_be_standardized = (version >= 2) 

482 if missing: 

483 # Data ID identifies a filter but the actual filter label values 

484 # haven't been fetched from the database; we have no choice but 

485 # to use the one in the file. 

486 # Warn if that's more likely than not to be bad, because the file 

487 # predates filter standardization. 

488 if not should_be_standardized: 

489 warnings.warn(f"Data ID {self.dataId} is missing (implied) value(s) for {missing}; " 

490 "the correctness of this Exposure's FilterLabel cannot be guaranteed. " 

491 "Call Registry.expandDataId before Butler.get to avoid this.") 

492 return file_filter_label 

493 if band is None and physical_filter is None: 

494 data_id_filter_label = None 

495 else: 

496 data_id_filter_label = FilterLabel(band=band, physical=physical_filter) 

497 if data_id_filter_label != file_filter_label and should_be_standardized: 

498 # File was written after FilterLabel and standardization, but its 

499 # FilterLabel doesn't agree with the data ID: this indicates a bug 

500 # in whatever code produced the Exposure (though it may be one that 

501 # has been fixed since the file was written). 

502 warnings.warn(f"Reading {self.fileDescriptor.location} with data ID {self.dataId}: " 

503 f"filter label mismatch (file is {file_filter_label}, data ID is " 

504 f"{data_id_filter_label}). This is probably a bug in the code that produced it.") 

505 return data_id_filter_label 

506 

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

508 # Docstring inherited. 

509 obj = super().readComponent(component, parameters) 

510 if component == "filterLabel": 

511 return self._fixFilterLabels(obj) 

512 else: 

513 return obj 

514 

515 def readFull(self, parameters=None): 

516 # Docstring inherited. 

517 full = super().readFull(parameters) 

518 full.getInfo().setFilterLabel(self._fixFilterLabels(full.getInfo().getFilterLabel())) 

519 return full 

520 

521 

522class FitsImageFormatter(FitsImageFormatterBase): 

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

524 """ 

525 

526 _readerClass = ImageFitsReader 

527 

528 

529class FitsMaskFormatter(FitsImageFormatterBase): 

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

531 """ 

532 

533 _readerClass = MaskFitsReader 

534 

535 

536class FitsMaskedImageFormatter(FitsImageFormatterBase): 

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

538 """ 

539 

540 _readerClass = MaskedImageFitsReader