Coverage for python/lsst/images/_generalized_image.py: 45%
103 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__ = ("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 lsst.resources import ResourcePathExpression
25from ._geom import Box
26from ._transforms import Projection, ProjectionAstropyView
27from .serialization import (
28 ArchiveTree,
29 ButlerInfo,
30 MetadataValue,
31 OpaqueArchiveMetadata,
32)
33from .serialization import (
34 read as read_archive,
35)
36from .serialization import (
37 write as write_archive,
38)
40if TYPE_CHECKING:
41 from lsst.daf.butler import DatasetProvenance, SerializedDatasetRef
44T = TypeVar("T", bound="GeneralizedImage") # for sphinx
47class GeneralizedImage(ABC):
48 """A base class for types that represent one or more 2-d image-like arrays
49 with the same pixel grid and projection.
51 Parameters
52 ----------
53 metadata
54 Arbitrary flexible metadata to associate with the image.
55 """
57 def __init__(self, metadata: dict[str, MetadataValue] | None = None):
58 self._metadata = metadata if metadata is not None else {}
59 self._opaque_metadata: OpaqueArchiveMetadata | None = None
60 self._butler_info: ButlerInfo | None = None
62 @property
63 @abstractmethod
64 def bbox(self) -> Box:
65 """Bounding box for the image (`~lsst.images.Box`)."""
66 raise NotImplementedError()
68 @property
69 @abstractmethod
70 def projection(self) -> Projection[Any] | None:
71 """The projection that maps this image's pixel grid to the sky
72 (`~lsst.images.Projection` | `None`).
74 Notes
75 -----
76 The pixel coordinates used by this projection account for the bounding
77 box ``start``; they are not just array indices.
78 """
79 raise NotImplementedError()
81 @property
82 def astropy_wcs(self) -> ProjectionAstropyView | None:
83 """An Astropy WCS for this image's pixel array.
85 Notes
86 -----
87 As expected for Astropy WCS objects, this defines pixel coordinates
88 such that the first row and column in any associated arrays are
89 ``(0, 0)``, not ``bbox.start``, as is the case for `projection`.
91 This object satisfies the `astropy.wcs.wcsapi.BaseHighLevelWCS` and
92 `astropy.wcs.wcsapi.BaseLowLevelWCS` interfaces, but it is not an
93 `astropy.wcs.WCS` (use `fits_wcs` for that).
94 """
95 return self.projection.as_astropy(self.bbox) if self.projection is not None else None
97 @cached_property
98 def fits_wcs(self) -> astropy.wcs.WCS | None:
99 """An Astropy FITS WCS for this image's pixel array.
101 Notes
102 -----
103 As expected for Astropy WCS objects, this defines pixel coordinates
104 such that the first row and column in any associated arrays are
105 ``(0, 0)``, not ``bbox.start``, as is the case for `projection`.
107 This may be an approximation or absent if `projection` is not
108 naturally representable as a FITS WCS.
109 """
110 return (
111 self.projection.as_fits_wcs(self.bbox, allow_approximation=True)
112 if self.projection is not None
113 else None
114 )
116 @property
117 def local(self) -> LocalSliceProxy[Self]:
118 """A proxy object for slicing a generalized image using "local" or
119 "array" pixel coordinates.
121 Notes
122 -----
123 In this convention, the first row and column of the pixel grid is
124 always at ``(0, 0)``. This is also the convention used by
125 `astropy.wcs` objects. When a subimage is created from a parent image,
126 its "local" coordinate system is offset from the coordinate systems of
127 the parent image.
129 Note that most `lsst.images` types (e.g. `~lsst.images.Box`,
130 `~lsst.images.Projection`, `~lsst.images.psfs.PointSpreadFunction`)
131 operate instead in "absolute" coordinates, which is shared by subimage
132 and their parents.
134 See Also
135 --------
136 lsst.images.BoxSliceFactory
137 lsst.images.IntervalSliceFactory
138 """
139 return LocalSliceProxy(self)
141 @property
142 def absolute(self) -> AbsoluteSliceProxy[Self]:
143 """A proxy object for slicing a generalized image using absolute pixel
144 coordinates.
146 Notes
147 -----
148 In this convention, the first row and column of the pixel grid is
149 ``bbox.start``. A subimage and its parent image share the same
150 absolute pixel coordinate system, and most `lsst.images` types (e.g.
151 `~lsst.images.Box`, `~lsst.images.Projection`,
152 `~lsst.images.psfs.PointSpreadFunction`) operate exclusively in this
153 system.
155 Note that `astropy.wcs` and `numpy.ndarray` are not aware of the
156 ``bbox.start`` offset that defines tihs coordinates system; use
157 `local` slicing for indices obtained from those.
159 See Also
160 --------
161 lsst.images.BoxSliceFactory
162 lsst.images.IntervalSliceFactory
163 """
164 return AbsoluteSliceProxy(self)
166 @property
167 def metadata(self) -> dict[str, MetadataValue]:
168 """Arbitrary flexible metadata associated with the image (`dict`).
170 Notes
171 -----
172 Metadata is shared with subimages and other views. It can be
173 disconnected by reassigning to a copy explicitly:
175 image.metadata = image.metadata.copy()
176 """
177 return self._metadata
179 @metadata.setter
180 def metadata(self, value: dict[str, MetadataValue]) -> None:
181 self._metadata = value
183 # Subclasses should delegate to super().__getitem__ for some user-friendly
184 # argument type-checking before providing their own implementation.
185 @abstractmethod
186 def __getitem__(self, bbox: Box | EllipsisType) -> Self:
187 if not isinstance(bbox, Box):
188 raise TypeError(
189 "Only Box objects can be used to subset image objects directly; "
190 "use .local[y, x] or .absolute[y, x] proxies for slice-based subsets."
191 )
192 return self
194 @abstractmethod
195 def copy(self) -> Self:
196 """Deep-copy the image and metadata.
198 Attached immutable objects (like `~lsst.images.Projection` instances)
199 are not copied.
200 """
201 raise NotImplementedError()
203 @classmethod
204 def read(cls, path: ResourcePathExpression, **kwargs: Any) -> Self:
205 """Read an instance of this class from a file.
207 A thin convenience wrapper around `lsst.images.serialization.read`
208 that fixes the expected in-memory type to this class. The container
209 format is inferred from ``path``'s extension.
211 Parameters
212 ----------
213 path
214 File to read; convertible to `lsst.resources.ResourcePath`.
215 **kwargs
216 Forwarded to `~lsst.images.serialization.read` (e.g. ``bbox`` to
217 read a subimage).
218 """
219 return read_archive(path, cls, **kwargs)
221 def write(self, path: str, **kwargs: Any) -> None:
222 """Write this object to a file.
224 A thin convenience wrapper around `lsst.images.serialization.write`.
225 The container format is chosen from ``path``'s extension.
227 Parameters
228 ----------
229 path
230 Destination file path. Must not already exist.
231 **kwargs
232 Forwarded to `~lsst.images.serialization.write` (e.g.
233 ``compression_options`` and ``compression_seed`` for FITS).
234 """
235 write_archive(self, path, **kwargs)
237 @property
238 def butler_dataset(self) -> SerializedDatasetRef | None:
239 """The butler dataset reference for this image
240 (`lsst.daf.butler.SerializedDatasetRef` | `None`).
241 """
242 if self._butler_info is None:
243 return None
244 from lsst.daf.butler import SerializedDatasetRef
246 # Guard against the unlikely case where the dataset was deserialized as
247 # Any because `lsst.daf.butler` couldn't be imported before, but can be
248 # imported now (*anything* can happen in Jupyter).
249 return SerializedDatasetRef.model_validate(self._butler_info.dataset)
251 @property
252 def butler_provenance(self) -> DatasetProvenance | None:
253 """The butler inputs and ID of the task quantum that produced this
254 dataset (`lsst.daf.butler.DatasetProvenance` | `None`)
255 """
256 if self._butler_info is None:
257 return None
259 # Guard against the unlikely case where the provenance was deserialized
260 # as Any because `lsst.daf.butler` couldn't be imported before, but can
261 # be imported now (*anything* can happen in Jupyter).
262 from lsst.daf.butler import DatasetProvenance
264 return DatasetProvenance.model_validate(self._butler_info.provenance)
266 def _transfer_metadata[T: GeneralizedImage](
267 self, new: T, copy: bool = False, bbox: Box | None = None
268 ) -> T:
269 """Transfer metadata held by this base class to a new instance.
271 Parameters
272 ----------
273 new
274 New instance to modify and return.
275 copy
276 Whether the new instance is a deep-copy of ``self``.
277 bbox
278 Bounding box used to construct ``new`` as a subset of ``self``.
280 Returns
281 -------
282 GeneralizedImage
283 The new object passed in, modified in place.
285 Notes
286 -----
287 This is a utility method for subclasses to use when finishing
288 construction of a new one.
289 """
290 if bbox is not None:
291 opaque_metadata = (
292 self._opaque_metadata.subset(bbox) if self._opaque_metadata is not None else None
293 )
294 else:
295 opaque_metadata = self._opaque_metadata
296 metadata = self._metadata
297 if copy:
298 metadata = metadata.copy()
299 opaque_metadata = opaque_metadata.copy() if opaque_metadata is not None else None
300 new._metadata = metadata
301 new._opaque_metadata = opaque_metadata
302 new._butler_info = self._butler_info
303 return new
305 def _finish_deserialize(self, model: ArchiveTree) -> Self:
306 """Attach generic information from `ArchiveTree` to this instance
307 at the end of deserialization.
308 """
309 self._metadata = model.metadata
310 self._butler_info = model.butler_info
311 return self
314class LocalSliceProxy[T: GeneralizedImage]:
315 """A proxy object for obtaining a generalized image subset using local
316 slicing.
318 See `~lsst.images.GeneralizedImage.local` for more information.
319 """
321 def __init__(self, parent: T):
322 self._parent = parent
324 def __getitem__(self, slices: tuple[slice, slice]) -> T:
325 try:
326 return self._parent[self._parent.bbox.local[slices]]
327 except TypeError as err:
328 if hasattr(self._parent, "array"):
329 err.add_note("The .array attribute may provide more slicing flexibility.")
330 raise
333class AbsoluteSliceProxy[T: GeneralizedImage]:
334 """A proxy object for obtaining a generalized image subset using absolute
335 slicing.
337 See `~lsst.images.GeneralizedImage.absolute` for more information.
338 """
340 def __init__(self, parent: T):
341 self._parent = parent
343 def __getitem__(self, slices: tuple[slice, slice]) -> T:
344 try:
345 return self._parent[self._parent.bbox.absolute[slices]]
346 except TypeError as err:
347 if hasattr(self._parent, "array"):
348 err.add_note(
349 "The .array attribute may provide more slicing flexibility "
350 "(but only works in local coordinates)."
351 )
352 raise