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