Coverage for qubalab/images/bioio_server.py: 88%

65 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-10-07 15:29 +0000

1import numpy as np 

2import dask.array as da 

3import math 

4from pathlib import Path 

5from bioio import BioImage 

6from dataclasses import astuple 

7from typing import Union, Any 

8from .image_server import ImageServer 

9from .metadata.image_metadata import ImageMetadata 

10from .metadata.pixel_calibration import PixelCalibration, PixelLength 

11from .metadata.image_shape import ImageShape 

12from .region_2d import Region2D 

13 

14 

15class BioIOServer(ImageServer): 

16 """ 

17 An ImageServer using BioIO (https://github.com/bioio-devs/bioio). 

18 

19 What this actually supports will depend upon how BioIO is installed. 

20 For example, it may provide Bio-Formats or CZI support... or it may not. 

21 

22 Note that the BioIo library does not currently handle unit attachment, so the pixel unit 

23 given by this server will always be 'pixels'. 

24 

25 Note that the BioIO library does not properly support pyramids, so you might only get the full 

26 resolution image when opening a pyramidal image. 

27 """ 

28 

29 def __init__( 

30 self, 

31 path: str, 

32 scene: int = 0, 

33 detect_resolutions=True, 

34 bioio_kwargs: dict[str, Any] = {}, 

35 **kwargs 

36 ): 

37 """ 

38 :param path: the local path to the image to open 

39 :param scene: BioIO divides images into scene. This parameter specifies which scene to consider 

40 :param detect_resolutions: whether to look at all resolutions of the image (instead of just the full resolution) 

41 :param bioio_kwargs: any specific keyword arguments to pass down to the fsspec created filesystem handled by the BioIO reader 

42 :param resize_method: the resampling method to use when resizing the image for downsampling. Bicubic by default 

43 """ 

44 super().__init__(**kwargs) 

45 self._path = path 

46 self._reader = BioImage(path, **bioio_kwargs) 

47 self._scene = scene 

48 self._detect_resolutions = detect_resolutions 

49 

50 def level_to_dask( 

51 self, level: int = 0, chunk_width: int = 1024, chunk_height: int = 1024 

52 ) -> da.Array: 

53 if level < 0 or level >= self.metadata.n_resolutions: 

54 raise ValueError( 

55 "The provided level ({0}) is outside the valid range ([0, {1}])".format( 

56 level, self.metadata.n_resolutions - 1 

57 ) 

58 ) 

59 

60 axes = ( 

61 ("T" if self.metadata.n_timepoints > 1 else "") 

62 + ( 

63 ("S" if "S" in self._reader.dims.order else "C") 

64 if self.metadata.n_channels > 1 

65 else "" 

66 ) 

67 + ("Z" if self.metadata.n_z_slices > 1 else "") 

68 + "YX" 

69 ) 

70 

71 self._reader.set_scene(self._reader.scenes[self._scene + level]) 

72 return self._reader.get_image_dask_data(axes) 

73 

74 def close(self): 

75 pass 

76 

77 def _build_metadata(self) -> ImageMetadata: 

78 return ImageMetadata( 

79 self._path, 

80 Path(self._path).name, 

81 self._get_shapes(self._reader, self._scene) 

82 if self._detect_resolutions 

83 else ( 

84 self._get_scene_shape(self._reader, self._reader.scenes[self._scene]), 

85 ), 

86 self._get_pixel_calibration(self._reader, self._scene), 

87 self._is_rgb(self._reader, self._scene), 

88 np.dtype(self._reader.dtype), 

89 ) 

90 

91 def _read_block(self, level: int, region: Region2D) -> np.ndarray: 

92 x, y, width, height, z, t = astuple(region) 

93 

94 self._reader.set_scene(self._reader.scenes[self._scene + level]) 

95 axes = "TZ" + ("S" if "S" in self._reader.dims.order else "C") + "YX" 

96 

97 return self._reader.get_image_dask_data(axes)[ 

98 t, z, :, y : y + height, x : x + width 

99 ].compute() 

100 

101 @staticmethod 

102 def _get_shapes(reader: BioImage, scene: int) -> tuple[ImageShape, ...]: 

103 shapes = [] 

104 for _scene in reader.scenes[scene:]: 

105 shape = BioIOServer._get_scene_shape(reader, _scene) 

106 

107 if len(shapes) == 0 or BioIOServer._is_lower_resolution(shapes[-1], shape): 

108 shapes.append(shape) 

109 else: 

110 break 

111 return tuple(shapes) 

112 

113 @staticmethod 

114 def _get_scene_shape(reader: BioImage, scene: Union[int, str]) -> ImageShape: 

115 reader.set_scene(scene) 

116 

117 return ImageShape( 

118 x=reader.dims.X if "X" in reader.dims.order else 1, 

119 y=reader.dims.Y if "Y" in reader.dims.order else 1, 

120 z=reader.dims.Z if "Z" in reader.dims.order else 1, 

121 c=reader.dims.S 

122 if "S" in reader.dims.order 

123 else (reader.dims.C if "C" in reader.dims.order else 1), 

124 t=reader.dims.T if "T" in reader.dims.order else 1, 

125 ) 

126 

127 @staticmethod 

128 def _get_pixel_calibration(reader: BioImage, scene: int) -> PixelCalibration: 

129 reader.set_scene(scene) 

130 sizes = reader.physical_pixel_sizes 

131 

132 if sizes.X or sizes.Y or sizes.Z: 

133 # The bioio library does not currently handle unit attachment, so the pixel unit is returned 

134 return PixelCalibration( 

135 PixelLength(sizes.X) if sizes.X is not None else PixelLength(), 

136 PixelLength(sizes.Y) if sizes.Y is not None else PixelLength(), 

137 PixelLength(sizes.Z) if sizes.Z is not None else PixelLength(), 

138 ) 

139 else: 

140 return PixelCalibration() 

141 

142 @staticmethod 

143 def _is_rgb(reader: BioImage, scene: int) -> bool: 

144 reader.set_scene(scene) 

145 return ("S" in reader.dims.order and reader.dims.S in [3, 4]) or ( 

146 reader.dtype == np.uint8 and reader.dims.C == 3 

147 ) 

148 

149 @staticmethod 

150 def _is_lower_resolution(base_shape: ImageShape, series_shape: ImageShape) -> bool: 

151 """ 

152 Calculate if the series shape is a lower resolution than the base shape. 

153 

154 This involves a bit of guesswork, but it's needed for so long as BioIO doesn't properly support pyramids. 

155 """ 

156 if ( 

157 base_shape.z == series_shape.z 

158 and base_shape.t == series_shape.t 

159 and base_shape.c == series_shape.c 

160 ): 

161 

162 x_ratio = series_shape.x / base_shape.x 

163 y_ratio = series_shape.y / base_shape.y 

164 return x_ratio < 1.0 and math.isclose(x_ratio, y_ratio, rel_tol=0.01) 

165 else: 

166 return False