Coverage for python/lsst/images/_generalized_image.py: 84%
106 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-25 08:24 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-25 08:24 +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 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) -> 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): 194 ↛ 195line 194 didn't jump to line 195 because the condition on line 194 was never true
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: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true
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: 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true
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) -> None:
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) -> None:
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