Coverage for python/lsst/obs/base/formatters/fitsExposure.py : 15%

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/>.
22__all__ = ("FitsExposureFormatter", "FitsImageFormatter", "FitsMaskFormatter",
23 "FitsMaskedImageFormatter")
25import warnings
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
39class FitsImageFormatterBase(Formatter):
40 """Base class interface for reading and writing afw images to and from
41 FITS files.
43 This Formatter supports write recipes.
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``.
51 The allowed entries under ``compression`` are:
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
58 The allowed entries under ``scaling`` are:
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)
75 A very simple example YAML recipe (for the ``Exposure`` specialization):
77 .. code-block:: yaml
79 lsst.obs.base.fitsExposureFormatter.FitsExposureFormatter:
80 default:
81 image: &default
82 compression:
83 algorithm: GZIP_SHUFFLE
84 mask: *default
85 variance: *default
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
94 unsupportedParameters = {}
95 """Support all parameters."""
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
107 def readMetadata(self):
108 """Read all header metadata directly into a PropertyList.
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
119 def stripMetadata(self):
120 """Remove metadata entries that are parsed into components.
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.
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
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
148 def readComponent(self, component, parameters=None):
149 """Read a component held by the Exposure.
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``.
159 Returns
160 -------
161 obj : component-dependent
162 In-memory component object.
164 Raises
165 ------
166 KeyError
167 Raised if the requested component cannot be handled.
168 """
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))
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)
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)
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)
216 if component == "dimensions" and thisComponent is not None:
217 thisComponent = thisComponent.getDimensions()
219 return thisComponent
220 else:
221 raise KeyError(f"Unknown component requested: {component}")
223 def readFull(self, parameters=None):
224 """Read the full Exposure object.
226 Parameters
227 ----------
228 parameters : `dict`, optional
229 If specified a dictionary of slicing parameters that overrides
230 those in ``fileDescriptor``.
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)
246 def read(self, component=None):
247 """Read data from a file.
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.
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.
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()
284 def write(self, inMemoryDataset):
285 """Write a Python object to a file.
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
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)
308 def getImageCompressionSettings(self, recipeName):
309 """Retrieve the relevant compression settings for this recipe.
311 Parameters
312 ----------
313 recipeName : `str`
314 Label associated with the collection of compression parameters
315 to select.
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"
329 if recipeName not in self.writeRecipes:
330 raise RuntimeError(f"Unrecognized recipe option given for compression: {recipeName}")
332 recipe = self.writeRecipes[recipeName]
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
342 return recipe
344 @classmethod
345 def validateWriteRecipes(cls, recipes):
346 """Validate supplied recipes for this formatter.
348 The recipes are supplemented with default values where appropriate.
350 TODO: replace this custom validation code with Cerberus (DM-11846)
352 Parameters
353 ----------
354 recipes : `dict`
355 Recipes to validate. Can be empty dict or `None`.
357 Returns
358 -------
359 validated : `dict`
360 Validated recipes. Returns what was given if there are no
361 recipes listed.
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 }
388 if not recipes:
389 # We can not insist on recipes being specified
390 return recipes
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}")
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}")
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
425class FitsExposureFormatter(FitsImageFormatterBase):
426 """Specialization for `~lsst.afw.image.Exposure` reading.
427 """
429 _readerClass = ExposureFitsReader
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.
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`.
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.
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
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
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
522class FitsImageFormatter(FitsImageFormatterBase):
523 """Specialisation for `~lsst.afw.image.Image` reading.
524 """
526 _readerClass = ImageFitsReader
529class FitsMaskFormatter(FitsImageFormatterBase):
530 """Specialisation for `~lsst.afw.image.Mask` reading.
531 """
533 _readerClass = MaskFitsReader
536class FitsMaskedImageFormatter(FitsImageFormatterBase):
537 """Specialisation for `~lsst.afw.image.MaskedImage` reading.
538 """
540 _readerClass = MaskedImageFitsReader