Coverage for python/lsst/images/_generalized_image.py: 84%
104 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-09 09:35 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-09 09:35 +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 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 super().__getitem__ for some user-friendly
187 # argument type-checking before providing their own implementation.
188 @abstractmethod
189 def __getitem__(self, bbox: Box | EllipsisType) -> Self:
190 if not isinstance(bbox, Box): 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true
191 raise TypeError(
192 "Only Box objects can be used to subset image objects directly; "
193 "use .local[y, x] or .absolute[y, x] proxies for slice-based subsets."
194 )
195 return self
197 @abstractmethod
198 def copy(self) -> Self:
199 """Deep-copy the image and metadata.
201 Attached immutable objects (like `~lsst.images.SkyProjection`
202 instances) are not copied.
203 """
204 raise NotImplementedError()
206 @classmethod
207 def read(cls, path: ResourcePathExpression, **kwargs: Any) -> Self:
208 """Read an instance of this class from a file.
210 A thin convenience wrapper around
211 `lsst.images.serialization.read_archive` that fixes the expected
212 in-memory type to this class. The container format is inferred
213 from ``path``'s extension.
215 Parameters
216 ----------
217 path
218 File to read; convertible to `lsst.resources.ResourcePath`.
219 **kwargs
220 Forwarded to `~lsst.images.serialization.read_archive`
221 (e.g. ``bbox`` to read a subimage).
222 """
223 return read_archive(path, cls, **kwargs)
225 def write(self, path: str, **kwargs: Any) -> None:
226 """Write this object to a file.
228 A thin convenience wrapper around
229 `lsst.images.serialization.write_archive`.
230 The container format is chosen from ``path``'s extension.
232 Parameters
233 ----------
234 path
235 Destination file path. Must not already exist.
236 **kwargs
237 Forwarded to `~lsst.images.serialization.write_archive` (e.g.
238 ``compression_options`` and ``compression_seed`` for FITS).
239 """
240 write_archive(self, path, **kwargs)
242 @property
243 def butler_dataset(self) -> SerializedDatasetRef | None:
244 """The butler dataset reference for this image
245 (`lsst.daf.butler.SerializedDatasetRef` | `None`).
246 """
247 if self._butler_info is None: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true
248 return None
249 from lsst.daf.butler import SerializedDatasetRef
251 # Guard against the unlikely case where the dataset was deserialized as
252 # Any because `lsst.daf.butler` couldn't be imported before, but can be
253 # imported now (*anything* can happen in Jupyter).
254 return SerializedDatasetRef.model_validate(self._butler_info.dataset)
256 @property
257 def butler_provenance(self) -> DatasetProvenance | None:
258 """The butler inputs and ID of the task quantum that produced this
259 dataset (`lsst.daf.butler.DatasetProvenance` | `None`).
260 """
261 if self._butler_info is None: 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true
262 return None
264 # Guard against the unlikely case where the provenance was deserialized
265 # as Any because `lsst.daf.butler` couldn't be imported before, but can
266 # be imported now (*anything* can happen in Jupyter).
267 from lsst.daf.butler import DatasetProvenance
269 return DatasetProvenance.model_validate(self._butler_info.provenance)
271 def _transfer_metadata[T: GeneralizedImage](
272 self, new: T, copy: bool = False, bbox: Box | None = None
273 ) -> T:
274 """Transfer metadata held by this base class to a new instance.
276 Parameters
277 ----------
278 new
279 New instance to modify and return.
280 copy
281 Whether the new instance is a deep-copy of ``self``.
282 bbox
283 Bounding box used to construct ``new`` as a subset of ``self``.
285 Returns
286 -------
287 GeneralizedImage
288 The new object passed in, modified in place.
290 Notes
291 -----
292 This is a utility method for subclasses to use when finishing
293 construction of a new one.
294 """
295 if bbox is not None:
296 opaque_metadata = (
297 self._opaque_metadata.subset(bbox) if self._opaque_metadata is not None else None
298 )
299 else:
300 opaque_metadata = self._opaque_metadata
301 metadata = self._metadata
302 if copy:
303 metadata = metadata.copy()
304 opaque_metadata = opaque_metadata.copy() if opaque_metadata is not None else None
305 new._metadata = metadata
306 new._opaque_metadata = opaque_metadata
307 new._butler_info = self._butler_info
308 return new
310 def _finish_deserialize(self, model: ArchiveTree) -> Self:
311 """Attach generic information from `ArchiveTree` to this instance
312 at the end of deserialization.
313 """
314 self._metadata = model.metadata
315 self._butler_info = model.butler_info
316 return self
319class LocalSliceProxy[T: GeneralizedImage]:
320 """A proxy object for obtaining a generalized image subset using local
321 slicing.
323 See `~lsst.images.GeneralizedImage.local` for more information.
325 Parameters
326 ----------
327 parent
328 Image the slice proxy operates on.
329 """
331 def __init__(self, parent: T) -> None:
332 self._parent = parent
334 def __getitem__(self, slices: tuple[slice, slice]) -> T:
335 try:
336 return self._parent[self._parent.bbox.local[slices]]
337 except TypeError as err:
338 if hasattr(self._parent, "array"):
339 err.add_note("The .array attribute may provide more slicing flexibility.")
340 raise
343class AbsoluteSliceProxy[T: GeneralizedImage]:
344 """A proxy object for obtaining a generalized image subset using absolute
345 slicing.
347 See `~lsst.images.GeneralizedImage.absolute` for more information.
349 Parameters
350 ----------
351 parent
352 Image the slice proxy operates on.
353 """
355 def __init__(self, parent: T) -> None:
356 self._parent = parent
358 def __getitem__(self, slices: tuple[slice, slice]) -> T:
359 try:
360 return self._parent[self._parent.bbox.absolute[slices]]
361 except TypeError as err:
362 if hasattr(self._parent, "array"):
363 err.add_note(
364 "The .array attribute may provide more slicing flexibility "
365 "(but only works in local coordinates)."
366 )
367 raise