Coverage for python/lsst/images/_generalized_image.py: 48%
121 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 08:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 08:38 +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, NoOverlapError, NotContainedError
26from ._transforms import SkyProjection, SkyProjectionAstropyView
27from .describe import DescribableMixin
28from .serialization import (
29 ArchiveTree,
30 ButlerInfo,
31 MetadataValue,
32 OpaqueArchiveMetadata,
33 read_archive,
34 write_archive,
35)
37if TYPE_CHECKING:
38 from lsst.daf.butler import DatasetProvenance, SerializedDatasetRef
41T = TypeVar("T", bound="GeneralizedImage") # for sphinx
44class GeneralizedImage(DescribableMixin, ABC):
45 """A base class for types that represent one or more 2-d image-like arrays
46 with the same pixel grid and sky projection.
48 Parameters
49 ----------
50 metadata
51 Arbitrary flexible metadata to associate with the image.
52 """
54 def __init__(self, metadata: dict[str, MetadataValue] | None = None) -> None:
55 self._metadata = metadata if metadata is not None else {}
56 self._opaque_metadata: OpaqueArchiveMetadata | None = None
57 self._butler_info: ButlerInfo | None = None
59 @property
60 @abstractmethod
61 def bbox(self) -> Box:
62 """Bounding box for the image (`~lsst.images.Box`)."""
63 raise NotImplementedError()
65 @property
66 def yx0(self) -> YX[int]:
67 """The coordinates of the first pixel in the array
68 (`~lsst.geom.YX` [`int`]).
69 """
70 return self.bbox.start
72 @property
73 @abstractmethod
74 def sky_projection(self) -> SkyProjection[Any] | None:
75 """The projection that maps this image's pixel grid to the sky
76 (`~lsst.images.SkyProjection` | `None`).
78 Notes
79 -----
80 The pixel coordinates used by this projection account for the bounding
81 box ``start``; they are not just array indices.
82 """
83 raise NotImplementedError()
85 @property
86 def astropy_wcs(self) -> SkyProjectionAstropyView | None:
87 """An Astropy WCS for this image's pixel array.
89 Notes
90 -----
91 As expected for Astropy WCS objects, this defines pixel coordinates
92 such that the first row and column in any associated arrays are
93 ``(0, 0)``, not ``bbox.start``, as is the case for `sky_projection`.
95 This object satisfies the `astropy.wcs.wcsapi.BaseHighLevelWCS` and
96 `astropy.wcs.wcsapi.BaseLowLevelWCS` interfaces, but it is not an
97 `astropy.wcs.WCS` (use `fits_wcs` for that).
98 """
99 return self.sky_projection.as_astropy(self.bbox) if self.sky_projection is not None else None
101 @cached_property
102 def fits_wcs(self) -> astropy.wcs.WCS | None:
103 """An Astropy FITS WCS for this image's pixel array.
105 Notes
106 -----
107 As expected for Astropy WCS objects, this defines pixel coordinates
108 such that the first row and column in any associated arrays are
109 ``(0, 0)``, not ``bbox.start``, as is the case for `sky_projection`.
111 This may be an approximation or absent if `sky_projection` is not
112 naturally representable as a FITS WCS.
113 """
114 return (
115 self.sky_projection.as_fits_wcs(self.bbox, allow_approximation=True)
116 if self.sky_projection is not None
117 else None
118 )
120 @property
121 def local(self) -> LocalSliceProxy[Self]:
122 """A proxy object for slicing a generalized image using "local" or
123 "array" pixel coordinates.
125 Notes
126 -----
127 In this convention, the first row and column of the pixel grid is
128 always at ``(0, 0)``. This is also the convention used by
129 `astropy.wcs` objects. When a subimage is created from a parent image,
130 its "local" coordinate system is offset from the coordinate systems of
131 the parent image.
133 Note that most `lsst.images` types (e.g. `~lsst.images.Box`,
134 `~lsst.images.SkyProjection`, `~lsst.images.psfs.PointSpreadFunction`)
135 operate instead in "absolute" coordinates, which is shared by subimage
136 and their parents.
138 See Also
139 --------
140 lsst.images.BoxSliceFactory
141 lsst.images.IntervalSliceFactory
142 """
143 return LocalSliceProxy(self)
145 @property
146 def absolute(self) -> AbsoluteSliceProxy[Self]:
147 """A proxy object for slicing a generalized image using absolute pixel
148 coordinates.
150 Notes
151 -----
152 In this convention, the first row and column of the pixel grid is
153 ``bbox.start``. A subimage and its parent image share the same
154 absolute pixel coordinate system, and most `lsst.images` types (e.g.
155 `~lsst.images.Box`, `~lsst.images.SkyProjection`,
156 `~lsst.images.psfs.PointSpreadFunction`) operate exclusively in this
157 system.
159 Note that `astropy.wcs` and `numpy.ndarray` are not aware of the
160 ``bbox.start`` offset that defines tihs coordinates system; use
161 `local` slicing for indices obtained from those.
163 See Also
164 --------
165 lsst.images.BoxSliceFactory
166 lsst.images.IntervalSliceFactory
167 """
168 return AbsoluteSliceProxy(self)
170 @property
171 def metadata(self) -> dict[str, MetadataValue]:
172 """Arbitrary flexible metadata associated with the image (`dict`).
174 Notes
175 -----
176 Metadata is shared with subimages and other views. It can be
177 disconnected by reassigning to a copy explicitly:
179 image.metadata = image.metadata.copy()
180 """
181 return self._metadata
183 @metadata.setter
184 def metadata(self, value: dict[str, MetadataValue]) -> None:
185 self._metadata = value
187 # Subclasses should delegate to _handle_getitem_args for some user-friendly
188 # argument type-checking before providing their own implementation.
189 @abstractmethod
190 def __getitem__(self, bbox: Box | EllipsisType) -> Self:
191 raise NotImplementedError()
193 @abstractmethod
194 def copy(self) -> Self:
195 """Deep-copy the image and metadata.
197 Attached immutable objects (like `~lsst.images.SkyProjection`
198 instances) are not copied.
199 """
200 raise NotImplementedError()
202 @classmethod
203 def read(cls, path: ResourcePathExpression, **kwargs: Any) -> Self:
204 """Read an instance of this class from a file.
206 A thin convenience wrapper around
207 `lsst.images.serialization.read_archive` that fixes the expected
208 in-memory type to this class. The container format is inferred
209 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_archive`
217 (e.g. ``bbox`` to 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
225 `lsst.images.serialization.write_archive`.
226 The container format is chosen from ``path``'s extension.
228 Parameters
229 ----------
230 path
231 Destination file path. Must not already exist.
232 **kwargs
233 Forwarded to `~lsst.images.serialization.write_archive` (e.g.
234 ``compression_options`` and ``compression_seed`` for FITS).
235 """
236 write_archive(self, path, **kwargs)
238 @property
239 def butler_dataset(self) -> SerializedDatasetRef | None:
240 """The butler dataset reference for this image
241 (`lsst.daf.butler.SerializedDatasetRef` | `None`).
242 """
243 if self._butler_info is None: 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true
244 return None
245 from lsst.daf.butler import SerializedDatasetRef
247 # Guard against the unlikely case where the dataset was deserialized as
248 # Any because `lsst.daf.butler` couldn't be imported before, but can be
249 # imported now (*anything* can happen in Jupyter).
250 return SerializedDatasetRef.model_validate(self._butler_info.dataset)
252 @property
253 def butler_provenance(self) -> DatasetProvenance | None:
254 """The butler inputs and ID of the task quantum that produced this
255 dataset (`lsst.daf.butler.DatasetProvenance` | `None`).
256 """
257 if self._butler_info is None: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true
258 return None
260 # Guard against the unlikely case where the provenance was deserialized
261 # as Any because `lsst.daf.butler` couldn't be imported before, but can
262 # be imported now (*anything* can happen in Jupyter).
263 from lsst.daf.butler import DatasetProvenance
265 return DatasetProvenance.model_validate(self._butler_info.provenance)
267 def _transfer_metadata[T: GeneralizedImage](
268 self, new: T, copy: bool = False, bbox: Box | None = None
269 ) -> T:
270 """Transfer metadata held by this base class to a new instance.
272 Parameters
273 ----------
274 new
275 New instance to modify and return.
276 copy
277 Whether the new instance is a deep-copy of ``self``.
278 bbox
279 Bounding box used to construct ``new`` as a subset of ``self``.
281 Returns
282 -------
283 GeneralizedImage
284 The new object passed in, modified in place.
286 Notes
287 -----
288 This is a utility method for subclasses to use when finishing
289 construction of a new one.
290 """
291 if bbox is not None:
292 opaque_metadata = (
293 self._opaque_metadata.subset(bbox) if self._opaque_metadata is not None else None
294 )
295 else:
296 opaque_metadata = self._opaque_metadata
297 metadata = self._metadata
298 if copy:
299 metadata = metadata.copy()
300 opaque_metadata = opaque_metadata.copy() if opaque_metadata is not None else None
301 new._metadata = metadata
302 new._opaque_metadata = opaque_metadata
303 new._butler_info = self._butler_info
304 return new
306 def _finish_deserialize(self, model: ArchiveTree) -> Self:
307 """Attach generic information from `ArchiveTree` to this instance
308 at the end of deserialization.
309 """
310 self._metadata = model.metadata
311 self._butler_info = model.butler_info
312 return self
314 def _handle_getitem_args(self, bbox: Box | EllipsisType) -> tuple[Box, YX[slice]]:
315 """Interpret the standard arguments to __getitem__."""
316 if bbox is ...:
317 return self.bbox, YX(y=slice(None), x=slice(None))
318 elif not isinstance(bbox, Box): 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true
319 raise TypeError(
320 "Only Box objects can be used to subset image objects directly; "
321 "use .local[y, x] or .absolute[y, x] proxies for slice-based subsets."
322 )
323 return bbox, bbox.slice_within(self.bbox)
325 def bbox_from_sky_circle(
326 self, center: astropy.coordinates.SkyCoord, radius: astropy.coordinates.Angle, clip: bool = False
327 ) -> Box:
328 """Calculate the bounding box on this image corresponding to a circular
329 region on the sky.
331 Parameters
332 ----------
333 center
334 The center of the circle, as a scalar
335 `astropy.coordinates.SkyCoord` in any frame.
336 radius
337 Radius of the circle, as a scalar `astropy.coordinates.Angle`.
338 clip
339 If `True` (`False` is default), clip pixel bounds when the circle
340 extends outside the image. If `False` an exception is raised if
341 any part of the circle is off the image.
343 Returns
344 -------
345 Box
346 Bounding box enclosing the circle in this image's pixel
347 coordinates.
349 Raises
350 ------
351 NotContainedError
352 Raised if the requested region is entirely off the image or if
353 any part of the region is off the image and clipping is `False`.
354 ValueError
355 Raised if ``center`` or ``radius`` is not scalar, or if this
356 image has no sky projection.
357 """
358 # Get the relevant mapping.
359 sky_projection = self.sky_projection
360 if sky_projection is None:
361 raise ValueError("A sky projection is required to calculate a bounding box from a sky region.")
363 # Calculate the Box around the region.
364 region_box = Box.from_sky_circle(sky_projection, center, radius)
366 # Determine the box within the image itself, clipping if requested.
367 if clip:
368 try:
369 region_box = region_box.intersection(self.bbox)
370 except NoOverlapError as e:
371 raise NotContainedError(
372 f"Requested sky circle has pixel bbox {region_box} which does not overlap {self.bbox}"
373 ) from e
374 if not self.bbox.contains(region_box):
375 raise NotContainedError(
376 f"Requested sky circle has pixel bbox {region_box}, which is not within {self.bbox}"
377 )
379 return region_box
382class LocalSliceProxy[T: GeneralizedImage]:
383 """A proxy object for obtaining a generalized image subset using local
384 slicing.
386 See `~lsst.images.GeneralizedImage.local` for more information.
388 Parameters
389 ----------
390 parent
391 Image the slice proxy operates on.
392 """
394 def __init__(self, parent: T) -> None:
395 self._parent = parent
397 def __getitem__(self, slices: tuple[slice, slice]) -> T:
398 try:
399 return self._parent[self._parent.bbox.local[slices]]
400 except TypeError as err:
401 if hasattr(self._parent, "array"):
402 err.add_note("The .array attribute may provide more slicing flexibility.")
403 raise
406class AbsoluteSliceProxy[T: GeneralizedImage]:
407 """A proxy object for obtaining a generalized image subset using absolute
408 slicing.
410 See `~lsst.images.GeneralizedImage.absolute` for more information.
412 Parameters
413 ----------
414 parent
415 Image the slice proxy operates on.
416 """
418 def __init__(self, parent: T) -> None:
419 self._parent = parent
421 def __getitem__(self, slices: tuple[slice, slice]) -> T:
422 try:
423 return self._parent[self._parent.bbox.absolute[slices]]
424 except TypeError as err:
425 if hasattr(self._parent, "array"):
426 err.add_note(
427 "The .array attribute may provide more slicing flexibility "
428 "(but only works in local coordinates)."
429 )
430 raise