Coverage for python / lsst / images / _visit_image.py: 27%
231 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-03 00:49 -0800
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-03 00:49 -0800
1# This file is part of lsst-images.
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# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12from __future__ import annotations
14import warnings
16__all__ = ("VisitImage", "VisitImageSerializationModel")
18import functools
19from collections.abc import Callable, Mapping, MutableMapping
20from types import EllipsisType
21from typing import Any, Literal, cast, overload
23import astropy.io.fits
24import astropy.units
25import astropy.wcs
26import pydantic
27from astro_metadata_translator import ObservationInfo, VisitInfoTranslator
29from ._geom import Box
30from ._image import Image, ImageSerializationModel
31from ._mask import Mask, MaskPlane, MaskSchema, MaskSerializationModel, get_legacy_visit_image_mask_planes
32from ._masked_image import MaskedImage, MaskedImageSerializationModel
33from ._transforms import DetectorFrame, Projection, ProjectionAstropyView, ProjectionSerializationModel
34from .fits import FitsOpaqueMetadata
35from .psfs import (
36 PiffSerializationModel,
37 PiffWrapper,
38 PointSpreadFunction,
39 PSFExSerializationModel,
40 PSFExWrapper,
41)
42from .serialization import ArchiveReadError, InputArchive, OutputArchive
45def _obs_info_from_md(md: MutableMapping[str, Any], visit_info: Any = None) -> ObservationInfo:
46 # Try to get an ObservationInfo from the primary header as if
47 # it's a raw header. Else fallback.
48 try:
49 obs_info = ObservationInfo.from_header(md, quiet=True)
50 except ValueError:
51 # Not known translator. Must fall back to visit info. If we have
52 # an actual VisitInfo, serialize it since we know that it will be
53 # complete.
54 if visit_info is not None:
55 from lsst.afw.image import setVisitInfoMetadata
56 from lsst.daf.base import PropertyList
58 pl = PropertyList()
59 setVisitInfoMetadata(pl, visit_info)
60 # Merge so that we still have access to butler provenance.
61 md.update(pl)
63 # Try the given header looking for VisitInfo hints.
64 # We get lots of warnings if nothing can be found. Currently
65 # no way to disable those without capturing them.
66 obs_info = ObservationInfo.from_header(md, translator_class=VisitInfoTranslator, quiet=True)
67 return obs_info
70class VisitImage(MaskedImage):
71 """A calibrated single-visit image.
73 Parameters
74 ----------
75 image
76 The main image plane. If this has a `Projection`, it will be used
77 for all planes unless a ``projection`` is passed separately.
78 mask
79 A bitmask image that annotates the main image plane. Must have the
80 same bounding box as ``image`` if provided. Any attached projection
81 is replaced (possibly by `None`).
82 variance
83 The per-pixel uncertainty of the main image as an image of variance
84 values. Must have the same bounding box as ``image`` if provided, and
85 its units must be the square of ``image.unit`` or `None`.
86 Values default to ``1.0``. Any attached projection is replaced
87 (possibly by `None`).
88 mask_schema
89 Schema for the mask plane. Must be provided if and only if ``mask`` is
90 not provided.
91 opaque_metadata
92 Opaque metadata obtained from reading this object from storage. It may
93 be provided when writing to storage to propagate that metadata and/or
94 preserve file-format-specific options (e.g. compression parameters).
95 projection
96 Projection that maps the pixel grid to the sky. Can only be `None` if
97 a projection is already attached to ``image``.
98 psf
99 Point-spread function model for this image, or an exception explaining
100 why it could not be read (to be raised if the PSF is requested later).
101 obs_info
102 General information about this visit in standardized form.
103 """
105 def __init__(
106 self,
107 image: Image,
108 *,
109 mask: Mask | None = None,
110 variance: Image | None = None,
111 mask_schema: MaskSchema | None = None,
112 projection: Projection[DetectorFrame] | None = None,
113 obs_info: ObservationInfo | None = None,
114 psf: PointSpreadFunction | ArchiveReadError,
115 ):
116 super().__init__(
117 image,
118 mask=mask,
119 variance=variance,
120 mask_schema=mask_schema,
121 projection=projection,
122 obs_info=obs_info,
123 )
124 if self.image.unit is None:
125 raise TypeError("The image component of a VisitImage must have units.")
126 if self.image.projection is None:
127 raise TypeError("The projection component of a VisitImage cannot be None.")
128 if self.image.obs_info is None:
129 raise TypeError("The observation info component of a VisitImage cannot be None.")
130 if not isinstance(self.image.projection.pixel_frame, DetectorFrame):
131 raise TypeError("The projection's pixel frame must be a DetectorFrame for VisitImage.")
132 self._psf = psf
134 @property
135 def unit(self) -> astropy.units.UnitBase:
136 """The units of the image plane (`astropy.units.Unit`)."""
137 return cast(astropy.units.UnitBase, super().unit)
139 @property
140 def projection(self) -> Projection[DetectorFrame]:
141 """The projection that maps the pixel grid to the sky
142 (`Projection` [`DetectorFrame`]).
143 """
144 return cast(Projection[DetectorFrame], super().projection)
146 @property
147 def obs_info(self) -> ObservationInfo:
148 """General information about this observation in standard form.
149 (`~astro_metadata_translator.ObservationInfo`).
150 """
151 obs_info = self.image.obs_info
152 assert obs_info is not None
153 return obs_info
155 @property
156 def astropy_wcs(self) -> ProjectionAstropyView:
157 """An Astropy WCS for the pixel arrays (`ProjectionAstropyView`).
159 Notes
160 -----
161 As expected for Astropy WCS objects, this defines pixel coordinates
162 such that the first row and column in the arrays are ``(0, 0)``, not
163 ``bbox.start``, as is the case for `projection`.
165 This object satisfies the `astropy.wcs.wcsapi.BaseHighLevelWCS` and
166 `astropy.wcs.wcsapi.BaseLowLevelWCS` interfaces, but it is not an
167 `astropy.wcs.WCS` (use `fits_wcs` for that).
168 """
169 return cast(ProjectionAstropyView, super().astropy_wcs)
171 @property
172 def psf(self) -> PointSpreadFunction:
173 """The point-spread function model for this image
174 (`.psfs.PointSpreadFunction`).
175 """
176 if isinstance(self._psf, ArchiveReadError):
177 raise self._psf
178 return self._psf
180 def __getitem__(self, bbox: Box | EllipsisType) -> VisitImage:
181 super().__getitem__(bbox)
182 if bbox is ...:
183 return self
184 result = VisitImage(
185 self.image[bbox],
186 mask=self.mask[bbox],
187 variance=self.variance[bbox],
188 projection=self.projection,
189 psf=self.psf,
190 obs_info=self.obs_info,
191 )
192 if opaque_metadata := getattr(self, "_opaque_metadata", None):
193 result._opaque_metadata = opaque_metadata.subset(bbox)
194 return result
196 def __str__(self) -> str:
197 return f"VisitImage({self.image!s}, {list(self.mask.schema.names)})"
199 def __repr__(self) -> str:
200 return f"VisitImage({self.image!r}, mask_schema={self.mask.schema!r})"
202 def copy(self) -> VisitImage:
203 """Deep-copy the visit image."""
204 result = VisitImage(
205 image=self._image.copy(),
206 mask=self._mask.copy(),
207 variance=self._variance.copy(),
208 psf=self._psf,
209 obs_info=self.obs_info,
210 )
211 if self._opaque_metadata is not None:
212 result._opaque_metadata = self._opaque_metadata.copy()
213 return result
215 def serialize(self, archive: OutputArchive[Any]) -> VisitImageSerializationModel:
216 serialized_image = archive.serialize_direct(
217 "image", functools.partial(self.image.serialize, save_projection=False)
218 )
219 serialized_mask = archive.serialize_direct(
220 "mask", functools.partial(self.mask.serialize, save_projection=False)
221 )
222 serialized_variance = archive.serialize_direct(
223 "variance", functools.partial(self.variance.serialize, save_projection=False)
224 )
225 serialized_projection = (
226 archive.serialize_direct("projection", self.projection.serialize)
227 if self.projection is not None
228 else None
229 )
230 serialized_psf: PiffSerializationModel | PSFExSerializationModel
231 match self._psf:
232 # MyPy is able to figure things out here with this match statement,
233 # but not a single isinstance check on both types.
234 case PiffWrapper():
235 serialized_psf = archive.serialize_direct("psf", self._psf.serialize)
236 case PSFExWrapper():
237 serialized_psf = archive.serialize_direct("psf", self._psf.serialize)
238 case _:
239 raise TypeError(
240 f"Cannot serialize VisitImage with unrecognized PSF type {type(self._psf).__name__}."
241 )
242 return VisitImageSerializationModel(
243 image=serialized_image,
244 mask=serialized_mask,
245 variance=serialized_variance,
246 projection=serialized_projection,
247 psf=serialized_psf,
248 obs_info=self.obs_info,
249 )
251 # Type-checkers want the model argument to only require
252 # MaskedImageSerializationModel[Any], and they'd be absolutely right if
253 # this were a regular instance method. But whether Liskov substitution
254 # applies to classmethods and staticmethods is sort of context-dependent,
255 # and here we do not want it to.
256 @staticmethod
257 def deserialize(
258 model: VisitImageSerializationModel[Any], # type: ignore[override]
259 archive: InputArchive[Any],
260 *,
261 bbox: Box | None = None,
262 ) -> VisitImage:
263 masked_image = MaskedImage.deserialize(model, archive, bbox=bbox)
264 psf = model.deserialize_psf(archive)
265 projection = Projection.deserialize(model.projection, archive)
266 result = VisitImage(
267 masked_image.image,
268 mask=masked_image.mask,
269 variance=masked_image.variance,
270 psf=psf,
271 projection=projection,
272 obs_info=model.obs_info,
273 )
274 return result
276 @staticmethod
277 def _get_archive_tree_type[P: pydantic.BaseModel](
278 pointer_type: type[P],
279 ) -> type[VisitImageSerializationModel[P]]:
280 """Return the serialization model type for this object for an archive
281 type that uses the given pointer type.
282 """
283 return VisitImageSerializationModel[pointer_type] # type: ignore
285 # write_fits and read_fits inherited from MaskedImage.
287 @staticmethod
288 def from_legacy(
289 legacy: Any,
290 *,
291 unit: astropy.units.Unit | None = None,
292 plane_map: Mapping[str, MaskPlane] | None = None,
293 instrument: str | None = None,
294 visit: int | None = None,
295 component: str | None = None,
296 ) -> VisitImage:
297 """Convert from an `lsst.afw.image.Exposure` instance.
299 Parameters
300 ----------
301 legacy
302 An `lsst.afw.image.Exposure` instance that will share image and
303 variance (but not mask) pixel data with the returned object.
304 unit
305 Units of the image. If not provided, the ``BUNIT`` metadata
306 key will be used, if available.
307 plane_map
308 A mapping from legacy mask plane name to the new plane name and
309 description. If `None` (default)
310 `get_legacy_visit_image_mask_planes` is used.
311 instrument
312 Name of the instrument. Extracted from the metadata if not
313 provided.
314 visit
315 ID of the visit. Extracted from the metadata if not provided.
316 """
317 if plane_map is None:
318 plane_map = get_legacy_visit_image_mask_planes()
319 md = legacy.getMetadata()
320 obs_info = _obs_info_from_md(md, visit_info=legacy.info.getVisitInfo())
321 instrument = _extract_or_check_header("LSST BUTLER DATAID INSTRUMENT", instrument, md, str)
322 visit = _extract_or_check_header("LSST BUTLER DATAID VISIT", visit, md, int)
323 unit = _extract_or_check_header("BUNIT", unit, md, lambda x: astropy.units.Unit(x, format="fits"))
324 legacy_wcs = legacy.getWcs()
325 if legacy_wcs is None:
326 raise ValueError("Exposure does not have a SkyWcs.")
327 legacy_detector = legacy.getDetector()
328 if legacy_detector is None:
329 raise ValueError("Exposure does not have a Detector.")
330 detector_bbox = Box.from_legacy(legacy_detector.getBBox())
331 opaque_fits_metadata = FitsOpaqueMetadata()
332 primary_header = astropy.io.fits.Header()
333 with warnings.catch_warnings():
334 # Silence warnings about long keys becoming HIERARCH.
335 warnings.simplefilter("ignore", category=astropy.io.fits.verify.VerifyWarning)
336 primary_header.update(md.toOrderedDict())
337 opaque_fits_metadata.extract_legacy_primary_header(primary_header)
338 projection = Projection.from_legacy(
339 legacy_wcs,
340 DetectorFrame(
341 instrument=instrument,
342 visit=visit,
343 detector=legacy_detector.getId(),
344 bbox=detector_bbox,
345 ),
346 )
347 legacy_psf = legacy.getPsf()
348 if legacy_psf is None:
349 raise ValueError("Exposure file does not have a Psf.")
350 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds=detector_bbox)
351 masked_image = MaskedImage.from_legacy(legacy.getMaskedImage(), unit=unit, plane_map=plane_map)
352 result = VisitImage(
353 image=masked_image.image.view(unit=unit),
354 mask=masked_image.mask,
355 variance=masked_image.variance,
356 projection=projection,
357 psf=psf,
358 obs_info=obs_info,
359 )
360 result._opaque_metadata = masked_image._opaque_metadata
361 return result
363 @overload # type: ignore[override]
364 @staticmethod
365 def read_legacy( 365 ↛ exitline 365 didn't return from function 'read_legacy' because
366 filename: str,
367 *,
368 component: Literal["bbox"],
369 ) -> Box: ...
371 @overload
372 @staticmethod
373 def read_legacy( 373 ↛ exitline 373 didn't return from function 'read_legacy' because
374 filename: str,
375 *,
376 preserve_quantization: bool = False,
377 instrument: str | None = None,
378 visit: int | None = None,
379 component: Literal["image"],
380 ) -> Image: ...
382 @overload
383 @staticmethod
384 def read_legacy( 384 ↛ exitline 384 didn't return from function 'read_legacy' because
385 filename: str,
386 *,
387 plane_map: Mapping[str, MaskPlane] | None = None,
388 instrument: str | None = None,
389 visit: int | None = None,
390 component: Literal["mask"],
391 ) -> Mask: ...
393 @overload
394 @staticmethod
395 def read_legacy( 395 ↛ exitline 395 didn't return from function 'read_legacy' because
396 filename: str,
397 *,
398 preserve_quantization: bool = False,
399 instrument: str | None = None,
400 visit: int | None = None,
401 component: Literal["variance"],
402 ) -> Image: ...
404 @overload
405 @staticmethod
406 def read_legacy( 406 ↛ exitline 406 didn't return from function 'read_legacy' because
407 filename: str,
408 *,
409 instrument: str | None = None,
410 visit: int | None = None,
411 component: Literal["projection"],
412 ) -> Projection[DetectorFrame]: ...
414 @overload
415 @staticmethod
416 def read_legacy( 416 ↛ exitline 416 didn't return from function 'read_legacy' because
417 filename: str,
418 *,
419 component: Literal["psf"],
420 ) -> PointSpreadFunction: ...
422 @overload
423 @staticmethod
424 def read_legacy( 424 ↛ exitline 424 didn't return from function 'read_legacy' because
425 filename: str,
426 *,
427 preserve_quantization: bool = False,
428 plane_map: Mapping[str, MaskPlane] | None = None,
429 instrument: str | None = None,
430 visit: int | None = None,
431 component: None = None,
432 ) -> VisitImage: ...
434 @staticmethod
435 def read_legacy( # type: ignore[override]
436 filename: str,
437 *,
438 preserve_quantization: bool = False,
439 plane_map: Mapping[str, MaskPlane] | None = None,
440 instrument: str | None = None,
441 visit: int | None = None,
442 component: Literal["bbox", "image", "mask", "variance", "projection", "psf"] | None = None,
443 ) -> Any:
444 """Read a FITS file written by `lsst.afw.image.Exposure.writeFits`.
446 Parameters
447 ----------
448 filename
449 Full name of the file.
450 preserve_quantization
451 If `True`, ensure that writing the masked image back out again will
452 exactly preserve quantization-compressed pixel values. This causes
453 the image and variance plane arrays to be marked as read-only and
454 stores the original binary table data for those planes in memory.
455 If the `MaskedImage` is copied, the precompressed pixel values are
456 not transferred to the copy.
457 plane_map
458 A mapping from legacy mask plane name to the new plane name and
459 description. If `None` (default)
460 `get_legacy_visit_image_mask_planes` is used.
461 instrument
462 Name of the instrument. Read from the primary header if not
463 provided.
464 visit
465 ID of the visit. Read from the primary header if not
466 provided.
467 component
468 A component to read instead of the full image.
469 """
470 from lsst.afw.image import ExposureFitsReader
472 reader = ExposureFitsReader(filename)
473 if component == "bbox":
474 return Box.from_legacy(reader.readBBox())
475 legacy_detector = reader.readDetector()
476 if legacy_detector is None:
477 raise ValueError(f"Exposure file {filename!r} does not have a Detector.")
478 detector_bbox = Box.from_legacy(legacy_detector.getBBox())
479 if component in (None, "image", "mask", "variance", "projection"):
480 legacy_wcs = reader.readWcs()
481 if legacy_wcs is None:
482 raise ValueError(f"Exposure file {filename!r} does not have a SkyWcs.")
483 if component in (None, "psf"):
484 legacy_psf = reader.readPsf()
485 if legacy_psf is None:
486 raise ValueError(f"Exposure file {filename!r} does not have a Psf.")
487 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds=detector_bbox)
488 if component == "psf":
489 return psf
490 assert component in (None, "image", "mask", "variance", "projection"), component # for MyPy
491 with astropy.io.fits.open(filename) as hdu_list:
492 primary_header = hdu_list[0].header
493 if instrument is None:
494 try:
495 instrument = primary_header["LSST BUTLER DATAID INSTRUMENT"]
496 except LookupError:
497 raise ValueError(
498 "Instrument could not be found in butler data ID FITS headers and must be provided."
499 ) from None
500 if visit is None:
501 try:
502 visit = primary_header["LSST BUTLER DATAID VISIT"]
503 except LookupError:
504 raise ValueError(
505 "Visit ID could not be found in butler data ID FITS headers and must be provided."
506 ) from None
507 projection = Projection.from_legacy(
508 legacy_wcs,
509 DetectorFrame(
510 instrument=instrument,
511 visit=visit,
512 detector=legacy_detector.getId(),
513 bbox=detector_bbox,
514 ),
515 )
516 if component == "projection":
517 return projection
518 if plane_map is None:
519 plane_map = get_legacy_visit_image_mask_planes()
520 assert component != "psf", component # for MyPy
521 from_masked_image = MaskedImage._read_legacy_hdus(
522 hdu_list,
523 filename,
524 preserve_quantization=preserve_quantization,
525 plane_map=plane_map,
526 component=component,
527 )
528 obs_info = _obs_info_from_md(primary_header)
529 if component is not None:
530 # This is the image, mask, or variance; attach the projection
531 # and return
532 return from_masked_image.view(projection=projection)
533 result = VisitImage(
534 from_masked_image.image,
535 mask=from_masked_image.mask,
536 variance=from_masked_image.variance,
537 projection=projection,
538 psf=psf,
539 obs_info=obs_info,
540 )
541 result._opaque_metadata = from_masked_image._opaque_metadata
542 return result
545class VisitImageSerializationModel[P: pydantic.BaseModel](MaskedImageSerializationModel[P]):
546 """A Pydantic model used to represent a serialized `VisitImage`."""
548 # Inherited attributes are duplicated because that improves the docs
549 # (some limitation in the sphinx/pydantic integration), and these are
550 # important docs.
552 image: ImageSerializationModel[P] = pydantic.Field(description="The main data image.")
553 mask: MaskSerializationModel[P] = pydantic.Field(
554 description="Bitmask that annotates the main image's pixels."
555 )
556 variance: ImageSerializationModel[P] = pydantic.Field(
557 description="Per-pixel variance estimates for the main image."
558 )
559 projection: ProjectionSerializationModel[P] = pydantic.Field(
560 description="Projection that maps the pixel grid to the sky.",
561 )
562 psf: PiffSerializationModel | PSFExSerializationModel | Any = pydantic.Field(
563 union_mode="left_to_right", description="PSF model for the image."
564 )
565 obs_info: ObservationInfo = pydantic.Field(
566 description="Standardized description of visit metadata",
567 )
569 def deserialize_psf(self, archive: InputArchive[Any]) -> PointSpreadFunction | ArchiveReadError:
570 """Finish deserializing the PSF model, or *return* any exception
571 raised in the attempt.
572 """
573 try:
574 match self.psf:
575 case PiffSerializationModel():
576 return PiffWrapper.deserialize(self.psf, archive)
577 case PSFExSerializationModel():
578 return PSFExWrapper.deserialize(self.psf, archive)
579 case _:
580 raise ArchiveReadError("PSF model type not recognized.")
581 except ArchiveReadError as err:
582 return err
585def _extract_or_check_header[T](
586 key: str, given_value: T | None, header: Any, coerce: Callable[[Any], T]
587) -> T:
588 hdr_value: T | None = None
589 if (hdr_raw_value := header.get(key)) is not None:
590 hdr_value = coerce(hdr_raw_value)
591 if given_value is not None:
592 if hdr_value is not None and hdr_value != given_value:
593 raise ValueError(
594 f"Given value {given_value!r} does not match {hdr_value!r} from header key {key}."
595 )
596 return given_value
597 else:
598 if hdr_value is None:
599 raise ValueError(f"No FITS header key {key} found.")
600 return hdr_value