Coverage for python/lsst/images/_generalized_image.py: 49%
131 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-30 11:30 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-30 11:30 +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 collections.abc import Mapping
18from functools import cached_property
19from types import EllipsisType
20from typing import TYPE_CHECKING, Any, Self, TypeVar
22import astropy.wcs
24from lsst.resources import ResourcePathExpression
26from ._geom import YX, Box, NoOverlapError, NotContainedError
27from ._metadata import MetadataView, _NativeMetadata
28from ._transforms import SkyProjection, SkyProjectionAstropyView
29from .describe import DescribableMixin
30from .serialization import (
31 ArchiveTree,
32 ButlerInfo,
33 EmptyExternalMetadata,
34 MetadataValue,
35 OpaqueArchiveMetadata,
36 read_archive,
37 write_archive,
38)
40if TYPE_CHECKING:
41 from lsst.daf.butler import DatasetProvenance, SerializedDatasetRef
44T = TypeVar("T", bound="GeneralizedImage") # for sphinx
47class GeneralizedImage(DescribableMixin, 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. A `dict`,
55 or the native metadata of a `~lsst.images.MetadataView`, is shared
56 by reference rather than copied, unlike assignment to `metadata`;
57 any other mapping is copied.
58 """
60 def __init__(self, metadata: Mapping[str, MetadataValue] | MetadataView | None = None) -> None:
61 self._metadata = self._to_native_dict(metadata) if metadata is not None else {}
62 self._opaque_metadata: OpaqueArchiveMetadata | None = None
63 self._butler_info: ButlerInfo | None = None
65 @property
66 @abstractmethod
67 def bbox(self) -> Box:
68 """Bounding box for the image (`~lsst.images.Box`)."""
69 raise NotImplementedError()
71 @property
72 def yx0(self) -> YX[int]:
73 """The coordinates of the first pixel in the array
74 (`~lsst.geom.YX` [`int`]).
75 """
76 return self.bbox.start
78 @property
79 @abstractmethod
80 def sky_projection(self) -> SkyProjection[Any] | None:
81 """The projection that maps this image's pixel grid to the sky
82 (`~lsst.images.SkyProjection` | `None`).
84 Notes
85 -----
86 The pixel coordinates used by this projection account for the bounding
87 box ``start``; they are not just array indices.
88 """
89 raise NotImplementedError()
91 @property
92 def astropy_wcs(self) -> SkyProjectionAstropyView | None:
93 """An Astropy WCS for this image's pixel array.
95 Notes
96 -----
97 As expected for Astropy WCS objects, this defines pixel coordinates
98 such that the first row and column in any associated arrays are
99 ``(0, 0)``, not ``bbox.start``, as is the case for `sky_projection`.
101 This object satisfies the `astropy.wcs.wcsapi.BaseHighLevelWCS` and
102 `astropy.wcs.wcsapi.BaseLowLevelWCS` interfaces, but it is not an
103 `astropy.wcs.WCS` (use `fits_wcs` for that).
104 """
105 return self.sky_projection.as_astropy(self.bbox) if self.sky_projection is not None else None
107 @cached_property
108 def fits_wcs(self) -> astropy.wcs.WCS | None:
109 """An Astropy FITS WCS for this image's pixel array.
111 Notes
112 -----
113 As expected for Astropy WCS objects, this defines pixel coordinates
114 such that the first row and column in any associated arrays are
115 ``(0, 0)``, not ``bbox.start``, as is the case for `sky_projection`.
117 This may be an approximation or absent if `sky_projection` is not
118 naturally representable as a FITS WCS.
119 """
120 return (
121 self.sky_projection.as_fits_wcs(self.bbox, allow_approximation=True)
122 if self.sky_projection is not None
123 else None
124 )
126 @property
127 def local(self) -> LocalSliceProxy[Self]:
128 """A proxy object for slicing a generalized image using "local" or
129 "array" pixel coordinates.
131 Notes
132 -----
133 In this convention, the first row and column of the pixel grid is
134 always at ``(0, 0)``. This is also the convention used by
135 `astropy.wcs` objects. When a subimage is created from a parent image,
136 its "local" coordinate system is offset from the coordinate systems of
137 the parent image.
139 Note that most `lsst.images` types (e.g. `~lsst.images.Box`,
140 `~lsst.images.SkyProjection`, `~lsst.images.psfs.PointSpreadFunction`)
141 operate instead in "absolute" coordinates, which is shared by subimage
142 and their parents.
144 See Also
145 --------
146 lsst.images.BoxSliceFactory
147 lsst.images.IntervalSliceFactory
148 """
149 return LocalSliceProxy(self)
151 @property
152 def absolute(self) -> AbsoluteSliceProxy[Self]:
153 """A proxy object for slicing a generalized image using absolute pixel
154 coordinates.
156 Notes
157 -----
158 In this convention, the first row and column of the pixel grid is
159 ``bbox.start``. A subimage and its parent image share the same
160 absolute pixel coordinate system, and most `lsst.images` types (e.g.
161 `~lsst.images.Box`, `~lsst.images.SkyProjection`,
162 `~lsst.images.psfs.PointSpreadFunction`) operate exclusively in this
163 system.
165 Note that `astropy.wcs` and `numpy.ndarray` are not aware of the
166 ``bbox.start`` offset that defines tihs coordinates system; use
167 `local` slicing for indices obtained from those.
169 See Also
170 --------
171 lsst.images.BoxSliceFactory
172 lsst.images.IntervalSliceFactory
173 """
174 return AbsoluteSliceProxy(self)
176 @property
177 def metadata(self) -> MetadataView:
178 """Flexible metadata associated with the image
179 (`~lsst.images.MetadataView`).
181 Notes
182 -----
183 The view combines two sources:
185 - ``metadata.native``: metadata that is part of the image's data
186 model and is saved with it. Keys are case-sensitive.
187 - ``metadata.external``: read-only metadata carried in from the file
188 the image was read from, such as the primary FITS header. Keys are
189 case-insensitive, and ``COMMENT`` and ``HISTORY`` cards are not
190 included.
192 Lookups check native metadata first, matching case exactly, and then
193 external metadata. Writes go to native metadata; adding a new key
194 that case-insensitively matches an external key raises `KeyError`.
195 When an external key is repeated, which of its values is returned is
196 unspecified; use `~lsst.images.MetadataView.get_all` to obtain every
197 value.
199 Native metadata is shared with subimages and other views. Assigning
200 a mapping replaces the native metadata with a shallow copy of it (of
201 just its native metadata, for a `~lsst.images.MetadataView`),
202 disconnecting this image from any others it was shared with while
203 leaving external metadata readable. To disconnect while keeping the
204 current contents, or to start empty::
206 image.metadata = image.metadata
207 image.metadata = {}
209 Assignment does not check for keys that shadow external metadata.
210 """
211 external = (
212 self._opaque_metadata.external_metadata()
213 if self._opaque_metadata is not None
214 else EmptyExternalMetadata()
215 )
216 return MetadataView(_NativeMetadata(self._metadata, external), external)
218 @metadata.setter
219 def metadata(self, value: Mapping[str, MetadataValue] | MetadataView) -> None:
220 self._metadata = dict(value._native if isinstance(value, MetadataView) else value)
222 @staticmethod
223 def _to_native_dict(value: Mapping[str, MetadataValue] | MetadataView) -> dict[str, MetadataValue]:
224 """Return the dict to hold as native metadata, sharing it with
225 ``value`` where possible.
226 """
227 if isinstance(value, MetadataView):
228 return value._native._data
229 if isinstance(value, dict): 229 ↛ 231line 229 didn't jump to line 231 because the condition on line 229 was always true
230 return value
231 return dict(value)
233 # Subclasses should delegate to _handle_getitem_args for some user-friendly
234 # argument type-checking before providing their own implementation.
235 @abstractmethod
236 def __getitem__(self, bbox: Box | EllipsisType) -> Self:
237 raise NotImplementedError()
239 @abstractmethod
240 def copy(self) -> Self:
241 """Deep-copy the image and metadata.
243 Attached immutable objects (like `~lsst.images.SkyProjection`
244 instances) are not copied.
245 """
246 raise NotImplementedError()
248 @classmethod
249 def read(cls, path: ResourcePathExpression, **kwargs: Any) -> Self:
250 """Read an instance of this class from a file.
252 A thin convenience wrapper around
253 `lsst.images.serialization.read_archive` that fixes the expected
254 in-memory type to this class. The container format is inferred
255 from ``path``'s extension.
257 Parameters
258 ----------
259 path
260 File to read; convertible to `lsst.resources.ResourcePath`.
261 **kwargs
262 Forwarded to `~lsst.images.serialization.read_archive`
263 (e.g. ``bbox`` to read a subimage).
264 """
265 return read_archive(path, cls, **kwargs)
267 def write(self, path: str, **kwargs: Any) -> None:
268 """Write this object to a file.
270 A thin convenience wrapper around
271 `lsst.images.serialization.write_archive`.
272 The container format is chosen from ``path``'s extension.
274 Parameters
275 ----------
276 path
277 Destination file path. Must not already exist.
278 **kwargs
279 Forwarded to `~lsst.images.serialization.write_archive` (e.g.
280 ``compression_options`` and ``compression_seed`` for FITS).
281 """
282 write_archive(self, path, **kwargs)
284 @property
285 def butler_dataset(self) -> SerializedDatasetRef | None:
286 """The butler dataset reference for this image
287 (`lsst.daf.butler.SerializedDatasetRef` | `None`).
288 """
289 if self._butler_info is None: 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true
290 return None
291 from lsst.daf.butler import SerializedDatasetRef
293 # Guard against the unlikely case where the dataset was deserialized as
294 # Any because `lsst.daf.butler` couldn't be imported before, but can be
295 # imported now (*anything* can happen in Jupyter).
296 return SerializedDatasetRef.model_validate(self._butler_info.dataset)
298 @property
299 def butler_provenance(self) -> DatasetProvenance | None:
300 """The butler inputs and ID of the task quantum that produced this
301 dataset (`lsst.daf.butler.DatasetProvenance` | `None`).
302 """
303 if self._butler_info is None: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true
304 return None
306 # Guard against the unlikely case where the provenance was deserialized
307 # as Any because `lsst.daf.butler` couldn't be imported before, but can
308 # be imported now (*anything* can happen in Jupyter).
309 from lsst.daf.butler import DatasetProvenance
311 return DatasetProvenance.model_validate(self._butler_info.provenance)
313 def _transfer_metadata[T: GeneralizedImage](
314 self, new: T, copy: bool = False, bbox: Box | None = None
315 ) -> T:
316 """Transfer metadata held by this base class to a new instance.
318 Parameters
319 ----------
320 new
321 New instance to modify and return.
322 copy
323 Whether the new instance is a deep-copy of ``self``.
324 bbox
325 Bounding box used to construct ``new`` as a subset of ``self``.
327 Returns
328 -------
329 GeneralizedImage
330 The new object passed in, modified in place.
332 Notes
333 -----
334 This is a utility method for subclasses to use when finishing
335 construction of a new one.
336 """
337 if bbox is not None:
338 opaque_metadata = (
339 self._opaque_metadata.subset(bbox) if self._opaque_metadata is not None else None
340 )
341 else:
342 opaque_metadata = self._opaque_metadata
343 metadata = self._metadata
344 if copy:
345 metadata = metadata.copy()
346 opaque_metadata = opaque_metadata.copy() if opaque_metadata is not None else None
347 new._metadata = metadata
348 new._opaque_metadata = opaque_metadata
349 new._butler_info = self._butler_info
350 return new
352 def _finish_deserialize(self, model: ArchiveTree) -> Self:
353 """Attach generic information from `ArchiveTree` to this instance
354 at the end of deserialization.
355 """
356 self._metadata = model.metadata
357 self._butler_info = model.butler_info
358 return self
360 def _handle_getitem_args(self, bbox: Box | EllipsisType) -> tuple[Box, YX[slice]]:
361 """Interpret the standard arguments to __getitem__."""
362 if bbox is ...:
363 return self.bbox, YX(y=slice(None), x=slice(None))
364 elif not isinstance(bbox, Box): 364 ↛ 365line 364 didn't jump to line 365 because the condition on line 364 was never true
365 raise TypeError(
366 "Only Box objects can be used to subset image objects directly; "
367 "use .local[y, x] or .absolute[y, x] proxies for slice-based subsets."
368 )
369 return bbox, bbox.slice_within(self.bbox)
371 def bbox_from_sky_circle(
372 self, center: astropy.coordinates.SkyCoord, radius: astropy.coordinates.Angle, clip: bool = False
373 ) -> Box:
374 """Calculate the bounding box on this image corresponding to a circular
375 region on the sky.
377 Parameters
378 ----------
379 center
380 The center of the circle, as a scalar
381 `astropy.coordinates.SkyCoord` in any frame.
382 radius
383 Radius of the circle, as a scalar `astropy.coordinates.Angle`.
384 clip
385 If `True` (`False` is default), clip pixel bounds when the circle
386 extends outside the image. If `False` an exception is raised if
387 any part of the circle is off the image.
389 Returns
390 -------
391 Box
392 Bounding box enclosing the circle in this image's pixel
393 coordinates.
395 Raises
396 ------
397 NotContainedError
398 Raised if the requested region is entirely off the image or if
399 any part of the region is off the image and clipping is `False`.
400 ValueError
401 Raised if ``center`` or ``radius`` is not scalar, or if this
402 image has no sky projection.
403 """
404 # Get the relevant mapping.
405 sky_projection = self.sky_projection
406 if sky_projection is None:
407 raise ValueError("A sky projection is required to calculate a bounding box from a sky region.")
409 # Calculate the Box around the region.
410 region_box = Box.from_sky_circle(sky_projection, center, radius)
412 # Determine the box within the image itself, clipping if requested.
413 if clip:
414 try:
415 region_box = region_box.intersection(self.bbox)
416 except NoOverlapError as e:
417 raise NotContainedError(
418 f"Requested sky circle has pixel bbox {region_box} which does not overlap {self.bbox}"
419 ) from e
420 if not self.bbox.contains(region_box):
421 raise NotContainedError(
422 f"Requested sky circle has pixel bbox {region_box}, which is not within {self.bbox}"
423 )
425 return region_box
428class LocalSliceProxy[T: GeneralizedImage]:
429 """A proxy object for obtaining a generalized image subset using local
430 slicing.
432 See `~lsst.images.GeneralizedImage.local` for more information.
434 Parameters
435 ----------
436 parent
437 Image the slice proxy operates on.
438 """
440 def __init__(self, parent: T) -> None:
441 self._parent = parent
443 def __getitem__(self, slices: tuple[slice, slice]) -> T:
444 try:
445 return self._parent[self._parent.bbox.local[slices]]
446 except TypeError as err:
447 if hasattr(self._parent, "array"):
448 err.add_note("The .array attribute may provide more slicing flexibility.")
449 raise
452class AbsoluteSliceProxy[T: GeneralizedImage]:
453 """A proxy object for obtaining a generalized image subset using absolute
454 slicing.
456 See `~lsst.images.GeneralizedImage.absolute` for more information.
458 Parameters
459 ----------
460 parent
461 Image the slice proxy operates on.
462 """
464 def __init__(self, parent: T) -> None:
465 self._parent = parent
467 def __getitem__(self, slices: tuple[slice, slice]) -> T:
468 try:
469 return self._parent[self._parent.bbox.absolute[slices]]
470 except TypeError as err:
471 if hasattr(self._parent, "array"):
472 err.add_note(
473 "The .array attribute may provide more slicing flexibility "
474 "(but only works in local coordinates)."
475 )
476 raise