Coverage for qubalab/images/metadata/image_metadata.py: 95%
75 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-10-07 15:29 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-10-07 15:29 +0000
1import logging
2import math
3import numpy as np
4from typing import Tuple, Optional
5from .image_channel import ImageChannel
6from .image_shape import ImageShape
7from .pixel_calibration import PixelCalibration
8from numpy.typing import DTypeLike
11class ImageMetadata:
12 """
13 Simple class to store core metadata for a pyramidal image.
14 """
16 def __init__(
17 self,
18 path: str,
19 name: str,
20 shapes: Tuple[ImageShape, ...],
21 pixel_calibration: PixelCalibration,
22 is_rgb: bool,
23 dtype: DTypeLike,
24 channels: Optional[Tuple[ImageChannel, ...]] = None,
25 downsamples=None,
26 ):
27 """
28 :param path: the local path to the image
29 :param name: the image name
30 :param shapes: the image shape, for each resolution of the image
31 :param pixel_calibration: the pixel calibration information of the image
32 :param is_rgb: whether pixels of the image are stored with the RGB format
33 :param dtype: the type of the pixel values
34 :param _channels: the channels of the image (optional)
35 :param _downsamples: the downsamples of the image (optional)
36 """
37 self.path = path
38 self.name = name
39 self.shapes = shapes
40 self.pixel_calibration = pixel_calibration
41 self.is_rgb = is_rgb
42 self.dtype = dtype
43 self._channels = channels
44 self._downsamples = downsamples
46 _DEFAULT_CHANNEL_SINGLE = (ImageChannel(name="Single channel", color=(1, 1, 1)),)
47 _DEFAULT_CHANNEL_RGB = (
48 ImageChannel(name="Red", color=(1, 0, 0)),
49 ImageChannel(name="Green", color=(0, 1, 0)),
50 ImageChannel(name="Green", color=(0, 0, 1)),
51 )
52 _DEFAULT_CHANNEL_TWO = (
53 ImageChannel(name="Channel 1", color=(1, 0, 1)),
54 ImageChannel(name="Channel 2", color=(0, 1, 0)),
55 )
56 _DEFAULT_CHANNEL_COLORS = (
57 (0, 1, 1),
58 (1, 1, 0),
59 (1, 0, 1),
60 (1, 0, 0),
61 (0, 1, 0),
62 (0, 0, 1),
63 )
65 @property
66 def shape(self) -> ImageShape:
67 """
68 The dimensions of the full-resolution image.
69 """
70 return self.shapes[0]
72 @property
73 def width(self) -> int:
74 """
75 The width of the full-resolution image.
76 """
77 return self.shape.x
79 @property
80 def height(self) -> int:
81 """
82 The height of the full-resolution image.
83 """
84 return self.shape.y
86 @property
87 def n_channels(self) -> int:
88 """
89 The number of channels of the image.
90 """
91 return self.shape.c
93 @property
94 def n_timepoints(self) -> int:
95 """
96 The number of time points of the image.
97 """
98 return self.shape.t
100 @property
101 def n_z_slices(self) -> int:
102 """
103 The number of z-slices of the image.
104 """
105 return self.shape.z
107 @property
108 def n_resolutions(self) -> int:
109 """
110 The number of resolutions of the image.
111 """
112 return len(self.shapes)
114 @property
115 def downsamples(self) -> Tuple[float, ...]:
116 """
117 The downsamples of the image.
118 """
119 if self._downsamples is None:
120 self._downsamples = tuple(
121 self._estimate_downsample(self.shape, s) for s in self.shapes
122 )
123 return self._downsamples
125 @property
126 def channels(self) -> Tuple[ImageChannel, ...]:
127 """
128 The channels of the image.
129 """
130 if self._channels is None:
131 if self.is_rgb:
132 self._channels = self._DEFAULT_CHANNEL_RGB
133 else:
134 if self.n_channels == 1:
135 self._channels = self._DEFAULT_CHANNEL_SINGLE
136 elif self.n_channels == 2:
137 self._channels = self._DEFAULT_CHANNEL_TWO
138 else:
139 self._channels = tuple(
140 ImageChannel(
141 f"Channel {ii + 1}",
142 self._DEFAULT_CHANNEL_COLORS[
143 ii % len(self._DEFAULT_CHANNEL_COLORS)
144 ],
145 )
146 for ii in range(self.n_channels)
147 )
148 return self._channels
150 def __eq__(self, other):
151 if isinstance(other, ImageMetadata):
152 return (
153 self.path == other.path
154 and self.name == other.name
155 and self.shapes == other.shapes
156 and self.pixel_calibration == other.pixel_calibration
157 and self.is_rgb == other.is_rgb
158 and self.dtype == other.dtype
159 and self.channels == other.channels
160 and self.downsamples == other.downsamples
161 )
162 else:
163 return False
165 def _estimate_downsample(
166 self, higher_resolution_shape: ImageShape, lower_resolution_shape: ImageShape
167 ) -> float:
168 """
169 Estimate the downsample factor between a higher resolution and a lower resolution ImageShape.
171 This is used to prefer values like 4 rather than 4.000345, which arise due to resolutions having to have
172 integer pixel dimensions.
173 The downsample is computed between the width and the heights of the shapes.
174 :param higher_resolution_shape: a higher resolution shape
175 :param lower_resolution_shape: a lower resolution shape
176 :return: the downsample between the higher and the lower resolution shape
177 """
179 dx = higher_resolution_shape.x / lower_resolution_shape.x
180 dy = higher_resolution_shape.y / lower_resolution_shape.y
181 downsample = (dx + dy) / 2.0
182 downsample_round = round(downsample)
184 if self._possible_downsample(
185 higher_resolution_shape.x, lower_resolution_shape.x, downsample_round
186 ) and self._possible_downsample(
187 higher_resolution_shape.y, lower_resolution_shape.y, downsample_round
188 ):
189 if downsample != downsample_round:
190 logging.debug(
191 f"Returning rounded downsample value {downsample_round} instead of {downsample}"
192 )
193 return downsample_round
194 else:
195 return downsample
197 def _possible_downsample(
198 self,
199 higher_resolution_value: int,
200 lower_resolution_value: int,
201 downsample: float,
202 ) -> bool:
203 """
204 Determine if an image dimension is what you'd expect after downsampling and applying floor or ceil.
206 :param higher_resolution_value: a higher resolution value to downsample
207 :param lower_resolution_value: a lower resolution value to compare to the downsampled higher resolution value
208 :param downsample: the downsample to apply to the higher resolution value
209 :return: whether the downsampled higher resolution value corresponds to the lower resolution value
210 """
211 return (
212 math.floor(higher_resolution_value / downsample) == lower_resolution_value
213 or math.ceil(higher_resolution_value / downsample) == lower_resolution_value
214 )