Coverage for python/lsst/images/_generalized_image.py: 85%
107 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -0700
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -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 read_archive,
33 write_archive,
34)
36if TYPE_CHECKING:
37 from lsst.daf.butler import DatasetProvenance, SerializedDatasetRef
40T = TypeVar("T", bound="GeneralizedImage") # for sphinx
43class GeneralizedImage(ABC):
44 """A base class for types that represent one or more 2-d image-like arrays
45 with the same pixel grid and sky projection.
47 Parameters
48 ----------
49 metadata
50 Arbitrary flexible metadata to associate with the image.
51 """
53 def __init__(self, metadata: dict[str, MetadataValue] | None = None) -> None:
54 self._metadata = metadata if metadata is not None else {}
55 self._opaque_metadata: OpaqueArchiveMetadata | None = None
56 self._butler_info: ButlerInfo | None = None
58 @property
59 @abstractmethod
60 def bbox(self) -> Box:
61 """Bounding box for the image (`~lsst.images.Box`)."""
62 raise NotImplementedError()
64 @property
65 def yx0(self) -> YX[int]:
66 """The coordinates of the first pixel in the array
67 (`~lsst.geom.YX` [`int`]).
68 """
69 return self.bbox.start
71 @property
72 @abstractmethod
73 def sky_projection(self) -> SkyProjection[Any] | None:
74 """The projection that maps this image's pixel grid to the sky
75 (`~lsst.images.SkyProjection` | `None`).
77 Notes
78 -----
79 The pixel coordinates used by this projection account for the bounding
80 box ``start``; they are not just array indices.
81 """
82 raise NotImplementedError()
84 @property
85 def astropy_wcs(self) -> SkyProjectionAstropyView | None:
86 """An Astropy 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 `sky_projection`.
94 This object satisfies the `astropy.wcs.wcsapi.BaseHighLevelWCS` and
95 `astropy.wcs.wcsapi.BaseLowLevelWCS` interfaces, but it is not an
96 `astropy.wcs.WCS` (use `fits_wcs` for that).
97 """
98 return self.sky_projection.as_astropy(self.bbox) if self.sky_projection is not None else None
100 @cached_property
101 def fits_wcs(self) -> astropy.wcs.WCS | None:
102 """An Astropy FITS WCS for this image's pixel array.
104 Notes
105 -----
106 As expected for Astropy WCS objects, this defines pixel coordinates
107 such that the first row and column in any associated arrays are
108 ``(0, 0)``, not ``bbox.start``, as is the case for `sky_projection`.
110 This may be an approximation or absent if `sky_projection` is not
111 naturally representable as a FITS WCS.
112 """
113 return (
114 self.sky_projection.as_fits_wcs(self.bbox, allow_approximation=True)
115 if self.sky_projection is not None
116 else None
117 )
119 @property
120 def local(self) -> LocalSliceProxy[Self]:
121 """A proxy object for slicing a generalized image using "local" or
122 "array" pixel coordinates.
124 Notes
125 -----
126 In this convention, the first row and column of the pixel grid is
127 always at ``(0, 0)``. This is also the convention used by
128 `astropy.wcs` objects. When a subimage is created from a parent image,
129 its "local" coordinate system is offset from the coordinate systems of
130 the parent image.
132 Note that most `lsst.images` types (e.g. `~lsst.images.Box`,
133 `~lsst.images.SkyProjection`, `~lsst.images.psfs.PointSpreadFunction`)
134 operate instead in "absolute" coordinates, which is shared by subimage
135 and their parents.
137 See Also
138 --------
139 lsst.images.BoxSliceFactory
140 lsst.images.IntervalSliceFactory
141 """
142 return LocalSliceProxy(self)
144 @property
145 def absolute(self) -> AbsoluteSliceProxy[Self]:
146 """A proxy object for slicing a generalized image using absolute pixel
147 coordinates.
149 Notes
150 -----
151 In this convention, the first row and column of the pixel grid is
152 ``bbox.start``. A subimage and its parent image share the same
153 absolute pixel coordinate system, and most `lsst.images` types (e.g.
154 `~lsst.images.Box`, `~lsst.images.SkyProjection`,
155 `~lsst.images.psfs.PointSpreadFunction`) operate exclusively in this
156 system.
158 Note that `astropy.wcs` and `numpy.ndarray` are not aware of the
159 ``bbox.start`` offset that defines tihs coordinates system; use
160 `local` slicing for indices obtained from those.
162 See Also
163 --------
164 lsst.images.BoxSliceFactory
165 lsst.images.IntervalSliceFactory
166 """
167 return AbsoluteSliceProxy(self)
169 @property
170 def metadata(self) -> dict[str, MetadataValue]:
171 """Arbitrary flexible metadata associated with the image (`dict`).
173 Notes
174 -----
175 Metadata is shared with subimages and other views. It can be
176 disconnected by reassigning to a copy explicitly:
178 image.metadata = image.metadata.copy()
179 """
180 return self._metadata
182 @metadata.setter
183 def metadata(self, value: dict[str, MetadataValue]) -> None:
184 self._metadata = value
186 # Subclasses should delegate to _handle_getitem_args for some user-friendly
187 # argument type-checking before providing their own implementation.
188 @abstractmethod
189 def __getitem__(self, bbox: Box | EllipsisType) -> Self:
190 raise NotImplementedError()
192 @abstractmethod
193 def copy(self) -> Self:
194 """Deep-copy the image and metadata.
196 Attached immutable objects (like `~lsst.images.SkyProjection`
197 instances) are not copied.
198 """
199 raise NotImplementedError()
201 @classmethod
202 def read(cls, path: ResourcePathExpression, **kwargs: Any) -> Self:
203 """Read an instance of this class from a file.
205 A thin convenience wrapper around
206 `lsst.images.serialization.read_archive` that fixes the expected
207 in-memory type to this class. The container format is inferred
208 from ``path``'s extension.
210 Parameters
211 ----------
212 path
213 File to read; convertible to `lsst.resources.ResourcePath`.
214 **kwargs
215 Forwarded to `~lsst.images.serialization.read_archive`
216 (e.g. ``bbox`` to read a subimage).
217 """
218 return read_archive(path, cls, **kwargs)
220 def write(self, path: str, **kwargs: Any) -> None:
221 """Write this object to a file.
223 A thin convenience wrapper around
224 `lsst.images.serialization.write_archive`.
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_archive` (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: 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true
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: 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true
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
313 def _handle_getitem_args(self, bbox: Box | EllipsisType) -> tuple[Box, YX[slice]]:
314 """Interpret the standard arguments to __getitem__."""
315 if bbox is ...:
316 return self.bbox, YX(y=slice(None), x=slice(None))
317 elif not isinstance(bbox, Box): 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true
318 raise TypeError(
319 "Only Box objects can be used to subset image objects directly; "
320 "use .local[y, x] or .absolute[y, x] proxies for slice-based subsets."
321 )
322 return bbox, bbox.slice_within(self.bbox)
325class LocalSliceProxy[T: GeneralizedImage]:
326 """A proxy object for obtaining a generalized image subset using local
327 slicing.
329 See `~lsst.images.GeneralizedImage.local` for more information.
331 Parameters
332 ----------
333 parent
334 Image the slice proxy operates on.
335 """
337 def __init__(self, parent: T) -> None:
338 self._parent = parent
340 def __getitem__(self, slices: tuple[slice, slice]) -> T:
341 try:
342 return self._parent[self._parent.bbox.local[slices]]
343 except TypeError as err:
344 if hasattr(self._parent, "array"):
345 err.add_note("The .array attribute may provide more slicing flexibility.")
346 raise
349class AbsoluteSliceProxy[T: GeneralizedImage]:
350 """A proxy object for obtaining a generalized image subset using absolute
351 slicing.
353 See `~lsst.images.GeneralizedImage.absolute` for more information.
355 Parameters
356 ----------
357 parent
358 Image the slice proxy operates on.
359 """
361 def __init__(self, parent: T) -> None:
362 self._parent = parent
364 def __getitem__(self, slices: tuple[slice, slice]) -> T:
365 try:
366 return self._parent[self._parent.bbox.absolute[slices]]
367 except TypeError as err:
368 if hasattr(self._parent, "array"):
369 err.add_note(
370 "The .array attribute may provide more slicing flexibility "
371 "(but only works in local coordinates)."
372 )
373 raise