Coverage for python / lsst / images / _generalized_image.py: 43%
95 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-28 09:01 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-28 09:01 +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__ = ("AbsoluteSliceProxy", "GeneralizedImage", "LocalSliceProxy")
16from abc import ABC, abstractmethod
17from functools import cached_property
18from types import EllipsisType
19from typing import TYPE_CHECKING, Any, Self, TypeVar
21import astropy.wcs
23from ._geom import Box
24from ._transforms import Projection, ProjectionAstropyView
25from .serialization import ArchiveTree, ButlerInfo, MetadataValue, OpaqueArchiveMetadata
27if TYPE_CHECKING:
28 from lsst.daf.butler import DatasetProvenance, SerializedDatasetRef
31T = TypeVar("T", bound="GeneralizedImage") # for sphinx
34class GeneralizedImage(ABC):
35 """A base class for types that represent one or more 2-d image-like arrays
36 with the same pixel grid and projection.
38 Parameters
39 ----------
40 metadata
41 Arbitrary flexible metadata to associate with the image.
42 """
44 def __init__(self, metadata: dict[str, MetadataValue] | None = None):
45 self._metadata = metadata if metadata is not None else {}
46 self._opaque_metadata: OpaqueArchiveMetadata | None = None
47 self._butler_info: ButlerInfo | None = None
49 @property
50 @abstractmethod
51 def bbox(self) -> Box:
52 """Bounding box for the image (`Box`)."""
53 raise NotImplementedError()
55 @property
56 @abstractmethod
57 def projection(self) -> Projection[Any] | None:
58 """The projection that maps this image's pixel grid to the sky
59 (`Projection` | `None`).
61 Notes
62 -----
63 The pixel coordinates used by this projection account for the bounding
64 box ``start``; they are not just array indices.
65 """
66 raise NotImplementedError()
68 @property
69 def astropy_wcs(self) -> ProjectionAstropyView | None:
70 """An Astropy WCS for this image's pixel array.
72 Notes
73 -----
74 As expected for Astropy WCS objects, this defines pixel coordinates
75 such that the first row and column in any associated arrays are
76 ``(0, 0)``, not ``bbox.start``, as is the case for `projection`.
78 This object satisfies the `astropy.wcs.wcsapi.BaseHighLevelWCS` and
79 `astropy.wcs.wcsapi.BaseLowLevelWCS` interfaces, but it is not an
80 `astropy.wcs.WCS` (use `fits_wcs` for that).
81 """
82 return self.projection.as_astropy(self.bbox) if self.projection is not None else None
84 @cached_property
85 def fits_wcs(self) -> astropy.wcs.WCS | None:
86 """An Astropy FITS WCS for this image's pixel array.
88 Notes
89 -----
90 As expected for Astropy WCS objects, this defines pixel coordinates
91 such that the first row and column in any associated arrays are
92 ``(0, 0)``, not ``bbox.start``, as is the case for `projection`.
94 This may be an approximation or absent if `projection` is not
95 naturally representable as a FITS WCS.
96 """
97 return (
98 self.projection.as_fits_wcs(self.bbox, allow_approximation=True)
99 if self.projection is not None
100 else None
101 )
103 @property
104 def local(self) -> LocalSliceProxy[Self]:
105 """A proxy object for slicing a generalized image using "local" or
106 "array" pixel coordinates.
108 Notes
109 -----
110 In this convention, the first row and column of the pixel grid is
111 always at ``(0, 0)``. This is also the convention used by
112 `astropy.wcs` objects. When a subimage is created from a parent image,
113 its "local" coordinate system is offset from the coordinate systems of
114 the parent image.
116 Note that most `lsst.images` types (e.g. `~lsst.images.Box`,
117 `~lsst.images.Projection`, `~lsst.images.psfs.PointSpreadFunction`)
118 operate instead in "absolute" coordinates, which is shared by subimage
119 and their parents.
121 See Also
122 --------
123 lsst.images.BoxSliceFactory
124 lsst.images.IntervalSliceFactory
125 """
126 return LocalSliceProxy(self)
128 @property
129 def absolute(self) -> AbsoluteSliceProxy[Self]:
130 """A proxy object for slicing a generalized image using absolute pixel
131 coordinates.
133 Notes
134 -----
135 In this convention, the first row and column of the pixel grid is
136 ``bbox.start``. A subimage and its parent image share the same
137 absolute pixel coordinate system, and most `lsst.images` types (e.g.
138 `~lsst.images.Box`, `~lsst.images.Projection`,
139 `~lsst.images.psfs.PointSpreadFunction`) operate exclusively in this
140 system.
142 Note that `astropy.wcs` and `numpy.ndarray` are not aware of the
143 ``bbox.start`` offset that defines tihs coordinates system; use
144 `local` slicing for indices obtained from those.
146 See Also
147 --------
148 lsst.images.BoxSliceFactory
149 lsst.images.IntervalSliceFactory
150 """
151 return AbsoluteSliceProxy(self)
153 @property
154 def metadata(self) -> dict[str, MetadataValue]:
155 """Arbitrary flexible metadata associated with the image (`dict`).
157 Notes
158 -----
159 Metadata is shared with subimages and other views. It can be
160 disconnected by reassigning to a copy explicitly:
162 image.metadata = image.metadata.copy()
163 """
164 return self._metadata
166 @metadata.setter
167 def metadata(self, value: dict[str, MetadataValue]) -> None:
168 self._metadata = value
170 # Subclasses should delegate to super().__getitem__ for some user-friendly
171 # argument type-checking before providing their own implementation.
172 @abstractmethod
173 def __getitem__(self, bbox: Box | EllipsisType) -> Self:
174 if not isinstance(bbox, Box):
175 raise TypeError(
176 "Only Box objects can be used to subset image objects directly; "
177 "use .local[y, x] or .absolute[y, x] proxies for slice-based subsets."
178 )
179 return self
181 @abstractmethod
182 def copy(self) -> Self:
183 """Deep-copy the image and metadata.
185 Attached immutable objects (like `Projection` instances) are not
186 copied.
187 """
188 raise NotImplementedError()
190 @property
191 def butler_dataset(self) -> SerializedDatasetRef | None:
192 """The butler dataset reference for this image
193 (`lsst.daf.butler.SerializedDatasetRef` | `None`).
194 """
195 if self._butler_info is None:
196 return None
197 from lsst.daf.butler import SerializedDatasetRef
199 # Guard against the unlikely case where the dataset was deserialized as
200 # Any because `lsst.daf.butler` couldn't be imported before, but can be
201 # imported now (*anything* can happen in Jupyter).
202 return SerializedDatasetRef.model_validate(self._butler_info.dataset)
204 @property
205 def butler_provenance(self) -> DatasetProvenance | None:
206 """The butler inputs and ID of the task quantum that produced this
207 dataset (`lsst.daf.butler.DatasetProvenance` | `None`)
208 """
209 if self._butler_info is None:
210 return None
212 # Guard against the unlikely case where the provenance was deserialized
213 # as Any because `lsst.daf.butler` couldn't be imported before, but can
214 # be imported now (*anything* can happen in Jupyter).
215 from lsst.daf.butler import DatasetProvenance
217 return DatasetProvenance.model_validate(self._butler_info.provenance)
219 def _transfer_metadata(self, new: Self, copy: bool = False, bbox: Box | None = None) -> Self:
220 """Transfer metadata held by this base class to a new instance.
222 Parameters
223 ----------
224 new
225 New instance to modify and return.
226 copy
227 Whether the new instance is a deep-copy of ``self``.
228 bbox
229 Bounding box used to construct ``new`` as a subset of ``self``.
231 Returns
232 -------
233 GeneralizedImage
234 The new object passed in, modified in place.
236 Notes
237 -----
238 This is a utility method for subclasses to use when finishing
239 construction of a new one.
240 """
241 if bbox is not None:
242 opaque_metadata = (
243 self._opaque_metadata.subset(bbox) if self._opaque_metadata is not None else None
244 )
245 else:
246 opaque_metadata = self._opaque_metadata
247 metadata = self._metadata
248 if copy:
249 metadata = metadata.copy()
250 opaque_metadata = opaque_metadata.copy() if opaque_metadata is not None else None
251 new._metadata = metadata
252 new._opaque_metadata = opaque_metadata
253 new._butler_info = self._butler_info
254 return new
256 def _finish_deserialize(self, model: ArchiveTree) -> Self:
257 """Attach generic information from `ArchiveTree` to this instance
258 at the end of deserialization.
259 """
260 self._metadata = model.metadata
261 self._butler_info = model.butler_info
262 return self
265class LocalSliceProxy[T: GeneralizedImage]:
266 """A proxy object for obtaining a generalized image subset using local
267 slicing.
269 See `GeneralizedImage.local` for more information.
270 """
272 def __init__(self, parent: T):
273 self._parent = parent
275 def __getitem__(self, slices: tuple[slice, slice]) -> T:
276 try:
277 return self._parent[self._parent.bbox.local[slices]]
278 except TypeError as err:
279 if hasattr(self._parent, "array"):
280 err.add_note("The .array attribute may provide more slicing flexibility.")
281 raise
284class AbsoluteSliceProxy[T: GeneralizedImage]:
285 """A proxy object for obtaining a generalized image subset using absolute
286 slicing.
288 See `GeneralizedImage.absolute` for more information.
289 """
291 def __init__(self, parent: T):
292 self._parent = parent
294 def __getitem__(self, slices: tuple[slice, slice]) -> T:
295 try:
296 return self._parent[self._parent.bbox.absolute[slices]]
297 except TypeError as err:
298 if hasattr(self._parent, "array"):
299 err.add_note(
300 "The .array attribute may provide more slicing flexibility "
301 "(but only works in local coordinates)."
302 )
303 raise