Coverage for qubalab/images/openslide_server.py: 87%

61 statements  

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

1import numpy as np 

2import dask.array as da 

3from pathlib import Path 

4from dataclasses import astuple 

5import warnings 

6try: 

7 import openslide 

8except ImportError as e: 

9 warnings.warn(f'Unable to import OpenSlide, will try TiffSlide instead') 

10 import tiffslide as openslide 

11from .image_server import ImageServer 

12from .metadata.image_metadata import ImageMetadata 

13from .metadata.pixel_calibration import PixelCalibration, PixelLength 

14from .metadata.image_shape import ImageShape 

15from .region_2d import Region2D 

16 

17 

18class OpenSlideServer(ImageServer): 

19 """ 

20 An image server that relies on OpenSlide (https://openslide.org/) to read RGB images. 

21 

22 This server may only be able to detect the full resolution of a pyramidal image. 

23 

24 OpenSlide provides some properties to define a rectangle bounding the non-empty region of the slide 

25 (see https://openslide.org/api/python/#standard-properties). If such properties are found, only this 

26 rectangle will be read (but note that this behaviour was not properly tested). 

27 """ 

28 

29 def __init__(self, path: str, strip_alpha=True, single_channel=False, limit_bounds=True, **kwargs): 

30 """ 

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

32 :param strip_alpha: whether to strip the alpha channel from the image 

33 :param single_channel: whether to keep only the first channel of the image 

34 :param limit_bounds: whether to only consider a rectangle bounding the non-empty region of the slide, 

35 if such rectangle is defined in the properties of the image 

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

37 """ 

38 super().__init__(**kwargs) 

39 self._reader = openslide.OpenSlide(path) 

40 self._path = path 

41 self._strip_alpha = strip_alpha 

42 self._single_channel = single_channel 

43 self._limit_bounds = limit_bounds 

44 

45 def close(self): 

46 self._reader.close() 

47 

48 def _build_metadata(self) -> ImageMetadata: 

49 n_channels = OpenSlideServer._get_n_channels(self._single_channel, self._strip_alpha) 

50 

51 full_bounds = (0, 0) + self._reader.dimensions 

52 bounds = (self._reader.properties.get(openslide.PROPERTY_NAME_BOUNDS_X), 

53 self._reader.properties.get(openslide.PROPERTY_NAME_BOUNDS_Y), 

54 self._reader.properties.get(openslide.PROPERTY_NAME_BOUNDS_WIDTH), 

55 self._reader.properties.get(openslide.PROPERTY_NAME_BOUNDS_HEIGHT)) 

56 if self._limit_bounds and not any(v is None for v in bounds): 

57 self._bounds = bounds 

58 w = self._bounds[3] 

59 h = self._bounds[2] 

60 shapes = tuple(ImageShape(x=int(w / d), y=int(h / d), c=n_channels) for d in self._reader.level_downsamples) 

61 else: 

62 self._bounds = full_bounds 

63 shapes = tuple(ImageShape(x=d[0], y=d[1], c=n_channels) for d in self._reader.level_dimensions) 

64 

65 return ImageMetadata( 

66 self._path, 

67 Path(self._path).name, 

68 shapes, 

69 OpenSlideServer._get_pixel_calibration( 

70 self._reader.properties.get(openslide.PROPERTY_NAME_MPP_X), 

71 self._reader.properties.get(openslide.PROPERTY_NAME_MPP_Y) 

72 ), 

73 True, 

74 np.uint8 

75 ) 

76 

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

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

79 assert z == 0, "OpenSlide can't read 3d images" 

80 assert t == 0, "OpenSlide can't read time-varying images" 

81 

82 # x and y are provided in the coordinate space of the level, while openslide 

83 # expect coordinates in the level 0 reference frame 

84 x = int(x * self.metadata.downsamples[level]) 

85 y = int(y * self.metadata.downsamples[level]) 

86 

87 # Map coordinates to bounding rectangle 

88 x += self._bounds[0] 

89 y += self._bounds[1] 

90 

91 image = self._reader.read_region((x, y), level, (width, height)) 

92 im = np.moveaxis(np.asarray(image), 2, 0) 

93 image.close() 

94 

95 # Return image, stripping alpha/converting to single-channel if needed 

96 return im[:self.metadata.n_channels, :, :] 

97 

98 @staticmethod 

99 def _get_n_channels(single_channel: bool, strip_alpha: bool): 

100 if single_channel: 

101 return 1 

102 elif strip_alpha: 

103 return 3 

104 else: 

105 return 4 

106 

107 @staticmethod 

108 def _get_pixel_calibration(pixel_width: str, pixel_height: str): 

109 if pixel_width is not None and pixel_height is not None: 

110 return PixelCalibration( 

111 length_x=PixelLength.create_microns(float(pixel_width)), 

112 length_y=PixelLength.create_microns(float(pixel_height)) 

113 ) 

114 else: 

115 return PixelCalibration()