Coverage for python/lsst/images/_difference_image.py: 57%
75 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-18 10:45 -0700
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-18 10:45 -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__ = ("DifferenceImage", "DifferenceImageSerializationModel")
16from collections.abc import Mapping
17from types import EllipsisType
18from typing import TYPE_CHECKING, Any, ClassVar, Literal
20import astropy.io.fits
21import astropy.units
22import astropy.wcs
23import pydantic
24from astro_metadata_translator import ObservationInfo
26from ._backgrounds import BackgroundMap
27from ._geom import Bounds, Box
28from ._image import Image
29from ._mask import Mask, MaskPlane, MaskSchema, get_legacy_difference_image_mask_planes
30from ._observation_summary_stats import ObservationSummaryStats
31from ._transforms import DetectorFrame, SkyProjection
32from ._visit_image import VisitImage, VisitImageSerializationModel
33from .aperture_corrections import (
34 ApertureCorrectionMap,
35)
36from .cameras import Detector
37from .fields import Field
38from .psfs import (
39 PointSpreadFunction,
40)
41from .serialization import ArchiveReadError, InputArchive, InvalidParameterError, MetadataValue, OutputArchive
43if TYPE_CHECKING:
44 try:
45 from lsst.afw.image import Exposure as LegacyExposure
46 except ImportError:
47 type LegacyExposure = Any # type: ignore[no-redef]
50class DifferenceImage(VisitImage):
51 """A calibrated single-visit image.
53 Parameters
54 ----------
55 image
56 The main image plane. If this has a `SkyProjection`, it will be used
57 for all planes unless a ``sky_projection`` is passed separately.
58 mask
59 A bitmask image that annotates the main image plane. Must have the
60 same bounding box as ``image`` if provided. Any attached
61 ``sky_projection`` is replaced (possibly by `None`).
62 variance
63 The per-pixel uncertainty of the main image as an image of variance
64 values. Must have the same bounding box as ``image`` if provided, and
65 its units must be the square of ``image.unit`` or `None`.
66 Values default to ``1.0``. Any attached sky_projection is replaced
67 (possibly by `None`).
68 mask_schema
69 Schema for the mask plane. Must be provided if and only if ``mask`` is
70 not provided.
71 sky_projection
72 Projection that maps the pixel grid to the sky. Can only be `None` if
73 a ``sky_projection`` is already attached to ``image``.
74 bounds
75 The region where this image's pixels and other properties are valid.
76 If not provided, the bounding box of the image is used. Other
77 components (``psf``, ``sky_projection``, ``aperture_corrections``,
78 etc.) are assumed to have their own bounds which may or may not be the
79 same as the image bounds. If ``bounds`` extends beyond the image
80 bounding box, the intersection between ``bounds`` and the image
81 bounding box is used instead.
82 obs_info
83 General information about this visit in standardized form.
84 summary_stats
85 Summary statistics associated with this visit. Initialized to default
86 values if not provided.
87 photometric_scaling
88 Field that can be used to multiply a post-ISR image units to yield
89 calibrated image units. This may be a scaling that was already
90 applied (so dividing by it will recover the post-ISR units) or a
91 scaling that has not been applied, depending on ``image.unit``.
92 psf
93 Point-spread function model for this image, or an exception explaining
94 why it could not be read (to be raised if the PSF is requested later).
95 detector
96 Geometry and electronic information for the detector attached to this
97 image.
98 aperture_corrections : `dict` [`str`, `~fields.BaseField`]
99 Mapping from photometry algorithm name to the aperture correction for
100 that algorithm.
101 backgrounds
102 Background models associated with this image.
103 band
104 Name of the passband the image was observed with (this is a shorter,
105 less specific version of ``obs_info.physical_filter``).
106 metadata
107 Arbitrary flexible metadata to associate with the image.
108 """
110 def __init__(
111 self,
112 image: Image,
113 *,
114 mask: Mask | None = None,
115 variance: Image | None = None,
116 mask_schema: MaskSchema | None = None,
117 sky_projection: SkyProjection[DetectorFrame] | None = None,
118 bounds: Bounds | None = None,
119 obs_info: ObservationInfo | None = None,
120 summary_stats: ObservationSummaryStats | None = None,
121 photometric_scaling: Field | None = None,
122 psf: PointSpreadFunction | ArchiveReadError,
123 detector: Detector,
124 aperture_corrections: ApertureCorrectionMap | None = None,
125 backgrounds: BackgroundMap | None = None,
126 band: str,
127 metadata: dict[str, MetadataValue] | None = None,
128 ):
129 super().__init__(
130 image,
131 mask=mask,
132 variance=variance,
133 mask_schema=mask_schema,
134 sky_projection=sky_projection,
135 bounds=bounds,
136 obs_info=obs_info,
137 summary_stats=summary_stats,
138 photometric_scaling=photometric_scaling,
139 psf=psf,
140 detector=detector,
141 aperture_corrections=aperture_corrections,
142 backgrounds=backgrounds,
143 band=band,
144 metadata=metadata,
145 )
147 @staticmethod
148 def _from_visit_image(visit_image: VisitImage) -> DifferenceImage:
149 return visit_image._transfer_metadata(
150 DifferenceImage(
151 visit_image.image,
152 mask=visit_image.mask,
153 variance=visit_image.variance,
154 sky_projection=visit_image.sky_projection,
155 bounds=visit_image.bounds,
156 obs_info=visit_image.obs_info,
157 summary_stats=visit_image.summary_stats,
158 photometric_scaling=visit_image.photometric_scaling,
159 psf=visit_image._psf, # get private attr to avoid triggering on ArchiveReadError early.
160 detector=visit_image.detector,
161 aperture_corrections=visit_image.aperture_corrections,
162 backgrounds=visit_image.backgrounds,
163 band=visit_image.band,
164 ),
165 )
167 def __getitem__(self, bbox: Box | EllipsisType) -> DifferenceImage:
168 if bbox is ...:
169 return self
170 return self._from_visit_image(super().__getitem__(bbox))
172 def __str__(self) -> str:
173 return f"DifferenceImage({self.image!s}, {list(self.mask.schema.names)})"
175 def __repr__(self) -> str:
176 return f"DifferenceImage({self.image!r}, mask_schema={self.mask.schema!r})"
178 def copy(self, *, copy_detector: bool = False) -> DifferenceImage:
179 """Deep-copy the difference image.
181 Parameters
182 ----------
183 copy_detector
184 Whether to deep-copy the `detector` attribute.
185 """
186 return self._from_visit_image(super().copy(copy_detector=copy_detector))
188 def convert_unit(
189 self,
190 unit: astropy.units.UnitBase = astropy.units.nJy,
191 copy: Literal["as-needed"] | bool = True,
192 copy_detector: bool = False,
193 ) -> DifferenceImage:
194 """Return an equivalent image with different pixel units.
196 Parameters
197 ----------
198 unit
199 The unit to transform to. This may be any of the following:
201 - any unit directly relatable to the current units via Astropy;
202 - any unit relatable to the product of the current units with the
203 `photometric_scaling` (i.e. if the current image is in
204 instrumental units but we know how to calibrate them)
205 - any unit relatable to the quotient of the current units with the
206 `photometric_scaling` (i.e. if the current image is in
207 calibrated units and we want to revert back to instrumental
208 units).
209 copy
210 Whether to copy the images and other components. If `True`, all
211 components that aren't controlled by some other argument will
212 always be deep-copied. If `False`, the operation will fail if the
213 image is not already in the right units. If ``as-needed``, only
214 the image and variance will be copied, and only if they are not
215 already in the right units.
216 copy_detector
217 Whether to deep-copy the `detector` attribute.
219 Returns
220 -------
221 `DifferenceImage`
222 An image with the given units.
223 """
224 return self._from_visit_image(super().convert_unit(unit, copy=copy, copy_detector=copy_detector))
226 def serialize(self, archive: OutputArchive[Any]) -> DifferenceImageSerializationModel:
227 return self._serialize_impl(DifferenceImageSerializationModel, archive)
229 @staticmethod
230 def _get_archive_tree_type[P: pydantic.BaseModel](
231 pointer_type: type[P],
232 ) -> type[DifferenceImageSerializationModel[P]]:
233 """Return the serialization model type for this object for an archive
234 type that uses the given pointer type.
235 """
236 return DifferenceImageSerializationModel[pointer_type] # type: ignore
238 @staticmethod
239 def from_legacy(
240 legacy: LegacyExposure,
241 *,
242 unit: astropy.units.UnitBase | None = None,
243 plane_map: Mapping[str, MaskPlane] | None = None,
244 instrument: str | None = None,
245 visit: int | None = None,
246 ) -> DifferenceImage:
247 """Convert from an `lsst.afw.image.Exposure` instance.
249 Parameters
250 ----------
251 legacy
252 An `lsst.afw.image.Exposure` instance that will share image and
253 variance (but not mask) pixel data with the returned object.
254 unit
255 Units of the image. If not provided, the ``BUNIT`` metadata
256 key will be used, if available.
257 plane_map
258 A mapping from legacy mask plane name to the new plane name and
259 description. If `None` (default)
260 `get_legacy_visit_image_mask_planes` is used.
261 instrument
262 Name of the instrument. Extracted from the metadata if not
263 provided.
264 visit
265 ID of the visit. Extracted from the metadata if not provided.
266 """
267 if plane_map is None:
268 plane_map = get_legacy_difference_image_mask_planes()
269 return DifferenceImage._from_visit_image(
270 VisitImage.from_legacy(legacy, unit=unit, plane_map=plane_map, instrument=instrument, visit=visit)
271 )
273 def to_legacy(
274 self, *, copy: bool | None = None, plane_map: Mapping[str, MaskPlane] | None = None
275 ) -> LegacyExposure:
276 """Convert to an `lsst.afw.image.Exposure` instance.
278 Parameters
279 ----------
280 copy
281 If `True`, always copy the image and variance pixel data.
282 If `False`, return a view, and raise `TypeError` if the pixel data
283 is read-only (this is not supported by afw). If `None`, only copy
284 if the pixel data is read-only. Mask pixel data is always copied.
285 plane_map
286 A mapping from legacy mask plane name to the new plane name and
287 description. If `None` (default),
288 `get_legacy_visit_image_mask_planes` is used.
289 """
290 if plane_map is None:
291 plane_map = get_legacy_difference_image_mask_planes()
292 return super().to_legacy(copy=copy, plane_map=plane_map)
294 @staticmethod
295 def read_legacy( # type: ignore[override]
296 filename: str,
297 *,
298 preserve_quantization: bool = False,
299 plane_map: Mapping[str, MaskPlane] | None = None,
300 instrument: str | None = None,
301 visit: int | None = None,
302 component: Literal[
303 "bbox",
304 "image",
305 "mask",
306 "variance",
307 "sky_projection",
308 "psf",
309 "detector",
310 "photometric_scaling",
311 "obs_info",
312 "summary_stats",
313 "aperture_corrections",
314 ]
315 | None = None,
316 ) -> Any:
317 """Read a FITS file written by `lsst.afw.image.Exposure.writeFits`.
319 Parameters
320 ----------
321 filename
322 Full name of the file.
323 preserve_quantization
324 If `True`, ensure that writing the masked image back out again will
325 exactly preserve quantization-compressed pixel values. This causes
326 the image and variance plane arrays to be marked as read-only and
327 stores the original binary table data for those planes in memory.
328 If the `MaskedImage` is copied, the precompressed pixel values are
329 not transferred to the copy.
330 plane_map
331 A mapping from legacy mask plane name to the new plane name and
332 description. If `None` (default)
333 `get_legacy_visit_image_mask_planes` is used.
334 instrument
335 Name of the instrument. Read from the primary header if not
336 provided.
337 visit
338 ID of the visit. Read from the primary header if not
339 provided.
340 component
341 A component to read instead of the full image.
342 """
343 if plane_map is None:
344 plane_map = get_legacy_difference_image_mask_planes()
345 result = VisitImage.read_legacy(
346 filename,
347 preserve_quantization=preserve_quantization,
348 plane_map=plane_map,
349 instrument=instrument,
350 visit=visit,
351 component=component,
352 )
353 if component is None:
354 return DifferenceImage._from_visit_image(result)
355 return result
358class DifferenceImageSerializationModel[P: pydantic.BaseModel](VisitImageSerializationModel[P]):
359 """A Pydantic model used to represent a serialized `DifferenceImage`."""
361 SCHEMA_NAME: ClassVar[str] = "difference_image"
362 SCHEMA_VERSION: ClassVar[str] = "1.0.0"
363 MIN_READ_VERSION: ClassVar[int] = 1
364 PUBLIC_TYPE: ClassVar[type] = DifferenceImage
366 def deserialize(
367 self, archive: InputArchive[Any], *, bbox: Box | None = None, **kwargs: Any
368 ) -> DifferenceImage:
369 if kwargs: 369 ↛ 370line 369 didn't jump to line 370 because the condition on line 369 was never true
370 raise InvalidParameterError(f"Unrecognized parameters for DifferenceImage: {set(kwargs.keys())}.")
371 return DifferenceImage._from_visit_image(super().deserialize(archive, bbox=bbox))
373 def deserialize_component(self, component: str, archive: InputArchive[Any], **kwargs: Any) -> Any:
374 if kwargs and component not in ("image", "mask", "variance"):
375 raise InvalidParameterError(
376 f"Unsupported parameters for DifferenceImage component {component}: {set(kwargs.keys())}."
377 )
378 return super().deserialize_component(component, archive)