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

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 

9 

10 

11class ImageMetadata: 

12 """ 

13 Simple class to store core metadata for a pyramidal image. 

14 """ 

15 

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 

45 

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 ) 

64 

65 @property 

66 def shape(self) -> ImageShape: 

67 """ 

68 The dimensions of the full-resolution image. 

69 """ 

70 return self.shapes[0] 

71 

72 @property 

73 def width(self) -> int: 

74 """ 

75 The width of the full-resolution image. 

76 """ 

77 return self.shape.x 

78 

79 @property 

80 def height(self) -> int: 

81 """ 

82 The height of the full-resolution image. 

83 """ 

84 return self.shape.y 

85 

86 @property 

87 def n_channels(self) -> int: 

88 """ 

89 The number of channels of the image. 

90 """ 

91 return self.shape.c 

92 

93 @property 

94 def n_timepoints(self) -> int: 

95 """ 

96 The number of time points of the image. 

97 """ 

98 return self.shape.t 

99 

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 

106 

107 @property 

108 def n_resolutions(self) -> int: 

109 """ 

110 The number of resolutions of the image. 

111 """ 

112 return len(self.shapes) 

113 

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 

124 

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 

149 

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 

164 

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. 

170 

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 """ 

178 

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) 

183 

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 

196 

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. 

205 

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 )