Coverage for python/lsst/images/_color_image.py: 82%
90 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-24 08:44 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-24 08:44 +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__ = ("ColorImage",)
16import functools
17from collections.abc import Sequence
18from types import EllipsisType
19from typing import Any, ClassVar, Literal
21import numpy as np
22import pydantic
24from ._generalized_image import GeneralizedImage
25from ._geom import Box
26from ._image import Image, ImageSerializationModel
27from ._transforms import SkyProjection, SkyProjectionSerializationModel
28from .serialization import ArchiveTree, InputArchive, InvalidParameterError, MetadataValue, OutputArchive
29from .utils import is_none
32class ColorImage(GeneralizedImage):
33 """An RGB image with an optional `SkyProjection`.
35 Parameters
36 ----------
37 array
38 Array or fill value for the image. Must have three dimensions with
39 the shape of the third dimension equal to three.
40 bbox
41 Bounding box for the image.
42 yx0
43 Logical coordinates of the first pixel in the array, ordered ``y``,
44 ``x`` (unless an `XY` instance is passed). Ignored if
45 ``bbox`` is provided. Defaults to zeros.
46 sky_projection
47 Projection that maps the pixel grid to the sky.
48 metadata
49 Arbitrary flexible metadata to associate with the image.
50 """
52 def __init__(
53 self,
54 array: np.ndarray[tuple[int, int, Literal[3]], np.dtype[Any]],
55 /,
56 *,
57 bbox: Box | None = None,
58 yx0: Sequence[int] | None = None,
59 sky_projection: SkyProjection[Any] | None = None,
60 metadata: dict[str, MetadataValue] | None = None,
61 ) -> None:
62 super().__init__(metadata)
63 if bbox is None:
64 bbox = Box.from_shape(array.shape[:2], start=yx0)
65 elif bbox.shape + (3,) != array.shape: 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true
66 raise ValueError(
67 f"Shape from bbox {bbox.shape + (3,)} does not match array with shape {array.shape}."
68 )
69 self._array = array
70 self._red = Image(self._array[..., 0], bbox=bbox, sky_projection=sky_projection)
71 self._green = Image(self._array[..., 1], bbox=bbox, sky_projection=sky_projection)
72 self._blue = Image(self._array[..., 2], bbox=bbox, sky_projection=sky_projection)
74 @staticmethod
75 def from_channels(
76 r: Image,
77 g: Image,
78 b: Image,
79 *,
80 sky_projection: SkyProjection[Any] | None = None,
81 metadata: dict[str, MetadataValue] | None = None,
82 ) -> ColorImage:
83 """Construct from separate RGB images.
85 All channels are assumed to have the same bounding box, sky_projection,
86 and pixel type.
87 """
88 if sky_projection is None and r.sky_projection is not None: 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true
89 sky_projection = r.sky_projection
90 return ColorImage(
91 np.stack([r.array, g.array, b.array], axis=2),
92 bbox=r.bbox,
93 sky_projection=sky_projection,
94 metadata=metadata,
95 )
97 @property
98 def array(self) -> np.ndarray[tuple[int, int, Literal[3]], np.dtype[Any]]:
99 """The 3-d array (`numpy.ndarray`)."""
100 return self._array
102 @property
103 def red(self) -> Image:
104 """A 2-d view of the red channel (`Image`)."""
105 return self._red
107 @property
108 def green(self) -> Image:
109 """A 2-d view of the green channel (`Image`)."""
110 return self._green
112 @property
113 def blue(self) -> Image:
114 """A 2-d view of the blue channel (`Image`)."""
115 return self._blue
117 @property
118 def bbox(self) -> Box:
119 """The 2-d bounding box of the image (`Box`)."""
120 return self._red.bbox
122 @property
123 def sky_projection(self) -> SkyProjection[Any] | None:
124 """The projection that maps the pixel grid to the sky
125 (`SkyProjection` | `None`).
126 """
127 return self._red.sky_projection
129 def __getitem__(self, bbox: Box | EllipsisType) -> ColorImage:
130 super().__getitem__(bbox)
131 if bbox is ...:
132 return self
133 return self._transfer_metadata(
134 ColorImage(
135 self.array[bbox.slice_within(self.bbox) + (slice(None),)],
136 bbox=bbox,
137 sky_projection=self.sky_projection,
138 ),
139 bbox=bbox,
140 )
142 def __setitem__(self, bbox: Box | EllipsisType, value: ColorImage) -> None:
143 self[bbox].array[...] = value.array
145 def __str__(self) -> str:
146 return f"ColorImage({self.bbox!s}, {self._array.dtype.type.__name__})"
148 def __repr__(self) -> str:
149 return f"ColorImage(..., bbox={self.bbox!r}, dtype={self._array.dtype!r})"
151 def copy(self) -> ColorImage:
152 """Deep-copy the image."""
153 return self._transfer_metadata(
154 ColorImage(self._array.copy(), bbox=self.bbox, sky_projection=self.sky_projection), copy=True
155 )
157 def serialize(self, archive: OutputArchive[Any]) -> ColorImageSerializationModel:
158 """Serialize the masked image to an output archive.
160 Parameters
161 ----------
162 archive
163 Archive to write to.
164 """
165 r = archive.serialize_direct("red", functools.partial(self.red.serialize, save_projection=False))
166 g = archive.serialize_direct("green", functools.partial(self.green.serialize, save_projection=False))
167 b = archive.serialize_direct("blue", functools.partial(self.blue.serialize, save_projection=False))
168 serialized_projection = (
169 archive.serialize_direct("sky_projection", self.sky_projection.serialize)
170 if self.sky_projection is not None
171 else None
172 )
173 return ColorImageSerializationModel(
174 red=r, green=g, blue=b, sky_projection=serialized_projection, metadata=self.metadata
175 )
177 @staticmethod
178 def _get_archive_tree_type[P: pydantic.BaseModel](
179 pointer_type: type[P],
180 ) -> type[ColorImageSerializationModel[P]]:
181 """Return the serialization model type for this object for an archive
182 type that uses the given pointer type.
183 """
184 return ColorImageSerializationModel[pointer_type] # type: ignore
187class ColorImageSerializationModel[P: pydantic.BaseModel](ArchiveTree):
188 """A Pydantic model used to represent a serialized `ColorImage`."""
190 SCHEMA_NAME: ClassVar[str] = "color_image"
191 SCHEMA_VERSION: ClassVar[str] = "1.0.0"
192 MIN_READ_VERSION: ClassVar[int] = 1
193 PUBLIC_TYPE: ClassVar[type] = ColorImage
195 red: ImageSerializationModel[P] = pydantic.Field(description="The red channel.")
196 green: ImageSerializationModel[P] = pydantic.Field(description="The green channel.")
197 blue: ImageSerializationModel[P] = pydantic.Field(description="The blue channel")
198 sky_projection: SkyProjectionSerializationModel[P] | None = pydantic.Field(
199 default=None,
200 exclude_if=is_none,
201 description="Projection that maps the pixel grid to the sky.",
202 )
204 @property
205 def bbox(self) -> Box:
206 """The bounding box of the image."""
207 return self.red.bbox
209 def deserialize(
210 self, archive: InputArchive[Any], *, bbox: Box | None = None, **kwargs: Any
211 ) -> ColorImage:
212 """Deserialize a image from an input archive.
214 Parameters
215 ----------
216 model
217 A Pydantic model representation of the image, holding references
218 to data stored in the archive.
219 archive
220 Archive to read from.
221 bbox
222 Bounding box of a subimage to read instead.
223 """
224 if kwargs: 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true
225 raise InvalidParameterError(f"Unrecognized parameters for ColoImage: {set(kwargs.keys())}.")
226 r = self.red.deserialize(archive, bbox=bbox)
227 g = self.green.deserialize(archive, bbox=bbox)
228 b = self.blue.deserialize(archive, bbox=bbox)
229 sky_projection = self.sky_projection.deserialize(archive) if self.sky_projection is not None else None
230 return ColorImage.from_channels(r, g, b, sky_projection=sky_projection)._finish_deserialize(self)