Coverage for python/lsst/images/_generalized_image.py: 46%
106 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-12 00:48 -0700
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-12 00:48 -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__ = ("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 YX, Box
26from ._transforms import SkyProjection, SkyProjectionAstropyView
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 sky 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 def yx0(self) -> YX[int]:
70 """The coordinates of the first pixel in the array
71 (`~lsst.geom.YX` [`int`]).
72 """
73 return self.bbox.start
75 @property
76 @abstractmethod
77 def sky_projection(self) -> SkyProjection[Any] | None:
78 """The projection that maps this image's pixel grid to the sky
79 (`~lsst.images.SkyProjection` | `None`).
81 Notes
82 -----
83 The pixel coordinates used by this projection account for the bounding
84 box ``start``; they are not just array indices.
85 """
86 raise NotImplementedError()
88 @property
89 def astropy_wcs(self) -> SkyProjectionAstropyView | None:
90 """An Astropy WCS for this image's pixel array.
92 Notes
93 -----
94 As expected for Astropy WCS objects, this defines pixel coordinates
95 such that the first row and column in any associated arrays are
96 ``(0, 0)``, not ``bbox.start``, as is the case for `sky_projection`.
98 This object satisfies the `astropy.wcs.wcsapi.BaseHighLevelWCS` and
99 `astropy.wcs.wcsapi.BaseLowLevelWCS` interfaces, but it is not an
100 `astropy.wcs.WCS` (use `fits_wcs` for that).
101 """
102 return self.sky_projection.as_astropy(self.bbox) if self.sky_projection is not None else None
104 @cached_property
105 def fits_wcs(self) -> astropy.wcs.WCS | None:
106 """An Astropy FITS WCS for this image's pixel array.
108 Notes
109 -----
110 As expected for Astropy WCS objects, this defines pixel coordinates
111 such that the first row and column in any associated arrays are
112 ``(0, 0)``, not ``bbox.start``, as is the case for `sky_projection`.
114 This may be an approximation or absent if `sky_projection` is not
115 naturally representable as a FITS WCS.
116 """
117 return (
118 self.sky_projection.as_fits_wcs(self.bbox, allow_approximation=True)
119 if self.sky_projection is not None
120 else None
121 )
123 @property
124 def local(self) -> LocalSliceProxy[Self]:
125 """A proxy object for slicing a generalized image using "local" or
126 "array" pixel coordinates.
128 Notes
129 -----
130 In this convention, the first row and column of the pixel grid is
131 always at ``(0, 0)``. This is also the convention used by
132 `astropy.wcs` objects. When a subimage is created from a parent image,
133 its "local" coordinate system is offset from the coordinate systems of
134 the parent image.
136 Note that most `lsst.images` types (e.g. `~lsst.images.Box`,
137 `~lsst.images.SkyProjection`, `~lsst.images.psfs.PointSpreadFunction`)
138 operate instead in "absolute" coordinates, which is shared by subimage
139 and their parents.
141 See Also
142 --------
143 lsst.images.BoxSliceFactory
144 lsst.images.IntervalSliceFactory
145 """
146 return LocalSliceProxy(self)
148 @property
149 def absolute(self) -> AbsoluteSliceProxy[Self]:
150 """A proxy object for slicing a generalized image using absolute pixel
151 coordinates.
153 Notes
154 -----
155 In this convention, the first row and column of the pixel grid is
156 ``bbox.start``. A subimage and its parent image share the same
157 absolute pixel coordinate system, and most `lsst.images` types (e.g.
158 `~lsst.images.Box`, `~lsst.images.SkyProjection`,
159 `~lsst.images.psfs.PointSpreadFunction`) operate exclusively in this
160 system.
162 Note that `astropy.wcs` and `numpy.ndarray` are not aware of the
163 ``bbox.start`` offset that defines tihs coordinates system; use
164 `local` slicing for indices obtained from those.
166 See Also
167 --------
168 lsst.images.BoxSliceFactory
169 lsst.images.IntervalSliceFactory
170 """
171 return AbsoluteSliceProxy(self)
173 @property
174 def metadata(self) -> dict[str, MetadataValue]:
175 """Arbitrary flexible metadata associated with the image (`dict`).
177 Notes
178 -----
179 Metadata is shared with subimages and other views. It can be
180 disconnected by reassigning to a copy explicitly:
182 image.metadata = image.metadata.copy()
183 """
184 return self._metadata
186 @metadata.setter
187 def metadata(self, value: dict[str, MetadataValue]) -> None:
188 self._metadata = value
190 # Subclasses should delegate to super().__getitem__ for some user-friendly
191 # argument type-checking before providing their own implementation.
192 @abstractmethod
193 def __getitem__(self, bbox: Box | EllipsisType) -> Self:
194 if not isinstance(bbox, Box):
195 raise TypeError(
196 "Only Box objects can be used to subset image objects directly; "
197 "use .local[y, x] or .absolute[y, x] proxies for slice-based subsets."
198 )
199 return self
201 @abstractmethod
202 def copy(self) -> Self:
203 """Deep-copy the image and metadata.
205 Attached immutable objects (like `~lsst.images.SkyProjection`
206 instances) are not copied.
207 """
208 raise NotImplementedError()
210 @classmethod
211 def read(cls, path: ResourcePathExpression, **kwargs: Any) -> Self:
212 """Read an instance of this class from a file.
214 A thin convenience wrapper around `lsst.images.serialization.read`
215 that fixes the expected in-memory type to this class. The container
216 format is inferred from ``path``'s extension.
218 Parameters
219 ----------
220 path
221 File to read; convertible to `lsst.resources.ResourcePath`.
222 **kwargs
223 Forwarded to `~lsst.images.serialization.read` (e.g. ``bbox`` to
224 read a subimage).
225 """
226 return read_archive(path, cls, **kwargs)
228 def write(self, path: str, **kwargs: Any) -> None:
229 """Write this object to a file.
231 A thin convenience wrapper around `lsst.images.serialization.write`.
232 The container format is chosen from ``path``'s extension.
234 Parameters
235 ----------
236 path
237 Destination file path. Must not already exist.
238 **kwargs
239 Forwarded to `~lsst.images.serialization.write` (e.g.
240 ``compression_options`` and ``compression_seed`` for FITS).
241 """
242 write_archive(self, path, **kwargs)
244 @property
245 def butler_dataset(self) -> SerializedDatasetRef | None:
246 """The butler dataset reference for this image
247 (`lsst.daf.butler.SerializedDatasetRef` | `None`).
248 """
249 if self._butler_info is None:
250 return None
251 from lsst.daf.butler import SerializedDatasetRef
253 # Guard against the unlikely case where the dataset was deserialized as
254 # Any because `lsst.daf.butler` couldn't be imported before, but can be
255 # imported now (*anything* can happen in Jupyter).
256 return SerializedDatasetRef.model_validate(self._butler_info.dataset)
258 @property
259 def butler_provenance(self) -> DatasetProvenance | None:
260 """The butler inputs and ID of the task quantum that produced this
261 dataset (`lsst.daf.butler.DatasetProvenance` | `None`)
262 """
263 if self._butler_info is None:
264 return None
266 # Guard against the unlikely case where the provenance was deserialized
267 # as Any because `lsst.daf.butler` couldn't be imported before, but can
268 # be imported now (*anything* can happen in Jupyter).
269 from lsst.daf.butler import DatasetProvenance
271 return DatasetProvenance.model_validate(self._butler_info.provenance)
273 def _transfer_metadata[T: GeneralizedImage](
274 self, new: T, copy: bool = False, bbox: Box | None = None
275 ) -> T:
276 """Transfer metadata held by this base class to a new instance.
278 Parameters
279 ----------
280 new
281 New instance to modify and return.
282 copy
283 Whether the new instance is a deep-copy of ``self``.
284 bbox
285 Bounding box used to construct ``new`` as a subset of ``self``.
287 Returns
288 -------
289 GeneralizedImage
290 The new object passed in, modified in place.
292 Notes
293 -----
294 This is a utility method for subclasses to use when finishing
295 construction of a new one.
296 """
297 if bbox is not None:
298 opaque_metadata = (
299 self._opaque_metadata.subset(bbox) if self._opaque_metadata is not None else None
300 )
301 else:
302 opaque_metadata = self._opaque_metadata
303 metadata = self._metadata
304 if copy:
305 metadata = metadata.copy()
306 opaque_metadata = opaque_metadata.copy() if opaque_metadata is not None else None
307 new._metadata = metadata
308 new._opaque_metadata = opaque_metadata
309 new._butler_info = self._butler_info
310 return new
312 def _finish_deserialize(self, model: ArchiveTree) -> Self:
313 """Attach generic information from `ArchiveTree` to this instance
314 at the end of deserialization.
315 """
316 self._metadata = model.metadata
317 self._butler_info = model.butler_info
318 return self
321class LocalSliceProxy[T: GeneralizedImage]:
322 """A proxy object for obtaining a generalized image subset using local
323 slicing.
325 See `~lsst.images.GeneralizedImage.local` for more information.
326 """
328 def __init__(self, parent: T):
329 self._parent = parent
331 def __getitem__(self, slices: tuple[slice, slice]) -> T:
332 try:
333 return self._parent[self._parent.bbox.local[slices]]
334 except TypeError as err:
335 if hasattr(self._parent, "array"):
336 err.add_note("The .array attribute may provide more slicing flexibility.")
337 raise
340class AbsoluteSliceProxy[T: GeneralizedImage]:
341 """A proxy object for obtaining a generalized image subset using absolute
342 slicing.
344 See `~lsst.images.GeneralizedImage.absolute` for more information.
345 """
347 def __init__(self, parent: T):
348 self._parent = parent
350 def __getitem__(self, slices: tuple[slice, slice]) -> T:
351 try:
352 return self._parent[self._parent.bbox.absolute[slices]]
353 except TypeError as err:
354 if hasattr(self._parent, "array"):
355 err.add_note(
356 "The .array attribute may provide more slicing flexibility "
357 "(but only works in local coordinates)."
358 )
359 raise