Coverage for python/lsst/images/_visit_image.py: 17%
430 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-08 08:45 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-08 08:45 +0000
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
14__all__ = ("VisitImage", "VisitImageSerializationModel")
16import functools
17import logging
18import warnings
19from collections.abc import Callable, Mapping, MutableMapping
20from types import EllipsisType
21from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
23import astropy.io.fits
24import astropy.units
25import astropy.wcs
26import numpy as np
27import pydantic
28from astro_metadata_translator import ObservationInfo, VisitInfoTranslator
30from ._backgrounds import BackgroundMap, BackgroundMapSerializationModel
31from ._concrete_bounds import SerializableBounds
32from ._geom import Bounds, Box
33from ._image import Image, ImageSerializationModel
34from ._mask import Mask, MaskPlane, MaskSchema, MaskSerializationModel, get_legacy_visit_image_mask_planes
35from ._masked_image import MaskedImage, MaskedImageSerializationModel
36from ._observation_summary_stats import ObservationSummaryStats
37from ._polygon import Polygon
38from ._transforms import DetectorFrame, Projection, ProjectionAstropyView, ProjectionSerializationModel
39from .aperture_corrections import (
40 ApertureCorrectionMap,
41 ApertureCorrectionMapSerializationModel,
42 aperture_corrections_from_legacy,
43 aperture_corrections_to_legacy,
44)
45from .cameras import Detector, DetectorSerializationModel
46from .fields import BaseField, Field, FieldSerializationModel, field_from_legacy_photo_calib
47from .fits import ExtensionKey, FitsOpaqueMetadata
48from .psfs import (
49 GaussianPointSpreadFunction,
50 GaussianPSFSerializationModel,
51 LegacyPointSpreadFunction,
52 PiffSerializationModel,
53 PiffWrapper,
54 PointSpreadFunction,
55 PSFExSerializationModel,
56 PSFExWrapper,
57)
58from .serialization import ArchiveReadError, InputArchive, InvalidParameterError, MetadataValue, OutputArchive
59from .utils import is_none
61if TYPE_CHECKING:
62 try:
63 from lsst.afw.cameraGeom import Detector as LegacyDetector
64 from lsst.afw.image import Exposure as LegacyExposure
65 from lsst.afw.image import FilterLabel as LegacyFilterLabel
66 from lsst.afw.image import VisitInfo as LegacyVisitInfo
67 except ImportError:
68 type LegacyDetector = Any # type: ignore[no-redef]
69 type LegacyExposure = Any # type: ignore[no-redef]
70 type LegacyFilterLabel = Any # type: ignore[no-redef]
71 type LegacyVisitInfo = Any # type: ignore[no-redef]
73_LOG = logging.getLogger("lsst.images")
76class VisitImage(MaskedImage):
77 """A calibrated single-visit image.
79 Parameters
80 ----------
81 image
82 The main image plane. If this has a `Projection`, it will be used
83 for all planes unless a ``projection`` is passed separately.
84 mask
85 A bitmask image that annotates the main image plane. Must have the
86 same bounding box as ``image`` if provided. Any attached projection
87 is replaced (possibly by `None`).
88 variance
89 The per-pixel uncertainty of the main image as an image of variance
90 values. Must have the same bounding box as ``image`` if provided, and
91 its units must be the square of ``image.unit`` or `None`.
92 Values default to ``1.0``. Any attached projection is replaced
93 (possibly by `None`).
94 mask_schema
95 Schema for the mask plane. Must be provided if and only if ``mask`` is
96 not provided.
97 projection
98 Projection that maps the pixel grid to the sky. Can only be `None` if
99 a projection is already attached to ``image``.
100 bounds
101 The region where this image's pixels and other properties are valid.
102 If not provided, the bounding box of the image is used. Other
103 components (``psf``, ``projection``, ``aperture_corrections``, etc.)
104 are assumed to have their own bounds which may or may not be the same
105 as the image bounds. If ``bounds`` extends beyond the image bounding
106 box, the intersection between ``bounds`` and the image bounding box
107 is used instead.
108 obs_info
109 General information about this visit in standardized form.
110 summary_stats
111 Summary statistics associated with this visit. Initialized to default
112 values if not provided.
113 photometric_scaling
114 Field that can be used to multiply a post-ISR image units to yield
115 calibrated image units. This may be a scaling that was already
116 applied (so dividing by it will recover the post-ISR units) or a
117 scaling that has not been applied, depending on ``image.unit``.
118 psf
119 Point-spread function model for this image, or an exception explaining
120 why it could not be read (to be raised if the PSF is requested later).
121 detector
122 Geometry and electronic information for the detector attached to this
123 image.
124 aperture_corrections : `dict` [`str`, `~fields.BaseField`]
125 Mapping from photometry algorithm name to the aperture correction for
126 that algorithm.
127 backgrounds
128 Background models associated with this image.
129 band
130 Name of the passband the image was observed with (this is a shorter,
131 less specific version of ``obs_info.physical_filter``).
132 metadata
133 Arbitrary flexible metadata to associate with the image.
134 """
136 def __init__(
137 self,
138 image: Image,
139 *,
140 mask: Mask | None = None,
141 variance: Image | None = None,
142 mask_schema: MaskSchema | None = None,
143 projection: Projection[DetectorFrame] | None = None,
144 bounds: Bounds | None = None,
145 obs_info: ObservationInfo | None = None,
146 summary_stats: ObservationSummaryStats | None = None,
147 photometric_scaling: Field | None = None,
148 psf: PointSpreadFunction | ArchiveReadError,
149 detector: Detector,
150 aperture_corrections: ApertureCorrectionMap | None = None,
151 backgrounds: BackgroundMap | None = None,
152 band: str,
153 metadata: dict[str, MetadataValue] | None = None,
154 ):
155 super().__init__(
156 image,
157 mask=mask,
158 variance=variance,
159 mask_schema=mask_schema,
160 projection=projection,
161 metadata=metadata,
162 )
163 if self.image.unit is None:
164 raise TypeError("The image component of a VisitImage must have units.")
165 if self.image.projection is None:
166 raise TypeError("The projection component of a VisitImage cannot be None.")
167 if obs_info is None:
168 raise TypeError("The observation info component of a VisitImage cannot be None.")
169 if obs_info.physical_filter is None:
170 raise ValueError("The obs_info.physical_filter attribute of a VisitImage cannot be None.")
171 self._obs_info = obs_info
172 if not isinstance(self.image.projection.pixel_frame, DetectorFrame):
173 raise TypeError("The projection's pixel frame must be a DetectorFrame for VisitImage.")
174 if summary_stats is None:
175 summary_stats = ObservationSummaryStats()
176 self._summary_stats = summary_stats
177 if photometric_scaling is not None and photometric_scaling.unit is None:
178 raise TypeError("If a photometric_scaling is provided, it must have units.")
179 self._photometric_scaling = photometric_scaling
180 self._psf = psf
181 self._detector = detector
182 self._aperture_corrections = aperture_corrections if aperture_corrections is not None else {}
183 self._bounds = bounds if bounds is not None else self.bbox
184 if not self.bbox.contains(self._bounds.bbox):
185 self._bounds = self._bounds.intersection(self.bbox)
186 self._backgrounds = backgrounds if backgrounds is not None else BackgroundMap()
187 self._band = band
189 @property
190 def unit(self) -> astropy.units.UnitBase:
191 """The units of the image plane (`astropy.units.Unit`)."""
192 return cast(astropy.units.UnitBase, super().unit)
194 @property
195 def projection(self) -> Projection[DetectorFrame]:
196 """The projection that maps the pixel grid to the sky
197 (`Projection` [`DetectorFrame`]).
198 """
199 return cast(Projection[DetectorFrame], super().projection)
201 @property
202 def bounds(self) -> Bounds:
203 """The region where pixels are valid (`Bounds`)."""
204 return self._bounds
206 @property
207 def obs_info(self) -> ObservationInfo:
208 """General information about this observation in standard form.
209 (`~astro_metadata_translator.ObservationInfo`).
210 """
211 return self._obs_info
213 @property
214 def physical_filter(self) -> str:
215 """Full name of the physical bandpass filter (`str`)."""
216 assert self._obs_info.physical_filter is not None, "Guaranteed at construction."
217 return self._obs_info.physical_filter
219 @property
220 def band(self) -> str:
221 """Short name of the bandpass filter (`str`)."""
222 return self._band
224 @property
225 def astropy_wcs(self) -> ProjectionAstropyView:
226 """An Astropy WCS for the pixel arrays (`ProjectionAstropyView`).
228 Notes
229 -----
230 As expected for Astropy WCS objects, this defines pixel coordinates
231 such that the first row and column in the arrays are ``(0, 0)``, not
232 ``bbox.start``, as is the case for `projection`.
234 This object satisfies the `astropy.wcs.wcsapi.BaseHighLevelWCS` and
235 `astropy.wcs.wcsapi.BaseLowLevelWCS` interfaces, but it is not an
236 `astropy.wcs.WCS` (use `fits_wcs` for that).
237 """
238 return cast(ProjectionAstropyView, super().astropy_wcs)
240 @property
241 def summary_stats(self) -> ObservationSummaryStats:
242 """Optional summary statistics for this observation
243 (`ObservationSummaryStats`).
244 """
245 return self._summary_stats
247 @property
248 def photometric_scaling(self) -> Field | None:
249 """Field that multiplies a post-ISR image to yield the calibrated
250 image (~`fields.BaseField`).
251 """
252 return self._photometric_scaling
254 @photometric_scaling.setter
255 def photometric_scaling(self, value: Field):
256 if value.unit is None:
257 raise TypeError("The photometric_scaling for a VisitImage must have units.")
258 self._photometric_scaling = value
260 @property
261 def psf(self) -> PointSpreadFunction:
262 """The point-spread function model for this image
263 (`.psfs.PointSpreadFunction`).
264 """
265 if isinstance(self._psf, ArchiveReadError):
266 raise self._psf
267 return self._psf
269 @property
270 def detector(self) -> Detector:
271 """Geometry and electronic information about the detector
272 (`.cameras.Detector`).
273 """
274 return self._detector
276 @property
277 def aperture_corrections(self) -> ApertureCorrectionMap:
278 """A mapping from photometry algorithm name to the aperture correction
279 field for that algorithm (`dict` [`str`, `~.fields.BaseField`]).
280 """
281 return self._aperture_corrections
283 @property
284 def backgrounds(self) -> BackgroundMap:
285 """A mapping of backgrounds associated with this image
286 (`BackgroundMap`).
287 """
288 return self._backgrounds
290 def __getitem__(self, bbox: Box | EllipsisType) -> VisitImage:
291 if bbox is ...:
292 return self
293 super().__getitem__(bbox)
294 return self._transfer_metadata(
295 VisitImage(
296 self.image[bbox],
297 mask=self.mask[bbox],
298 variance=self.variance[bbox],
299 projection=self.projection,
300 psf=self.psf,
301 obs_info=self.obs_info,
302 bounds=self._bounds, # don't need to intersect here, because __init__ will do that.
303 summary_stats=self.summary_stats,
304 detector=self._detector,
305 photometric_scaling=self._photometric_scaling,
306 aperture_corrections=self.aperture_corrections,
307 backgrounds=self._backgrounds,
308 band=self._band,
309 ),
310 bbox=bbox,
311 )
313 def __str__(self) -> str:
314 return f"VisitImage({self.image!s}, {list(self.mask.schema.names)})"
316 def __repr__(self) -> str:
317 return f"VisitImage({self.image!r}, mask_schema={self.mask.schema!r})"
319 def copy(self, *, copy_detector: bool = False) -> VisitImage:
320 """Deep-copy the visit image.
322 Parameters
323 ----------
324 copy_detector
325 Whether to deep-copy the `detector` attribute.
326 """
327 return self._transfer_metadata(
328 VisitImage(
329 image=self._image.copy(),
330 mask=self._mask.copy(),
331 variance=self._variance.copy(),
332 psf=self._psf,
333 obs_info=self.obs_info,
334 bounds=self._bounds,
335 summary_stats=self.summary_stats.model_copy(),
336 detector=self._detector.copy() if copy_detector else self._detector,
337 photometric_scaling=self._photometric_scaling,
338 aperture_corrections=self.aperture_corrections.copy(),
339 backgrounds=self._backgrounds.copy(),
340 band=self.band,
341 ),
342 copy=True,
343 )
345 def convert_unit(
346 self,
347 unit: astropy.units.UnitBase = astropy.units.nJy,
348 copy: Literal["as-needed"] | bool = True,
349 copy_detector: bool = False,
350 ) -> VisitImage:
351 """Return an equivalent image with different pixel units.
353 Parameters
354 ----------
355 unit
356 The unit to transform to. This may be any of the following:
358 - any unit directly relatable to the current units via Astropy;
359 - any unit relatable to the product of the current units with the
360 `photometric_scaling` (i.e. if the current image is in
361 instrumental units but we know how to calibrate them)
362 - any unit relatable to the quotient of the current units with the
363 `photometric_scaling` (i.e. if the current image is in
364 calibrated units and we want to revert back to instrumental
365 units).
366 copy
367 Whether to copy the images and other components. If `True`, all
368 components that aren't controlled by some other argument will
369 always be deep-copied. If `False`, the operation will fail if the
370 image is not already in the right units. If ``as-needed``, only
371 the image and variance will be copied, and only if they are not
372 already in the right units.
373 copy_detector
374 Whether to deep-copy the `detector` attribute.
376 Returns
377 -------
378 `VisitImage`
379 An image with the given units.
380 """
381 if copy not in (True, False, "as-needed"):
382 raise TypeError(f"Invalid value for 'copy' parameter: {copy!r}.")
383 if (factor := _get_unit_conversion_factor(self.unit, unit)) is not None:
384 if factor == 1.0:
385 if copy is True: # not "as-needed"
386 return self.copy()
387 else:
388 return self[...]
389 elif copy is False:
390 raise astropy.units.UnitConversionError(
391 f"Units must be converted ({self.unit} -> {unit}), but copy=False."
392 )
393 image = Image(self._image.array * factor, bbox=self.bbox, projection=self.projection, unit=unit)
394 variance = Image(
395 self._variance.array * factor**2,
396 bbox=self.bbox,
397 unit=unit**2,
398 )
399 elif self._photometric_scaling is None:
400 raise astropy.units.UnitConversionError(
401 "VisitImage.photometric_scaling is None, and there "
402 f"is no constant conversion from {self.unit} to {unit}."
403 )
404 else:
405 if copy is False:
406 raise astropy.units.UnitConversionError(
407 f"Photometric scaling must be applied to go from ={self.unit} to {unit}, but copy=False."
408 )
409 scaling = self._photometric_scaling
410 assert scaling.unit is not None, "Checked at construction."
411 if (constant_factor := _get_unit_conversion_factor(self.unit * scaling.unit, unit)) is not None:
412 if constant_factor != 1.0:
413 scaling = scaling * constant_factor
414 scaling_array = scaling.render(self.bbox, dtype=self.image.array.dtype).array
415 elif (constant_factor := _get_unit_conversion_factor(self.unit / scaling.unit, unit)) is not None:
416 if constant_factor != 1.0:
417 scaling = scaling / constant_factor
418 scaling_array = scaling.render(self.bbox, dtype=self.image.array.dtype).array
419 np.true_divide(1.0, scaling_array, out=scaling_array)
420 else:
421 raise astropy.units.UnitConversionError(
422 f"photometric_scaling with units {scaling.unit} does not "
423 f"provide a path from {self.unit} to {unit}."
424 )
425 # We needed to allocate a new array to evaluate the scaling field,
426 # and then we need to allocate another to hold its square for the
427 # variance scaling. But then we can multiply those arrays in-place
428 # to get the output image and variance to avoid yet more
429 # allocations (note we can't instead multiply the visit image's
430 # image and variance arrays in place because they might have other
431 # references that are still associated with the old units).
432 image = Image(scaling_array, bbox=self.bbox, unit=unit)
433 variance = Image(np.square(scaling_array), bbox=self.bbox, unit=unit**2)
434 image.array *= self._image.array
435 variance.array *= self._variance.array
436 copy_components = copy is True
437 return self._transfer_metadata(
438 VisitImage(
439 image=image,
440 mask=self._mask if not copy_components else self._mask.copy(),
441 variance=variance,
442 projection=self.projection, # never copied; immutable
443 obs_info=self.obs_info if not copy_components else self.obs_info.model_copy(),
444 psf=self._psf, # never copied; immutable
445 bounds=self._bounds, # never copied; immutable
446 summary_stats=self.summary_stats if not copy_components else self.summary_stats.model_copy(),
447 detector=self._detector if not copy_detector else self._detector.copy(),
448 photometric_scaling=self._photometric_scaling, # never copied; immutable
449 aperture_corrections=(
450 self.aperture_corrections if not copy_components else self.aperture_corrections.copy()
451 ),
452 backgrounds=self.backgrounds if not copy_components else self.backgrounds.copy(),
453 band=self.band,
454 )
455 )
457 def serialize(self, archive: OutputArchive[Any]) -> VisitImageSerializationModel:
458 return self._serialize_impl(VisitImageSerializationModel, archive)
460 # This is slightly bad Liskov substitution - we're demanding M be a
461 # VisitImageSerializationModel, not just a MaskedImageSerializationModel,
462 # but that's because we know only `serialize` will call it.
463 def _serialize_impl[M: VisitImageSerializationModel]( # type: ignore[override]
464 self, model_type: type[M], archive: OutputArchive[Any]
465 ) -> M:
466 result = super()._serialize_impl(model_type, archive)
467 match self._psf:
468 # MyPy is able to figure things out here with this match statement,
469 # but not a single isinstance check on the three types.
470 case PiffWrapper():
471 result.psf = archive.serialize_direct("psf", self._psf.serialize)
472 case PSFExWrapper():
473 result.psf = archive.serialize_direct("psf", self._psf.serialize)
474 case GaussianPointSpreadFunction():
475 result.psf = archive.serialize_direct("psf", self._psf.serialize)
476 case _:
477 raise TypeError(
478 f"Cannot serialize VisitImage with unrecognized PSF type {type(self._psf).__name__}."
479 )
480 assert result.projection is not None, "VisitImage always has a projection."
481 result.obs_info = self.obs_info
482 result.summary_stats = self.summary_stats
483 result.bounds = self._bounds.serialize() if self._bounds != self.bbox else None
484 result.detector = archive.serialize_direct("detector", self._detector.serialize)
485 result.band = self.band
486 result.photometric_scaling = (
487 # MyPy can't quite follow the type union through the serialize
488 # method return types.
489 archive.serialize_direct(
490 "photometric_scaling",
491 self._photometric_scaling.serialize,
492 ) # type: ignore[assignment]
493 if self._photometric_scaling is not None
494 else None
495 )
496 result.aperture_corrections = archive.serialize_direct(
497 "aperture_corrections",
498 functools.partial(ApertureCorrectionMapSerializationModel.serialize, self.aperture_corrections),
499 )
500 result.backgrounds = archive.serialize_direct("backgrounds", self._backgrounds.serialize)
501 return result
503 @staticmethod
504 def _get_archive_tree_type[P: pydantic.BaseModel](
505 pointer_type: type[P],
506 ) -> type[VisitImageSerializationModel[P]]:
507 """Return the serialization model type for this object for an archive
508 type that uses the given pointer type.
509 """
510 return VisitImageSerializationModel[pointer_type] # type: ignore
512 @staticmethod
513 def from_legacy(
514 legacy: LegacyExposure,
515 *,
516 unit: astropy.units.UnitBase | None = None,
517 plane_map: Mapping[str, MaskPlane] | None = None,
518 instrument: str | None = None,
519 visit: int | None = None,
520 ) -> VisitImage:
521 """Convert from an `lsst.afw.image.Exposure` instance.
523 Parameters
524 ----------
525 legacy
526 An `lsst.afw.image.Exposure` instance that will share image and
527 variance (but not mask) pixel data with the returned object.
528 unit
529 Units of the image. If not provided, the ``BUNIT`` metadata
530 key will be used, if available.
531 plane_map
532 A mapping from legacy mask plane name to the new plane name and
533 description. If `None` (default)
534 `get_legacy_visit_image_mask_planes` is used.
535 instrument
536 Name of the instrument. Extracted from the metadata if not
537 provided.
538 visit
539 ID of the visit. Extracted from the metadata if not provided.
540 """
541 if plane_map is None:
542 plane_map = get_legacy_visit_image_mask_planes()
543 md = legacy.getMetadata()
544 obs_info = _obs_info_from_md(md, visit_info=legacy.info.getVisitInfo())
545 instrument = _extract_or_check_header(
546 "LSST BUTLER DATAID INSTRUMENT", instrument, md, obs_info.instrument, str
547 )
548 visit = _extract_or_check_header("LSST BUTLER DATAID VISIT", visit, md, obs_info.exposure_id, int)
549 legacy_wcs = legacy.getWcs()
550 if legacy_wcs is None:
551 raise ValueError("Exposure does not have a SkyWcs.")
552 legacy_detector = legacy.getDetector()
553 if legacy_detector is None:
554 raise ValueError("Exposure does not have a Detector.")
555 detector_bbox = Box.from_legacy(legacy_detector.getBBox())
557 # Update the ObservationInfo from other components.
558 obs_info = _update_obs_info_from_legacy(obs_info, legacy_detector, legacy.info.getFilter())
560 opaque_fits_metadata = FitsOpaqueMetadata()
561 primary_header = astropy.io.fits.Header()
562 with warnings.catch_warnings():
563 # Silence warnings about long keys becoming HIERARCH.
564 warnings.simplefilter("ignore", category=astropy.io.fits.verify.VerifyWarning)
565 primary_header.update(md.toOrderedDict())
566 metadata = opaque_fits_metadata.extract_legacy_primary_header(primary_header)
567 instrumental_unit = opaque_fits_metadata.get_instrumental_unit() or astropy.units.electron
568 hdr_unit: astropy.units.UnitBase | None = None
569 if hdr_unit_str := md.get("BUNIT"):
570 hdr_unit = astropy.units.Unit(hdr_unit_str, format="FITS")
571 if hdr_unit == astropy.units.adu and instrumental_unit == astropy.units.electron:
572 # Fix incorrect BUNIT='adu' in LSST
573 # preliminary_visit_image.
574 hdr_unit = astropy.units.electron
575 if unit is None:
576 unit = hdr_unit
577 elif hdr_unit is not None and hdr_unit != unit:
578 raise ValueError(f"BUNIT value {hdr_unit} disagrees with given unit {unit}.")
579 projection = Projection.from_legacy(
580 legacy_wcs,
581 DetectorFrame(
582 instrument=instrument,
583 visit=visit,
584 detector=legacy_detector.getId(),
585 bbox=detector_bbox,
586 ),
587 )
588 legacy_psf = legacy.getPsf()
589 if legacy_psf is None:
590 raise ValueError("Exposure file does not have a Psf.")
591 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds=detector_bbox)
592 masked_image = MaskedImage.from_legacy(legacy.getMaskedImage(), unit=unit, plane_map=plane_map)
593 legacy_summary_stats = legacy.info.getSummaryStats()
594 legacy_ap_corr_map = legacy.info.getApCorrMap()
595 legacy_polygon = legacy.info.getValidPolygon()
596 legacy_photo_calib = legacy.info.getPhotoCalib()
597 detector = Detector.from_legacy(
598 legacy_detector, instrument=instrument, visit=visit, is_raw_assembled=True
599 )
600 _reconcile_detector_serial(obs_info, detector)
601 result = VisitImage(
602 image=masked_image.image.view(unit=unit),
603 mask=masked_image.mask,
604 variance=masked_image.variance,
605 projection=projection,
606 psf=psf,
607 obs_info=obs_info,
608 summary_stats=(
609 ObservationSummaryStats.from_legacy(legacy_summary_stats)
610 if legacy_summary_stats is not None
611 else None
612 ),
613 detector=detector,
614 aperture_corrections=(
615 aperture_corrections_from_legacy(legacy_ap_corr_map)
616 if legacy_ap_corr_map is not None
617 else None
618 ),
619 bounds=Polygon.from_legacy(legacy_polygon) if legacy_polygon is not None else None,
620 photometric_scaling=(
621 field_from_legacy_photo_calib(
622 legacy_photo_calib, bounds=detector_bbox, instrumental_unit=instrumental_unit
623 )
624 if legacy_photo_calib is not None
625 else None
626 ),
627 band=legacy.info.getFilter().bandLabel,
628 metadata=metadata,
629 )
630 result.metadata["id"] = legacy.info.getId()
631 result._opaque_metadata = opaque_fits_metadata
632 return result
634 def to_legacy(
635 self, *, copy: bool | None = None, plane_map: Mapping[str, MaskPlane] | None = None
636 ) -> LegacyExposure:
637 """Convert to an `lsst.afw.image.Exposure` instance.
639 Parameters
640 ----------
641 copy
642 If `True`, always copy the image and variance pixel data.
643 If `False`, return a view, and raise `TypeError` if the pixel data
644 is read-only (this is not supported by afw). If `None`, only copy
645 if the pixel data is read-only. Mask pixel data is always copied.
646 plane_map
647 A mapping from legacy mask plane name to the new plane name and
648 description. If `None` (default),
649 `get_legacy_visit_image_mask_planes` is used.
650 """
651 from lsst.afw.image import Exposure as LegacyExposure
652 from lsst.afw.image import FilterLabel as LegacyFilterLabel
653 from lsst.obs.base.makeRawVisitInfoViaObsInfo import MakeRawVisitInfoViaObsInfo
655 if plane_map is None:
656 plane_map = get_legacy_visit_image_mask_planes()
657 legacy_masked_image = super().to_legacy(copy=copy, plane_map=plane_map)
658 result = LegacyExposure(legacy_masked_image, dtype=self.image.array.dtype)
659 result_info = result.info
660 result_info.setId(self.metadata.get("id"))
661 result_info.setWcs(self.projection.to_legacy())
662 result_info.setDetector(self.detector.to_legacy())
663 result_info.setFilter(LegacyFilterLabel.fromBandPhysical(self.band, self.obs_info.physical_filter))
664 if self._photometric_scaling is not None:
665 result_info.setPhotoCalib(self._photometric_scaling.to_legacy_photo_calib(self.unit))
666 else:
667 result_info.setPhotoCalib(BaseField.make_legacy_photo_calib(self.unit))
668 # We just dump all of the FITS headers and non-FITS metadata into the
669 # legacy metadata component, to make sure we have everything. We dump
670 # the latter into a pair of special cards to be able to full round-trip
671 # them (including case preservation).
672 result_md = result_info.getMetadata()
673 try:
674 result_md["BUNIT"] = self.unit.to_string(format="fits")
675 except ValueError:
676 # Write units that astropy doesn't think FITS will accept anyway;
677 # FITS standard says "SHOULD" about using it's recommended units,
678 # and coloring outside the lines is better than lying.
679 result_md["BUNIT"] = self.unit.to_string()
680 if isinstance(self._opaque_metadata, FitsOpaqueMetadata):
681 result_md.update(self._opaque_metadata.headers[ExtensionKey()])
682 for n, (k, v) in enumerate(self.metadata.items()):
683 result_md[f"LSST IMAGES KEY {n + 1}"] = k
684 result_md[f"LSST IMAGES VALUE {n + 1}"] = v
685 if isinstance(self._psf, LegacyPointSpreadFunction):
686 result_info.setPsf(self._psf.legacy_psf)
687 elif isinstance(self._psf, PiffWrapper):
688 result_info.setPsf(self._psf.to_legacy())
689 if isinstance(self.bounds, Polygon):
690 result_info.setValidPolygon(self.bounds.to_legacy())
691 if self.aperture_corrections:
692 result_info.setApCorrMap(aperture_corrections_to_legacy(self.aperture_corrections))
693 result_info.setVisitInfo(MakeRawVisitInfoViaObsInfo.observationInfo2visitInfo(self.obs_info))
694 result_info.setSummaryStats(self.summary_stats.to_legacy())
695 return result
697 @staticmethod
698 def read_legacy( # type: ignore[override]
699 filename: str,
700 *,
701 preserve_quantization: bool = False,
702 plane_map: Mapping[str, MaskPlane] | None = None,
703 instrument: str | None = None,
704 visit: int | None = None,
705 component: Literal[
706 "bbox",
707 "image",
708 "mask",
709 "variance",
710 "projection",
711 "psf",
712 "detector",
713 "photometric_scaling",
714 "obs_info",
715 "summary_stats",
716 "aperture_corrections",
717 ]
718 | None = None,
719 ) -> Any:
720 """Read a FITS file written by `lsst.afw.image.Exposure.writeFits`.
722 Parameters
723 ----------
724 filename
725 Full name of the file.
726 preserve_quantization
727 If `True`, ensure that writing the masked image back out again will
728 exactly preserve quantization-compressed pixel values. This causes
729 the image and variance plane arrays to be marked as read-only and
730 stores the original binary table data for those planes in memory.
731 If the `MaskedImage` is copied, the precompressed pixel values are
732 not transferred to the copy.
733 plane_map
734 A mapping from legacy mask plane name to the new plane name and
735 description. If `None` (default)
736 `get_legacy_visit_image_mask_planes` is used.
737 instrument
738 Name of the instrument. Read from the primary header if not
739 provided.
740 visit
741 ID of the visit. Read from the primary header if not
742 provided.
743 component
744 A component to read instead of the full image.
745 """
746 from lsst.afw.image import ExposureFitsReader
748 reader = ExposureFitsReader(filename)
749 if component == "bbox":
750 return Box.from_legacy(reader.readBBox())
751 legacy_detector = reader.readDetector()
752 if legacy_detector is None:
753 raise ValueError(f"Exposure file {filename!r} does not have a Detector.")
754 detector_bbox = Box.from_legacy(legacy_detector.getBBox())
755 legacy_wcs = None
756 if component in (None, "image", "mask", "variance", "projection"):
757 legacy_wcs = reader.readWcs()
758 if legacy_wcs is None:
759 raise ValueError(f"Exposure file {filename!r} does not have a SkyWcs.")
760 legacy_exposure_info = reader.readExposureInfo()
761 summary_stats = None
762 if component in (None, "summary_stats"):
763 legacy_stats = legacy_exposure_info.getSummaryStats()
764 if legacy_stats is not None:
765 summary_stats = ObservationSummaryStats.from_legacy(legacy_stats)
766 if component == "summary_stats":
767 return summary_stats
768 if component in (None, "psf"):
769 legacy_psf = reader.readPsf()
770 if legacy_psf is None:
771 raise ValueError(f"Exposure file {filename!r} does not have a Psf.")
772 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds=detector_bbox)
773 if component == "psf":
774 return psf
775 aperture_corrections: ApertureCorrectionMap = {}
776 if component in (None, "aperture_corrections"):
777 legacy_ap_corr_map = reader.readApCorrMap()
778 if legacy_ap_corr_map is not None:
779 aperture_corrections = aperture_corrections_from_legacy(legacy_ap_corr_map)
780 if component == "aperture_corrections":
781 return aperture_corrections
782 assert component in (
783 None,
784 "image",
785 "mask",
786 "variance",
787 "projection",
788 "obs_info",
789 "detector",
790 "photometric_scaling",
791 ), component # for MyPy
792 filter_label = reader.readFilter()
793 with astropy.io.fits.open(filename) as hdu_list:
794 primary_header = hdu_list[0].header
795 obs_info = _obs_info_from_md(primary_header)
796 obs_info = _update_obs_info_from_legacy(obs_info, legacy_detector, filter_label)
797 if component == "obs_info":
798 return obs_info
799 instrument = _extract_or_check_header(
800 "LSST BUTLER DATAID INSTRUMENT", instrument, primary_header, obs_info.instrument, str
801 )
802 visit = _extract_or_check_header(
803 "LSST BUTLER DATAID VISIT", visit, primary_header, obs_info.exposure_id, int
804 )
805 opaque_metadata = FitsOpaqueMetadata()
806 # This extraction is destructive, so we need to be sure to pass
807 # this opaque_metadata down to MaskedImage._read_legacy_hdus
808 # so it doesn't try to extract it again.
809 metadata = opaque_metadata.extract_legacy_primary_header(primary_header)
810 if (instrumental_unit := opaque_metadata.get_instrumental_unit()) is None:
811 instrumental_unit = astropy.units.electron
812 photometric_scaling: Field | None = None
813 if component in (None, "photometric_scaling"):
814 legacy_photo_calib = reader.readPhotoCalib()
815 if legacy_photo_calib is not None:
816 photometric_scaling = field_from_legacy_photo_calib(
817 legacy_photo_calib, bounds=detector_bbox, instrumental_unit=instrumental_unit
818 )
819 if component == "photometric_scaling":
820 return photometric_scaling
821 if component in ("detector", None):
822 detector = Detector.from_legacy(
823 legacy_detector, instrument=instrument, visit=visit, is_raw_assembled=True
824 )
825 _reconcile_detector_serial(obs_info, detector)
826 if component == "detector":
827 return detector
828 assert component != "detector", "MyPy can't work this out from the above."
829 projection = Projection.from_legacy(
830 legacy_wcs,
831 DetectorFrame(
832 instrument=instrument,
833 visit=visit,
834 detector=legacy_detector.getId(),
835 bbox=detector_bbox,
836 ),
837 )
838 if component == "projection":
839 return projection
840 if plane_map is None:
841 plane_map = get_legacy_visit_image_mask_planes()
842 assert component != "psf", component # for MyPy
843 from_masked_image = MaskedImage._read_legacy_hdus(
844 hdu_list,
845 filename,
846 opaque_metadata=opaque_metadata,
847 preserve_quantization=preserve_quantization,
848 plane_map=plane_map,
849 component=component,
850 )
851 if component is not None:
852 # This is the image, mask, or variance; attach the projection and
853 # obs_info and return
854 return from_masked_image.view(projection=projection)
855 legacy_polygon = reader.readValidPolygon()
856 result = VisitImage(
857 from_masked_image.image,
858 mask=from_masked_image.mask,
859 variance=from_masked_image.variance,
860 projection=projection,
861 psf=psf,
862 detector=detector,
863 obs_info=obs_info,
864 summary_stats=summary_stats,
865 aperture_corrections=aperture_corrections,
866 bounds=Polygon.from_legacy(legacy_polygon) if legacy_polygon is not None else None,
867 photometric_scaling=photometric_scaling,
868 band=filter_label.bandLabel,
869 metadata=metadata,
870 )
871 result._opaque_metadata = from_masked_image._opaque_metadata
872 result.metadata["id"] = reader.readExposureId()
873 return result
876class VisitImageSerializationModel[P: pydantic.BaseModel](MaskedImageSerializationModel[P]):
877 """A Pydantic model used to represent a serialized `VisitImage`."""
879 SCHEMA_NAME: ClassVar[str] = "visit_image"
880 SCHEMA_VERSION: ClassVar[str] = "1.0.0"
881 MIN_READ_VERSION: ClassVar[int] = 1
882 PUBLIC_TYPE: ClassVar[type] = VisitImage
884 # Inherited attributes are duplicated because that improves the docs
885 # (some limitation in the sphinx/pydantic integration), and these are
886 # important docs.
888 image: ImageSerializationModel[P] = pydantic.Field(description="The main data image.")
889 mask: MaskSerializationModel[P] = pydantic.Field(
890 description="Bitmask that annotates the main image's pixels."
891 )
892 variance: ImageSerializationModel[P] = pydantic.Field(
893 description="Per-pixel variance estimates for the main image."
894 )
895 projection: ProjectionSerializationModel[P] = pydantic.Field(
896 description="Projection that maps the pixel grid to the sky.",
897 )
898 psf: PiffSerializationModel | PSFExSerializationModel | GaussianPSFSerializationModel | Any = (
899 pydantic.Field(union_mode="left_to_right", description="PSF model for the image.")
900 )
901 obs_info: ObservationInfo = pydantic.Field(
902 description="Standardized description of visit metadata",
903 )
904 photometric_scaling: FieldSerializationModel | None = pydantic.Field(
905 default=None,
906 description="Scaling that can be used to multiply a post-ISR image to yield calibrated pixel values.",
907 )
908 summary_stats: ObservationSummaryStats = pydantic.Field(
909 description="Summary statistics for the observation."
910 )
911 detector: DetectorSerializationModel = pydantic.Field(
912 description="Geometry and electronic information for the detector."
913 )
914 aperture_corrections: ApertureCorrectionMapSerializationModel = pydantic.Field(
915 default_factory=ApertureCorrectionMapSerializationModel,
916 description="Aperture corrections, keyed by flux algorithm.",
917 )
918 bounds: SerializableBounds | None = pydantic.Field(
919 default=None,
920 description="Pixel validity region, if different from the image bounding box.",
921 exclude_if=is_none,
922 )
923 backgrounds: BackgroundMapSerializationModel = pydantic.Field(
924 default_factory=BackgroundMapSerializationModel,
925 description="Background models associated with this image.",
926 )
927 band: str = pydantic.Field(description="Short name of the bandpass filter.")
929 def deserialize(
930 self, archive: InputArchive[Any], *, bbox: Box | None = None, **kwargs: Any
931 ) -> VisitImage:
932 if kwargs:
933 raise InvalidParameterError(f"Unrecognized parameters for VisitImage: {set(kwargs.keys())}.")
934 masked_image = super().deserialize(archive, bbox=bbox)
935 try:
936 psf = self.psf.deserialize(archive)
937 except ArchiveReadError as err:
938 # Defer this until/unless somebody actually asks for the PSF.
939 psf = err
940 detector = self.detector.deserialize(archive)
941 aperture_corrections = self.aperture_corrections.deserialize(archive)
942 photometric_scaling = (
943 self.photometric_scaling.deserialize(archive) if self.photometric_scaling is not None else None
944 )
945 return VisitImage(
946 masked_image.image,
947 mask=masked_image.mask,
948 variance=masked_image.variance,
949 psf=psf,
950 projection=masked_image.projection,
951 obs_info=self.obs_info,
952 summary_stats=self.summary_stats,
953 detector=detector,
954 aperture_corrections=aperture_corrections,
955 photometric_scaling=photometric_scaling,
956 bounds=self.bounds.deserialize() if self.bounds is not None else None,
957 backgrounds=self.backgrounds.deserialize(archive),
958 band=self.band,
959 )._finish_deserialize(self)
961 def deserialize_component(self, component: str, archive: InputArchive[Any], **kwargs: Any) -> Any:
962 if kwargs and component not in ("image", "mask", "variance"):
963 raise InvalidParameterError(
964 f"Unsupported parameters for VisitImage component {component}: {set(kwargs.keys())}."
965 )
966 return super().deserialize_component(component, archive)
969def _obs_info_from_md(
970 md: MutableMapping[str, Any], visit_info: LegacyVisitInfo | None = None
971) -> ObservationInfo:
972 # Try to get an ObservationInfo from the primary header as if
973 # it's a raw header. Else fallback.
974 try:
975 obs_info = ObservationInfo.from_header(md, quiet=True)
976 except ValueError:
977 # Not known translator. Must fall back to visit info. If we have
978 # an actual VisitInfo, serialize it since we know that it will be
979 # complete.
980 if visit_info is not None:
981 from lsst.afw.image import setVisitInfoMetadata
982 from lsst.daf.base import PropertyList
984 pl = PropertyList()
985 setVisitInfoMetadata(pl, visit_info)
986 # Merge so that we still have access to butler provenance.
987 md.update(pl)
989 # Try the given header looking for VisitInfo hints.
990 # We get lots of warnings if nothing can be found. Currently
991 # no way to disable those without capturing them.
992 obs_info = ObservationInfo.from_header(md, translator_class=VisitInfoTranslator, quiet=True)
993 return obs_info
996def _update_obs_info_from_legacy(
997 obs_info: ObservationInfo,
998 detector: LegacyDetector | None = None,
999 filter_label: LegacyFilterLabel | None = None,
1000) -> ObservationInfo:
1001 extra_md: dict[str, str | int] = {}
1003 if filter_label is not None and filter_label.hasBandLabel():
1004 extra_md["physical_filter"] = filter_label.physicalLabel
1006 # Fill in detector metadata, check for consistency.
1007 # ObsInfo detector name and group can not be derived from
1008 # the getName() information without knowing how the components
1009 # are separated.
1010 if detector is not None:
1011 detector_md = {
1012 "detector_num": detector.getId(),
1013 "detector_unique_name": detector.getName(),
1014 }
1015 extra_md.update(detector_md)
1017 obs_info_updates: dict[str, str | int] = {}
1018 for k, v in extra_md.items():
1019 current = getattr(obs_info, k)
1020 if current is None:
1021 obs_info_updates[k] = v
1022 continue
1023 if current != v:
1024 raise RuntimeError(
1025 f"ObservationInfo contains value for '{k}' that is inconsistent "
1026 f"with given legacy object: {v} != {current}"
1027 )
1029 if obs_info_updates:
1030 obs_info = obs_info.model_copy(update=obs_info_updates)
1031 return obs_info
1034def _reconcile_detector_serial(obs_info: ObservationInfo, detector: Detector) -> None:
1035 # Some LSSTCam detector serial numbers are/were incorrect in the camera
1036 # geometry (DM-55080), so if they conflict it's the ObservationInfo (from
1037 # the headers) that's correct.
1038 if obs_info.detector_serial is not None and detector.serial != obs_info.detector_serial:
1039 _LOG.warning(
1040 "Detector serial from ObservationInfo (%s) for detector %d does not agree "
1041 "with camera geometry %s; assuming the former is correct.",
1042 obs_info.detector_serial,
1043 detector.id,
1044 detector.serial,
1045 )
1046 detector._attributes.serial = obs_info.detector_serial
1049def _extract_or_check_value[T](
1050 key: str,
1051 given_value: T | None,
1052 *sources: tuple[str, T | None],
1053) -> T:
1054 # Compare given value against multiple sources. If given value is not
1055 # supplied return the first non-None value in the reference sources.
1056 if given_value is not None:
1057 for source_name, source_value in sources:
1058 if source_value is not None and source_value != given_value:
1059 raise ValueError(
1060 f"Given value {given_value!r} does not match {source_value!r} from {source_name}."
1061 )
1062 if source_value is not None:
1063 # Only check the first non-None source rather than checking
1064 # all supplied values.
1065 break
1066 return given_value
1068 for _, source_value in sources:
1069 if source_value is not None:
1070 return source_value
1072 raise ValueError(f"No value found for {key}.")
1075def _extract_or_check_header[T](
1076 key: str, given_value: T | None, header: Any, obs_info_value: T | None, coerce: Callable[[Any], T]
1077) -> T:
1078 hdr_value: T | None = None
1079 if (hdr_raw_value := header.get(key)) is not None:
1080 hdr_value = coerce(hdr_raw_value)
1081 return _extract_or_check_value(
1082 key, given_value, ("ObservationInfo", obs_info_value), (f"header key {key}", hdr_value)
1083 )
1086def _get_unit_conversion_factor(
1087 original: astropy.units.UnitBase, new: astropy.units.UnitBase
1088) -> float | None:
1089 try:
1090 return original.to(new)
1091 except astropy.units.UnitConversionError:
1092 return None