Coverage for python/lsst/obs/base/formatters/fitsExposure.py: 84%
359 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-22 08:31 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-22 08:31 +0000
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__ = (
23 "FitsExposureFormatter",
24 "FitsImageFormatter",
25 "FitsImageFormatterBase",
26 "FitsMaskFormatter",
27 "FitsMaskedImageFormatter",
28 "StandardFitsImageFormatterBase",
29 "standardizeAmplifierParameters",
30)
32import hashlib
33import json
34import logging
35import threading
36import uuid
37import warnings
38from abc import abstractmethod
39from collections.abc import Mapping, Set
40from io import BytesIO
41from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol
43import astropy.io.fits
44import numpy as np
46import lsst.geom
47from lsst.afw.cameraGeom import AmplifierGeometryComparison, AmplifierIsolator
48from lsst.afw.fits import CompressionOptions, MemFileManager
49from lsst.afw.geom.wcsUtils import getImageXY0FromMetadata
50from lsst.afw.image import (
51 ExposureFitsReader,
52 ExposureInfo,
53 FilterLabel,
54 ImageFitsReader,
55 MaskedImageFitsReader,
56 MaskFitsReader,
57)
59# Needed for ApCorrMap to resolve properly
60from lsst.afw.math import BoundedField # noqa: F401
61from lsst.daf.base import PropertyList
62from lsst.daf.butler import DatasetProvenance, FormatterV2
63from lsst.resources import ResourcePath
64from lsst.utils.classes import cached_getter
65from lsst.utils.introspection import find_outside_stacklevel
67from ..utils import add_provenance_to_fits_header
69if TYPE_CHECKING:
70 import lsst.afw.cameraGeom
73_LOG = logging.getLogger(__name__)
75_ALWAYS_USE_ASTROPY_FOR_COMPONENT_READ = False
76"""If True, the astropy code will always be used to read component and cutouts
77even if the file is local, the cutout is too large, or the dataset type is
78wrong. This should mostly be used for testing.
79"""
82class _ReaderClassLike(Protocol):
83 def __init__(self, path: str) -> None: ...
84 def readBBox(self) -> lsst.geom.Box2I: ...
85 def read(self, bbox: lsst.geom.Box2I = lsst.geom.Box2I(), dtype: Any = None) -> Any: ...
86 def readImage(self, bbox: lsst.geom.Box2I = lsst.geom.Box2I(), dtype: Any = None) -> Any: ...
87 def readMask(self, bbox: lsst.geom.Box2I = lsst.geom.Box2I(), dtype: Any = None) -> Any: ...
88 def readVariance(self, bbox: lsst.geom.Box2I = lsst.geom.Box2I(), dtype: Any = None) -> Any: ...
89 def readDetector(self) -> lsst.afw.cameraGeom.Detector: ...
90 def readComponent(self, component: str) -> Any: ...
91 def readMetadata(self) -> PropertyList: ...
92 def readSerializationVersion(self) -> int: ...
95class FitsImageFormatterBase(FormatterV2):
96 """Base class formatter for image-like storage classes stored via FITS.
98 Notes
99 -----
100 This class makes no assumptions about how many HDUs are used to represent
101 the image on disk, and includes no support for writing. It's really just a
102 collection of miscellaneous boilerplate common to all FITS image
103 formatters.
105 Concrete subclasses must implement `readComponent`, `readFull`, and
106 `write_local_file` (even if just to disable them by raising an exception).
107 """
109 can_read_from_local_file = True
110 default_extension = ".fits"
111 supported_extensions: ClassVar[Set[str]] = frozenset({".fits", ".fits.gz", ".fits.fz", ".fz", ".fit"})
113 unsupported_parameters: ClassVar[Set[str]] = frozenset()
114 """Support all parameters."""
116 _reader = None
117 _reader_path: str | None = None
119 ReaderClass: type[_ReaderClassLike] # must be set by concrete subclasses
120 """Class to use for reading FITS files in the expected way.
121 (e.g., `type` [`lsst.afw.image.ImageFitsReader])
122 """
124 @property
125 def reader(self) -> _ReaderClassLike:
126 """The reader object that backs this formatter's read operations.
128 This is computed on first use and then cached. It should never be
129 accessed when writing. Currently assumes a local file.
130 """
131 if self._reader is None:
132 if self._reader_path is None: 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true
133 raise RuntimeError("Internal error in formatter; failing to set path.")
134 self._reader = self.ReaderClass(self._reader_path)
135 return self._reader
137 @property
138 @cached_getter
139 def checked_parameters(self) -> dict[str, Any]:
140 """The parameters passed by the butler user, after checking them
141 against the storage class and transforming `None` into an empty `dict`
142 (`dict`).
144 This is computed on first use and then cached. It should never be
145 accessed when writing. Subclasses that need additional checking should
146 delegate to `super` and then check the result before returning it.
147 """
148 parameters = self.file_descriptor.parameters
149 if parameters is None:
150 parameters = {}
151 self.file_descriptor.storageClass.validateParameters(parameters)
152 return parameters
154 @property
155 def storageClass_dtype(self) -> np.dtype | None:
156 """The numpy data type associated with the storage class."""
157 dtype: np.dtype | None = None
158 try:
159 # lsst.afw.image.Exposure is generic base class and does not have
160 # the dtype attribute.
161 dtype = np.dtype(self.file_descriptor.storageClass.pytype.dtype) # type: ignore[attr-defined]
162 except AttributeError:
163 pass
164 return dtype
166 def read_from_local_file(self, path: str, component: str | None = None, expected_size: int = -1) -> Any:
167 # Docstring inherited.
168 if future_type := _get_future_image_type(self.file_descriptor.readStorageClass.name, component): 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true
169 return future_type.read_legacy(
170 path,
171 component=component,
172 preserve_quantization=self.checked_parameters.get("preserve_quantization", False),
173 )
174 elif self.checked_parameters.get("preserve_quantization", False): 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true
175 raise NotImplementedError(
176 "preserve_quantization=True only works when converting to VisitImage on read."
177 )
179 # The methods doing the reading all currently assume local file
180 # and assume that the file descriptor refers to a local file.
181 # With FormatterV2 that file descriptor does not refer to a local
182 # file.
183 self._reader_path = path
184 self._reader = None # Ensure the reader class is reset.
185 try:
186 if component is not None:
187 in_memory_dataset = self.readComponent(component)
188 else:
189 in_memory_dataset = self.readFull()
190 finally:
191 self._reader = None # Release the file handle.
192 return in_memory_dataset
194 @abstractmethod
195 def readComponent(self, component: str) -> Any:
196 """Read a component dataset.
198 Parameters
199 ----------
200 component : `str`, optional
201 Component to read from the file.
203 Returns
204 -------
205 obj : `typing.Any`
206 In-memory component object.
208 Raises
209 ------
210 KeyError
211 Raised if the requested component cannot be handled.
212 """
213 raise NotImplementedError()
215 @abstractmethod
216 def readFull(self) -> Any:
217 """Read the full dataset (while still accounting for parameters).
219 Returns
220 -------
221 obj : `typing.Any`
222 In-memory component object.
224 """
225 raise NotImplementedError()
228class StandardFitsImageFormatterBase(FitsImageFormatterBase):
229 """Base class interface for image-like storage stored via FITS,
230 written using LSST code.
232 Notes
233 -----
234 Concrete subclasses must provide at least the ``ReaderClass`` attribute.
236 The provided implementation of `readComponent` handles only the 'bbox',
237 'dimensions', and 'xy0' components common to all image-like storage
238 classes. Subclasses with additional components should handle them first,
239 then delegate to ``super()`` for these (or, if necessary, delegate first
240 and catch `KeyError`).
242 The provided implementation of `readFull` handles only parameters that
243 can be forwarded directly to the reader class (usually ``bbox`` and
244 ``origin``). Concrete subclasses that need to handle additional parameters
245 should generally reimplement without delegating (the implementation is
246 trivial).
248 This Formatter supports write recipes, and assumes its in-memory type has
249 ``writeFits`` and (for write recipes) ``writeFitsWithOptions`` methods.
251 Each ``StandardFitsImageFormatterBase`` recipe for FITS compression should
252 define ``image``, ``mask`` and ``variance`` entries, each of which may
253 contain entries supported by
254 `lsst.afw.fits.CompressionOptions.from_mapping` (``null`` disables
255 compression).
257 A very simple example YAML recipe (for the `lsst.afw.image.Exposure`
258 specialization):
260 .. code-block:: yaml
262 lsst.obs.base.fitsExposureFormatter.FitsExposureFormatter:
263 default:
264 image: &default
265 algorithm: GZIP_2
266 mask: *default
267 variance: *default
269 """
271 supported_write_parameters = frozenset({"recipe"})
273 def readComponent(self, component: str) -> Any:
274 # Docstring inherited.
275 if component in ("bbox", "dimensions", "xy0"): 275 ↛ 284line 275 didn't jump to line 284 because the condition on line 275 was always true
276 bbox = self.reader.readBBox()
277 if component == "dimensions":
278 return bbox.getDimensions()
279 elif component == "xy0":
280 return bbox.getMin()
281 else:
282 return bbox
283 else:
284 raise KeyError(f"Unknown component requested: {component}")
286 def readFull(self) -> Any:
287 # Docstring inherited.
288 return self.reader.read(**self.checked_parameters, dtype=self.storageClass_dtype)
290 def write_local_file(self, in_memory_dataset: Any, uri: ResourcePath) -> None:
291 """Serialize the image to FITS.
293 Parameters
294 ----------
295 in_memory_dataset : `object`
296 Image to write. Must support a ``writeFits`` or
297 ``writeFitsWithOptions`` interface.
298 uri : `lsst.resources.ResourcePath`
299 Location to write the local file.
300 """
301 # check to see if we have a recipe requested
302 recipeName = self.write_parameters.get("recipe")
303 recipe = self.get_image_compression_settings(recipeName)
304 if recipe:
305 in_memory_dataset.writeFitsWithOptions(uri.ospath, options=recipe)
306 else:
307 in_memory_dataset.writeFits(uri.ospath)
309 def get_image_compression_settings(self, recipeName: str | None) -> dict:
310 """Retrieve the relevant compression settings for this recipe.
312 Parameters
313 ----------
314 recipeName : `str` or `None`
315 Label associated with the collection of compression parameters
316 to select.
318 Returns
319 -------
320 settings : `dict`
321 The selected settings.
322 """
323 # if no recipe has been provided and there is no default
324 # return immediately
325 if not recipeName:
326 if "default" not in self.write_recipes: 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true
327 return {}
328 recipeName = "default"
330 if recipeName not in self.write_recipes: 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true
331 raise RuntimeError(f"Unrecognized recipe option given for compression: {recipeName}")
333 recipe = self.write_recipes[recipeName]
334 if recipe is None:
335 return {}
336 seed: int | None = None
337 for plane in ("image", "mask", "variance"):
338 if plane in recipe and (quantization := recipe[plane].get("quantization")) is not None:
339 if quantization.get("seed", 0) == 0: 339 ↛ 364line 339 didn't jump to line 364 because the condition on line 339 was always true
340 if seed is None:
341 # Set the seed based on data ID. We can't just use
342 # 'hash', since like 'set' that's not deterministic.
343 # And we can't rely on a DimensionPacker because those
344 # are only defined for certain combinations of
345 # dimensions. Doing an MD5 of the JSON feels like
346 # overkill but I don't really see anything much
347 # simpler.
348 hash_bytes = hashlib.md5(
349 json.dumps(list(self.data_id.required_values)).encode(),
350 usedforsecurity=False,
351 ).digest()
352 # And it *really* feels like overkill when we squash
353 # that into the [1, 10000] range allowed by FITS.
354 seed = 1 + int.from_bytes(hash_bytes) % 9999
355 _LOG.debug(
356 "Setting compression quantization seed for %s %s %s to %s.",
357 self.data_id,
358 self.dataset_ref.datasetType.name,
359 plane,
360 seed,
361 )
362 quantization["seed"] = seed
363 else:
364 _LOG.warning(
365 "Compression quantization seed for %s %s %s was set explicitly to %s.",
366 self.dataset_ref.datasetType.name,
367 self.data_id,
368 plane,
369 quantization["seed"],
370 )
371 else:
372 _LOG.debug(
373 "No quantization found for %s %s %s.",
374 self.dataset_ref.datasetType.name,
375 self.data_id,
376 plane,
377 )
378 return recipe
380 @classmethod
381 def validate_write_recipes(cls, recipes: Mapping[str, Any] | None) -> Mapping[str, Any] | None:
382 """Validate supplied recipes for this formatter.
384 The recipes are supplemented with default values where appropriate.
386 Parameters
387 ----------
388 recipes : `dict` or `None`
389 Recipes to validate. Can be empty dict or `None`.
391 Returns
392 -------
393 validated : `dict`
394 Validated recipes. Returns what was given if there are no
395 recipes listed.
397 Raises
398 ------
399 RuntimeError
400 Raised if validation fails.
401 """
402 if not recipes:
403 # We can not insist on recipes being specified.
404 return recipes
406 validated: dict[str, Any] = {}
407 for name, recipe in recipes.items():
408 if recipe is not None:
409 validated[name] = {}
410 for plane in ["image", "mask", "variance"]:
411 try:
412 options = CompressionOptions.from_mapping(recipe[plane])
413 except Exception as err:
414 err.add_note(f"Validating write recipe {name!r} ({plane!r} section).")
415 raise
416 validated[name][plane] = options.to_dict()
417 else:
418 validated[name] = None
419 return validated
422class FitsImageFormatter(StandardFitsImageFormatterBase):
423 """Concrete formatter for reading/writing `~lsst.afw.image.Image`
424 from/to FITS.
425 """
427 ReaderClass = ImageFitsReader
430class FitsMaskFormatter(StandardFitsImageFormatterBase):
431 """Concrete formatter for reading/writing `~lsst.afw.image.Mask`
432 from/to FITS.
433 """
435 ReaderClass = MaskFitsReader
438class FitsMaskedImageFormatter(StandardFitsImageFormatterBase):
439 """Concrete formatter for reading/writing `~lsst.afw.image.MaskedImage`
440 from/to FITS.
441 """
443 ReaderClass = MaskedImageFitsReader
445 def readComponent(self, component: str) -> Any:
446 # Docstring inherited.
447 if component == "image":
448 return self.reader.readImage(**self.checked_parameters, dtype=self.storageClass_dtype)
449 elif component == "mask":
450 return self.reader.readMask(**self.checked_parameters)
451 elif component == "variance":
452 return self.reader.readVariance(**self.checked_parameters, dtype=self.storageClass_dtype)
453 else:
454 # Delegate to base for bbox, dimensions, xy0.
455 return super().readComponent(component)
458def standardizeAmplifierParameters(
459 parameters: dict[str, Any], on_disk_detector: lsst.afw.cameraGeom.Detector | None
460) -> tuple[lsst.afw.cameraGeom.Amplifier, lsst.afw.cameraGeom.Detector, bool]:
461 """Preprocess the Exposure storage class's "amp" and "detector" parameters.
463 This checks the given objects for consistency with the on-disk geometry and
464 converts amplifier IDs/names to Amplifier instances.
466 Parameters
467 ----------
468 parameters : `dict`
469 Dictionary of parameters passed to formatter. See the Exposure storage
470 class definition in daf_butler for allowed keys and values.
471 on_disk_detector : `lsst.afw.cameraGeom.Detector` or `None`
472 Detector that represents the on-disk image being loaded, or `None` if
473 this is unknown (and hence the user must provide one in
474 ``parameters`` if "amp" is in ``parameters``).
476 Returns
477 -------
478 amplifier : `lsst.afw.cameraGeom.Amplifier` or `None`
479 An amplifier object that defines a subimage to load, or `None` if there
480 was no "amp" parameter.
481 detector : `lsst.afw.cameraGeom.Detector` or `None`
482 A detector object whose amplifiers are in the same s/orientation
483 state as the on-disk image. If there is no "amp" parameter,
484 ``on_disk_detector`` is simply passed through.
485 regions_differ : `bool`
486 `True` if the on-disk detector and the detector given in the parameters
487 had different bounding boxes for one or more regions. This can happen
488 if the true overscan region sizes can only be determined when the image
489 is actually read, but otherwise it should be considered user error.
490 """
491 if (amplifier := parameters.get("amp")) is None:
492 return None, on_disk_detector, False
493 if "bbox" in parameters or "origin" in parameters: 493 ↛ 494line 493 didn't jump to line 494 because the condition on line 493 was never true
494 raise ValueError("Cannot pass 'amp' with 'bbox' or 'origin'.")
495 if isinstance(amplifier, int | str):
496 amp_key = amplifier
497 target_amplifier = None
498 else:
499 amp_key = amplifier.getName()
500 target_amplifier = amplifier
501 if (detector := parameters.get("detector")) is not None:
502 if on_disk_detector is not None: 502 ↛ 523line 502 didn't jump to line 523 because the condition on line 502 was always true
503 # User passed a detector and we also found one on disk. Check them
504 # for consistency. Note that we are checking the amps we'd get
505 # from the two detectors against each other, not the amplifier we
506 # got directly from the user, as the latter is allowed to differ in
507 # assembly/orientation state.
508 comparison = on_disk_detector[amp_key].compareGeometry(detector[amp_key])
509 if comparison & comparison.ASSEMBLY_DIFFERS: 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true
510 raise ValueError(
511 "The given 'detector' has a different assembly state and/or orientation from "
512 f"the on-disk one for amp {amp_key}."
513 )
514 else:
515 if on_disk_detector is None: 515 ↛ 516line 515 didn't jump to line 516 because the condition on line 515 was never true
516 raise ValueError(
517 f"No on-disk detector and no detector given; cannot load amplifier from key {amp_key}. "
518 "Please provide either a 'detector' parameter or an Amplifier instance in the "
519 "'amp' parameter."
520 )
521 comparison = AmplifierGeometryComparison.EQUAL
522 detector = on_disk_detector
523 if target_amplifier is None:
524 target_amplifier = detector[amp_key]
525 return target_amplifier, detector, comparison & comparison.REGIONS_DIFFER
528class _ComponentCache(NamedTuple):
529 id_: uuid.UUID | None = None
530 reader: ExposureFitsReader | None = None
531 bbox: lsst.geom.Box2I | None = None
532 mem: MemFileManager | None = None
535class FitsExposureFormatter(FitsMaskedImageFormatter):
536 """Concrete formatter for reading/writing `~lsst.afw.image.Exposure`
537 from/to FITS.
539 Notes
540 -----
541 This class inherits from `FitsMaskedImageFormatter` even though
542 `lsst.afw.image.Exposure` doesn't inherit from
543 `lsst.afw.image.MaskedImage`; this is just an easy way to be able to
544 delegate to `FitsMaskedImageFormatter` for component-handling, and
545 should be replaced with e.g. both calling a free function if that slight
546 type covariance violation ever becomes a practical problem.
547 """
549 can_read_from_uri = True
550 ReaderClass = ExposureFitsReader
551 # TODO: Remove MemFileManager from cache when DM-49640 is fixed.
552 _lock = threading.Lock()
553 _cached_fits: _ComponentCache = _ComponentCache()
555 def read_from_uri(self, uri: ResourcePath, component: str | None = None, expected_size: int = -1) -> Any:
556 # For now only support small non-pixel components. In future
557 # could work with cutouts.
558 self._reader = None # Guarantee things are reset.
560 parameters = self.checked_parameters.copy()
561 preserve_quantization = parameters.pop("preserve_quantization", False)
562 # Full read, always use local file read.
563 if preserve_quantization or (not component and not parameters):
564 return NotImplemented
566 if not _ALWAYS_USE_ASTROPY_FOR_COMPONENT_READ and uri.isLocal:
567 # For a local URI allow afw to read it directly.
568 return NotImplemented
569 pixel_components = ("mask", "image", "variance")
571 if component in pixel_components:
572 # For pixel access currently this can not be cached in memory
573 # and the performance gains are unclear. Assume local file
574 # read with file caching for now.
575 return NotImplemented
577 # With current file layouts the non-pixel extensions account for 1/3
578 # of the file size and it is more efficient to download the entire
579 # file.
580 if not ( 580 ↛ 584line 580 didn't jump to line 584 because the condition on line 580 was never true
581 _ALWAYS_USE_ASTROPY_FOR_COMPONENT_READ
582 or self._dataset_ref.dataId.mapping.keys().isdisjoint({"tract", "patch"})
583 ):
584 return NotImplemented
586 # Cutouts can be optimized. For now only use this optimization
587 # if bbox is the only parameter and the number of pixels in the
588 # bounding box is reasonable.
589 bbox = None
590 origin = lsst.afw.image.PARENT
591 if not component:
592 # Try to support PARENT and LOCAL origin but if there are any
593 # other parameters do not attempt a cutout.
594 if parameters.keys() - {"bbox", "origin"}: 594 ↛ 595line 594 didn't jump to line 595 because the condition on line 594 was never true
595 return NotImplemented
596 bbox = parameters["bbox"]
597 origin = parameters.get("origin", lsst.afw.image.PARENT)
598 # For larger cutouts use the full file.
599 max_cutout_size = 500 * 500
600 if not _ALWAYS_USE_ASTROPY_FOR_COMPONENT_READ and bbox.width * bbox.height > max_cutout_size: 600 ↛ 601line 600 didn't jump to line 601 because the condition on line 600 was never true
601 return NotImplemented
603 # We only cache component reads since those are small.
604 if component:
605 with self._lock:
606 cache = type(self)._cached_fits
607 if self.dataset_ref.id == cache.id_:
608 if component in {"xy0", "dimensions", "bbox"} and cache.bbox is not None:
609 match component:
610 case "xy0":
611 return cache.bbox.getMin()
612 case "dimensions":
613 return cache.bbox.getDimensions()
614 case "bbox": 614 ↛ 620line 614 didn't jump to line 620 because the pattern on line 614 always matched
615 return cache.bbox
616 else:
617 self._reader = cache.reader
618 return self.readComponent(component)
620 try:
621 fs, fspath = uri.to_fsspec()
622 except Exception:
623 # fsspec cannot be initialized, fall back to downloading the file.
624 return NotImplemented
626 bbox_component = None
627 try:
628 hdul = []
629 with fs.open(fspath) as f, astropy.io.fits.open(f) as fits_obj:
630 # Read all non-pixel components and cache.
631 for hdu in fits_obj:
632 hdr = hdu.header
633 extname = hdr.get("EXTNAME")
634 # Older files have IMAGE in EXTNAME in PRIMARY so check
635 # for EXTEND=T.
636 extend = hdr.get("EXTEND")
637 if not extend and extname and extname.lower() in pixel_components:
638 # Calculate the dimensional components for later
639 # caching. Do not derive from cached FITS reader
640 # because they depend on the dimensionality of the
641 # pixel data and we do not want to cache the pixel
642 # data.
643 if bbox_component is None:
644 shape = hdu.shape
645 dimensions = lsst.geom.Extent2I(shape[1], shape[0])
647 # XY0 is defined in the A WCS.
648 pl = PropertyList()
649 pl.update(hdr)
650 xy0 = getImageXY0FromMetadata(pl, "A", strip=False)
652 # This is the PARENT bbox.
653 bbox_component = lsst.geom.Box2I(xy0, dimensions)
655 # Handle cutout request.
656 if bbox:
657 if origin == lsst.afw.image.PARENT:
658 full_bbox = bbox_component
659 else:
660 full_bbox = lsst.geom.Box2I(
661 lsst.geom.Point2I(0, 0), bbox_component.getDimensions
662 )
663 minX = bbox.getBeginX() - full_bbox.getBeginX()
664 maxX = bbox.getEndX() - full_bbox.getBeginX()
665 minY = bbox.getBeginY() - full_bbox.getBeginY()
666 maxY = bbox.getEndY() - full_bbox.getBeginY()
667 data = hdu.section[minY:maxY, minX:maxX]
669 # Must correct the header WCS to take into
670 # account the offset.
671 if (k := "CRPIX1") in hdr: 671 ↛ 673line 671 didn't jump to line 673 because the condition on line 671 was always true
672 hdr[k] -= minX
673 if (k := "CRPIX2") in hdr: 673 ↛ 675line 673 didn't jump to line 675 because the condition on line 673 was always true
674 hdr[k] -= minY
675 if (k := "LTV1") in hdr: 675 ↛ 677line 675 didn't jump to line 677 because the condition on line 675 was always true
676 hdr[k] = -bbox.getBeginX()
677 if (k := "LTV2") in hdr: 677 ↛ 679line 677 didn't jump to line 679 because the condition on line 677 was always true
678 hdr[k] = -bbox.getBeginY()
679 if (k := "CRVAL1A") in hdr: 679 ↛ 681line 679 didn't jump to line 681 because the condition on line 679 was always true
680 hdr[k] = bbox.getBeginX()
681 if (k := "CRVAL2A") in hdr: 681 ↛ 687line 681 didn't jump to line 687 because the condition on line 681 was always true
682 hdr[k] = bbox.getBeginY()
683 else:
684 data = np.zeros([1, 1], dtype=np.int32)
686 # Construct a new HDU and copy the header.
687 stripped_hdu = astropy.io.fits.ImageHDU(data=data, header=hdr)
688 hdul.append(stripped_hdu)
689 else:
690 hdul.append(hdu)
691 stripped_fits = astropy.io.fits.HDUList(hdus=hdul)
692 # Write the FITS file to in-memory FITS.
693 buffer = BytesIO()
694 stripped_fits.writeto(buffer)
695 except Exception as e:
696 # For some reason we can't open the remote file so fall back.
697 _LOG.debug(
698 "Attempted remote read of components but encountered an error. "
699 "Falling back to file download. Error: %s",
700 str(e),
701 )
702 return NotImplemented
704 # Pass the new FITS buffer to the reader class without going through
705 # a temporary file. We can assume this is relatively small for
706 # components.
707 fits_data = buffer.getvalue()
708 mem = MemFileManager(len(fits_data))
709 mem.setData(fits_data, len(fits_data))
710 self._reader = self.ReaderClass(mem)
712 if component:
713 with self._lock:
714 type(self)._cached_fits = _ComponentCache(
715 id_=self.dataset_ref.id,
716 reader=self._reader,
717 mem=mem,
718 bbox=bbox_component,
719 )
720 match component:
721 case "xy0": 721 ↛ 722line 721 didn't jump to line 722 because the pattern on line 721 never matched
722 if bbox_component is None: # For mypy.
723 return None
724 return bbox_component.getMin()
725 case "dimensions": 725 ↛ 726line 725 didn't jump to line 726 because the pattern on line 725 never matched
726 if bbox_component is None:
727 return None
728 return bbox_component.getDimensions()
729 case "bbox": 729 ↛ 730line 729 didn't jump to line 730 because the pattern on line 729 never matched
730 return bbox_component
731 case _:
732 return self.readComponent(component)
733 else:
734 # Must be a cutout. We have applied the bbox parameter so no
735 # parameters should be passed here.
736 cutout = self.reader.read(dtype=self.storageClass_dtype)
737 cutout.getInfo().setFilter(self._fixFilterLabels(cutout.getInfo().getFilter()))
738 return cutout
740 def add_provenance(
741 self, in_memory_dataset: Any, /, *, provenance: DatasetProvenance | None = None
742 ) -> Any:
743 # Add provenance via FITS headers.
744 add_provenance_to_fits_header(in_memory_dataset.metadata, self.dataset_ref, provenance)
745 return in_memory_dataset
747 def readComponent(self, component: str) -> Any:
748 # Docstring inherited.
749 # Generic components can be read via a string name; DM-27754 will make
750 # this mapping larger at the expense of the following one.
751 genericComponents = {
752 "summaryStats": ExposureInfo.KEY_SUMMARY_STATS,
753 }
754 if (genericComponentName := genericComponents.get(component)) is not None:
755 return self.reader.readComponent(genericComponentName)
756 # Other components have hard-coded method names, but don't take
757 # parameters.
758 standardComponents = {
759 "id": "readExposureId",
760 "metadata": "readMetadata",
761 "wcs": "readWcs",
762 "coaddInputs": "readCoaddInputs",
763 "psf": "readPsf",
764 "photoCalib": "readPhotoCalib",
765 "filter": "readFilter",
766 "validPolygon": "readValidPolygon",
767 "apCorrMap": "readApCorrMap",
768 "visitInfo": "readVisitInfo",
769 "transmissionCurve": "readTransmissionCurve",
770 "detector": "readDetector",
771 "exposureInfo": "readExposureInfo",
772 }
773 if (methodName := standardComponents.get(component)) is not None:
774 result = getattr(self.reader, methodName)()
775 if component == "filter":
776 return self._fixFilterLabels(result)
777 return result
778 # Delegate to MaskedImage and ImageBase implementations for the rest.
779 return super().readComponent(component)
781 def readFull(self) -> Any:
782 # Docstring inherited.
783 amplifier, detector, _ = standardizeAmplifierParameters(
784 self.checked_parameters,
785 self.reader.readDetector(),
786 )
787 if amplifier is not None:
788 amplifier_isolator = AmplifierIsolator(
789 amplifier,
790 self.reader.readBBox(),
791 detector,
792 )
793 result = amplifier_isolator.transform_subimage(
794 self.reader.read(bbox=amplifier_isolator.subimage_bbox, dtype=self.storageClass_dtype)
795 )
796 result.setDetector(amplifier_isolator.make_detector())
797 else:
798 result = self.reader.read(**self.checked_parameters, dtype=self.storageClass_dtype)
799 result.getInfo().setFilter(self._fixFilterLabels(result.getInfo().getFilter()))
800 return result
802 def _fixFilterLabels(
803 self, file_filter_label: lsst.afw.image.FilterLabel, should_be_standardized: bool | None = None
804 ) -> lsst.afw.image.FilterLabel:
805 """Compare the filter label read from the file with the one in the
806 data ID.
808 Parameters
809 ----------
810 file_filter_label : `lsst.afw.image.FilterLabel` or `None`
811 Filter label read from the file, if there was one.
812 should_be_standardized : `bool`, optional
813 If `True`, expect ``file_filter_label`` to be consistent with the
814 data ID and warn only if it is not. If `False`, expect it to be
815 inconsistent and warn only if the data ID is incomplete and hence
816 the `FilterLabel` cannot be fixed. If `None` (default) guess
817 whether the file should be standardized by looking at the
818 serialization version number in file, which requires this method to
819 have been run after `readFull` or `readComponent`.
821 Returns
822 -------
823 filter_label : `lsst.afw.image.FilterLabel` or `None`
824 The preferred filter label; may be the given one or one built from
825 the data ID. `None` is returned if there should never be any
826 filters associated with this dataset type.
828 Notes
829 -----
830 Most test coverage for this method is in ci_hsc_gen3, where we have
831 much easier access to test data that exhibits the problems it attempts
832 to solve.
833 """
834 # Remember filter data ID keys that weren't in this particular data ID,
835 # so we can warn about them later.
836 missing = []
837 band = None
838 physical_filter = None
839 if "band" in self.data_id.dimensions.names:
840 band = self.data_id.get("band")
841 # band isn't in the data ID; is that just because this data ID
842 # hasn't been filled in with everything the Registry knows, or
843 # because this dataset is never associated with a band?
844 if band is None and not self.data_id.hasFull() and "band" in self.data_id.dimensions.implied: 844 ↛ 845line 844 didn't jump to line 845 because the condition on line 844 was never true
845 missing.append("band")
846 if "physical_filter" in self.data_id.dimensions.names:
847 physical_filter = self.data_id.get("physical_filter")
848 # Same check as above for band, but for physical_filter.
849 if ( 849 ↛ 854line 849 didn't jump to line 854 because the condition on line 849 was never true
850 physical_filter is None
851 and not self.data_id.hasFull()
852 and "physical_filter" in self.data_id.dimensions.implied
853 ):
854 missing.append("physical_filter")
855 if should_be_standardized is None: 855 ↛ 858line 855 didn't jump to line 858 because the condition on line 855 was always true
856 version = self.reader.readSerializationVersion()
857 should_be_standardized = version >= 2
858 if missing: 858 ↛ 864line 858 didn't jump to line 864 because the condition on line 858 was never true
859 # Data ID identifies a filter but the actual filter label values
860 # haven't been fetched from the database; we have no choice but
861 # to use the one in the file.
862 # Warn if that's more likely than not to be bad, because the file
863 # predates filter standardization.
864 if not should_be_standardized:
865 warnings.warn(
866 f"Data ID {self.data_id} is missing (implied) value(s) for {missing}; "
867 "the correctness of this Exposure's FilterLabel cannot be guaranteed. "
868 "Call Registry.expandDataId before Butler.get to avoid this.",
869 # Report the warning from outside of middleware or the
870 # relevant runQuantum method.
871 stacklevel=find_outside_stacklevel(
872 "lsst.obs.base", "lsst.pipe.base", "lsst.daf.butler", allow_methods={"runQuantum"}
873 ),
874 )
875 return file_filter_label
876 if band is None and physical_filter is None:
877 data_id_filter_label = None
878 else:
879 data_id_filter_label = FilterLabel(band=band, physical=physical_filter)
880 if data_id_filter_label != file_filter_label and should_be_standardized: 880 ↛ 885line 880 didn't jump to line 885 because the condition on line 880 was never true
881 # File was written after FilterLabel and standardization, but its
882 # FilterLabel doesn't agree with the data ID: this indicates a bug
883 # in whatever code produced the Exposure (though it may be one that
884 # has been fixed since the file was written).
885 warnings.warn(
886 f"Reading {self.file_descriptor.location} with data ID {self.data_id}: "
887 f"filter label mismatch (file is {file_filter_label}, data ID is "
888 f"{data_id_filter_label}). This is probably a bug in the code that produced it.",
889 stacklevel=find_outside_stacklevel(
890 "lsst.obs.base", "lsst.pipe.base", "lsst.daf.butler", allow_methods={"runQuantum"}
891 ),
892 )
893 return data_id_filter_label
896def _get_future_image_type(storage_class_name: str, component: str | None) -> type[Any] | None:
897 match storage_class_name, component:
898 case ( 898 ↛ 904line 898 didn't jump to line 904 because the pattern on line 898 never matched
899 ("VisitImage", None)
900 | ("PointSpreadFunction", "psf")
901 | ("DetectorV2", "detector")
902 | ("BoxV2", "bbox") # MaskedImage has bbox, but its read_legacy doesn't support it.
903 ):
904 from lsst.images import VisitImage
906 return VisitImage
907 case ("DifferenceImage", None): 907 ↛ 908line 907 didn't jump to line 908 because the pattern on line 907 never matched
908 from lsst.images import DifferenceImage
910 return DifferenceImage
911 case ("ImageV2", "image" | "variance") | ("MaskV2", "mask"): 911 ↛ 912line 911 didn't jump to line 912 because the pattern on line 911 never matched
912 from lsst.images import MaskedImage
914 return MaskedImage
915 # The components below can't be used unless we fix daf_butler
916 # restrictions on component names (they're checked against the original
917 # storage class component names).
918 case ( 918 ↛ 924line 918 didn't jump to line 924 because the pattern on line 918 never matched
919 ("ObservationSummaryStats", "summary_stats")
920 | ("ObservationInfo", "obs_info")
921 | ("StructuredDataDict", "aperture_corrections")
922 | ("ImageField", "photometric_scaling")
923 ):
924 from lsst.images import VisitImage
926 return VisitImage
927 return None