Coverage for python/lsst/images/_color_image.py: 39%
95 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__ = ("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 .describe import DescribeOptions, FieldRole, Report, ReportField
29from .serialization import ArchiveTree, InputArchive, InvalidParameterError, MetadataValue, OutputArchive
30from .utils import is_none
33class ColorImage(GeneralizedImage):
34 """An RGB image with an optional `SkyProjection`.
36 Parameters
37 ----------
38 array
39 Array or fill value for the image. Must have three dimensions with
40 the shape of the third dimension equal to three.
41 bbox
42 Bounding box for the image.
43 yx0
44 Logical coordinates of the first pixel in the array, ordered ``y``,
45 ``x`` (unless an `XY` instance is passed). Ignored if
46 ``bbox`` is provided. Defaults to zeros.
47 sky_projection
48 Projection that maps the pixel grid to the sky.
49 metadata
50 Arbitrary flexible metadata to associate with the image.
51 """
53 def __init__(
54 self,
55 array: np.ndarray[tuple[int, int, Literal[3]], np.dtype[Any]],
56 /,
57 *,
58 bbox: Box | None = None,
59 yx0: Sequence[int] | None = None,
60 sky_projection: SkyProjection[Any] | None = None,
61 metadata: dict[str, MetadataValue] | None = None,
62 ) -> None:
63 super().__init__(metadata)
64 if bbox is None:
65 bbox = Box.from_shape(array.shape[:2], start=yx0)
66 elif bbox.shape + (3,) != array.shape: 66 ↛ 67line 66 didn't jump to line 67 because the condition on line 66 was never true
67 raise ValueError(
68 f"Shape from bbox {bbox.shape + (3,)} does not match array with shape {array.shape}."
69 )
70 self._array = array
71 self._red = Image(self._array[..., 0], bbox=bbox, sky_projection=sky_projection)
72 self._green = Image(self._array[..., 1], bbox=bbox, sky_projection=sky_projection)
73 self._blue = Image(self._array[..., 2], bbox=bbox, sky_projection=sky_projection)
75 @staticmethod
76 def from_channels(
77 r: Image,
78 g: Image,
79 b: Image,
80 *,
81 sky_projection: SkyProjection[Any] | None = None,
82 metadata: dict[str, MetadataValue] | None = None,
83 ) -> ColorImage:
84 """Construct from separate RGB images.
86 All channels are assumed to have the same bounding box, sky_projection,
87 and pixel type.
89 Parameters
90 ----------
91 r
92 Red channel image.
93 g
94 Green channel image.
95 b
96 Blue channel image.
97 sky_projection
98 Sky projection for the image, defaulting to that of ``r``.
99 metadata
100 Flexible metadata to associate with the image.
101 """
102 if sky_projection is None and r.sky_projection is not None: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 sky_projection = r.sky_projection
104 return ColorImage(
105 np.stack([r.array, g.array, b.array], axis=2),
106 bbox=r.bbox,
107 sky_projection=sky_projection,
108 metadata=metadata,
109 )
111 @property
112 def array(self) -> np.ndarray[tuple[int, int, Literal[3]], np.dtype[Any]]:
113 """The 3-d array (`numpy.ndarray`)."""
114 return self._array
116 @property
117 def red(self) -> Image:
118 """A 2-d view of the red channel (`Image`)."""
119 return self._red
121 @property
122 def green(self) -> Image:
123 """A 2-d view of the green channel (`Image`)."""
124 return self._green
126 @property
127 def blue(self) -> Image:
128 """A 2-d view of the blue channel (`Image`)."""
129 return self._blue
131 @property
132 def bbox(self) -> Box:
133 """The 2-d bounding box of the image (`Box`)."""
134 return self._red.bbox
136 @property
137 def sky_projection(self) -> SkyProjection[Any] | None:
138 """The projection that maps the pixel grid to the sky
139 (`SkyProjection` | `None`).
140 """
141 return self._red.sky_projection
143 def __getitem__(self, bbox: Box | EllipsisType) -> ColorImage:
144 bbox, indices = self._handle_getitem_args(bbox)
145 return self._transfer_metadata(
146 ColorImage(
147 self.array[indices + (slice(None),)],
148 bbox=bbox,
149 sky_projection=self.sky_projection,
150 ),
151 bbox=bbox,
152 )
154 def __setitem__(self, bbox: Box | EllipsisType, value: ColorImage) -> None:
155 self[bbox].array[...] = value.array
157 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
158 """Return a `Report` describing this color image.
160 Parameters
161 ----------
162 options : `DescribeOptions`, optional
163 Rendering options. ``"bbox"`` and ``"sky_projection"`` are
164 recognized in `DescribeOptions.exclude`.
165 """
166 children = {}
167 sky_projection = self.sky_projection
168 if not options.brief and "sky_projection" not in options.exclude and sky_projection is not None: 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true
169 children["sky_projection"] = sky_projection._describe(options.for_child(), bbox=self.bbox)
170 fields = [
171 ReportField(
172 label="array",
173 value="<array>",
174 repr_value="...",
175 positional=True,
176 role=FieldRole.REPR_ONLY,
177 ),
178 ]
179 if "bbox" not in options.exclude: 179 ↛ 181line 179 didn't jump to line 181 because the condition on line 179 was always true
180 fields.append(ReportField(label="bbox", value=self.bbox, repr_value=repr(self.bbox)))
181 fields.append(
182 ReportField(label="dtype", value=str(self._array.dtype), repr_value=repr(self._array.dtype))
183 )
184 return Report(
185 type_name="ColorImage",
186 summary=f"ColorImage({self.bbox!s}, {self._array.dtype.type.__name__})",
187 fields=fields,
188 children=children,
189 )
191 def copy(self) -> ColorImage:
192 """Deep-copy the image."""
193 return self._transfer_metadata(
194 ColorImage(self._array.copy(), bbox=self.bbox, sky_projection=self.sky_projection), copy=True
195 )
197 def serialize(self, archive: OutputArchive[Any]) -> ColorImageSerializationModel:
198 """Serialize the masked image to an output archive.
200 Parameters
201 ----------
202 archive
203 Archive to write to.
204 """
205 r = archive.serialize_direct("red", functools.partial(self.red.serialize, save_projection=False))
206 g = archive.serialize_direct("green", functools.partial(self.green.serialize, save_projection=False))
207 b = archive.serialize_direct("blue", functools.partial(self.blue.serialize, save_projection=False))
208 serialized_projection = (
209 archive.serialize_direct("sky_projection", self.sky_projection.serialize)
210 if self.sky_projection is not None
211 else None
212 )
213 return ColorImageSerializationModel(
214 red=r, green=g, blue=b, sky_projection=serialized_projection, metadata=self.metadata
215 )
217 @staticmethod
218 def _get_archive_tree_type[P: pydantic.BaseModel](
219 pointer_type: type[P],
220 ) -> type[ColorImageSerializationModel[P]]:
221 """Return the serialization model type for this object for an archive
222 type that uses the given pointer type.
223 """
224 return ColorImageSerializationModel[pointer_type] # type: ignore
227class ColorImageSerializationModel[P: pydantic.BaseModel](ArchiveTree):
228 """A Pydantic model used to represent a serialized `ColorImage`."""
230 SCHEMA_NAME: ClassVar[str] = "color_image"
231 SCHEMA_VERSION: ClassVar[str] = "1.0.0.dev0"
232 MIN_READ_VERSION: ClassVar[int] = 1
233 PUBLIC_TYPE: ClassVar[type] = ColorImage
235 red: ImageSerializationModel[P] = pydantic.Field(description="The red channel.")
236 green: ImageSerializationModel[P] = pydantic.Field(description="The green channel.")
237 blue: ImageSerializationModel[P] = pydantic.Field(description="The blue channel")
238 sky_projection: SkyProjectionSerializationModel[P] | None = pydantic.Field(
239 default=None,
240 exclude_if=is_none,
241 description="Projection that maps the pixel grid to the sky.",
242 )
244 @property
245 def bbox(self) -> Box:
246 """The bounding box of the image."""
247 return self.red.bbox
249 def deserialize(
250 self, archive: InputArchive[Any], *, bbox: Box | None = None, **kwargs: Any
251 ) -> ColorImage:
252 """Deserialize a image from an input archive.
254 Parameters
255 ----------
256 archive
257 Archive to read from.
258 bbox
259 Bounding box of a subimage to read instead.
260 **kwargs
261 Unsupported keyword arguments are accepted only to provide
262 better error messages (raising
263 `.serialization.InvalidParameterError`).
264 """
265 if kwargs: 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true
266 raise InvalidParameterError(f"Unrecognized parameters for ColoImage: {set(kwargs.keys())}.")
267 r = self.red.deserialize(archive, bbox=bbox)
268 g = self.green.deserialize(archive, bbox=bbox)
269 b = self.blue.deserialize(archive, bbox=bbox)
270 sky_projection = self.sky_projection.deserialize(archive) if self.sky_projection is not None else None
271 return ColorImage.from_channels(r, g, b, sky_projection=sky_projection)._finish_deserialize(self)