Coverage for qubalab/images/metadata/image_metadata.py: 95%

73 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-31 11:24 +0000

1import logging 

2import math 

3import numpy as np 

4from .image_channel import ImageChannel 

5from .image_shape import ImageShape 

6from .pixel_calibration import PixelCalibration 

7 

8 

9class ImageMetadata: 

10 """ 

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

12 """ 

13 

14 def __init__( 

15 self, 

16 path: str, 

17 name: str, 

18 shapes: tuple[ImageShape, ...], 

19 pixel_calibration: PixelCalibration, 

20 is_rgb: bool, 

21 dtype: np.dtype, 

22 channels: tuple[ImageChannel, ...] = None, 

23 downsamples = None 

24 ): 

25 """ 

26 :param path: the local path to the image 

27 :param name: the image name 

28 :param shapes: the image shape, for each resolution of the image 

29 :param pixel_calibration: the pixel calibration information of the image 

30 :param is_rgb: whether pixels of the image are stored with the RGB format 

31 :param dtype: the type of the pixel values 

32 :param _channels: the channels of the image (optional) 

33 :param _downsamples: the downsamples of the image (optional) 

34 """ 

35 self.path = path 

36 self.name = name 

37 self.shapes = shapes 

38 self.pixel_calibration = pixel_calibration 

39 self.is_rgb = is_rgb 

40 self.dtype = dtype 

41 self._channels = channels 

42 self._downsamples = downsamples 

43 

44 _DEFAULT_CHANNEL_SINGLE = ( 

45 ImageChannel(name='Single channel', color=(1, 1, 1)), 

46 ) 

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(self._estimate_downsample(self.shape, s) for s in self.shapes) 

121 return self._downsamples 

122 

123 @property 

124 def channels(self) -> tuple[ImageChannel, ...]: 

125 """ 

126 The channels of the image. 

127 """ 

128 if self._channels is None: 

129 if self.is_rgb: 

130 self._channels = self._DEFAULT_CHANNEL_RGB 

131 else: 

132 if self.n_channels == 1: 

133 self._channels = self._DEFAULT_CHANNEL_SINGLE 

134 elif self.n_channels == 2: 

135 self._channels = self._DEFAULT_CHANNEL_TWO 

136 else: 

137 self._channels = [ 

138 ImageChannel( 

139 f'Channel {ii + 1}', 

140 self._DEFAULT_CHANNEL_COLORS[ii % len(self._DEFAULT_CHANNEL_COLORS)] 

141 ) for ii in range(self.n_channels) 

142 ] 

143 return self._channels 

144 

145 def __eq__(self, other): 

146 if isinstance(other, ImageMetadata): 

147 return self.path == other.path and \ 

148 self.path == other.path and self.name == other.name and self.shapes == other.shapes \ 

149 and self.pixel_calibration == other.pixel_calibration and self.is_rgb == other.is_rgb \ 

150 and self.dtype == other.dtype and self.channels == other.channels and self.downsamples == other.downsamples 

151 else: 

152 return False 

153 

154 def _estimate_downsample(self, higher_resolution_shape: ImageShape, lower_resolution_shape: ImageShape) -> float: 

155 """ 

156 Estimate the downsample factor between a higher resolution and a lower resolution ImageShape. 

157  

158 This is used to prefer values like 4 rather than 4.000345, which arise due to resolutions having to have 

159 integer pixel dimensions. 

160 The downsample is computed between the width and the heights of the shapes. 

161 :param higher_resolution_shape: a higher resolution shape 

162 :param lower_resolution_shape: a lower resolution shape 

163 :return: the downsample between the higher and the lower resolution shape 

164 """ 

165 

166 dx = higher_resolution_shape.x / lower_resolution_shape.x 

167 dy = higher_resolution_shape.y / lower_resolution_shape.y 

168 downsample = (dx + dy) / 2.0 

169 downsample_round = round(downsample) 

170 

171 if ( 

172 self._possible_downsample(higher_resolution_shape.x, lower_resolution_shape.x, downsample_round) and 

173 self._possible_downsample(higher_resolution_shape.y, lower_resolution_shape.y, downsample_round) 

174 ): 

175 if downsample != downsample_round: 

176 logging.debug(f'Returning rounded downsample value {downsample_round} instead of {downsample}') 

177 return downsample_round 

178 else: 

179 return downsample 

180 

181 def _possible_downsample(self, higher_resolution_value: int, lower_resolution_value: int, downsample: float) -> bool: 

182 """ 

183 Determine if an image dimension is what you'd expect after downsampling and applying floor or ceil. 

184 

185 :param higher_resolution_value: a higher resolution value to downsample 

186 :param lower_resolution_value: a lower resolution value to compare to the downsampled higher resolution value 

187 :param downsample: the downsample to apply to the higher resolution value 

188 :return: whether the downsampled higher resolution value corresponds to the lower resolution value 

189 """ 

190 return (math.floor(higher_resolution_value / downsample) == lower_resolution_value or 

191 math.ceil(higher_resolution_value / downsample) == lower_resolution_value)